+ Each scene constructs the same production-shaped fixture used by
+ tiles_proofs: full tiles cover the compacted prefix and
+ an in-memory tree owns the frontier. Thin wrappers record every
+ subtree_root attempt made by the real
+ ProofEngine.
+
+
+ Inclusion scenes are checked byte-for-byte against
+ Tree::path(). The consistency scene is verified with
+ ProofEngine::verify_consistency() before it reaches this
+ page.
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/proof-viz/make_viz.sh b/tools/proof-viz/make_viz.sh
new file mode 100755
index 0000000..a7bba5a
--- /dev/null
+++ b/tools/proof-viz/make_viz.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+repo_root=$(cd "$script_dir/../.." && pwd)
+output_dir=${1:-$script_dir}
+build_dir=${BUILD_DIR:-$repo_root/build/proof-viz}
+cxx=${CXX:-c++}
+
+mkdir -p "$build_dir" "$output_dir"
+output_dir=$(cd "$output_dir" && pwd)
+rm -f "$output_dir/data.js"
+"$cxx" -std=c++20 -O2 \
+ -I"$repo_root" \
+ -I"$repo_root/test" \
+ "$script_dir/proof_viz.cpp" \
+ -o "$build_dir/proof_viz"
+"$build_dir/proof_viz" "$script_dir/scenarios" "$output_dir/data.json"
+
+node --check "$script_dir/app.js"
+node - "$output_dir/data.json" <<'NODE'
+const fs = require("node:fs");
+const path = require("node:path");
+
+const data = JSON.parse(fs.readFileSync(path.resolve(process.argv[2]), "utf8"));
+const validAttempt = (attempt) => {
+ if (!["frontier", "tile"].includes(attempt.source)) return false;
+ if (!Number.isSafeInteger(attempt.level) || attempt.level < 0 || attempt.level > 52) return false;
+ if (!Number.isSafeInteger(attempt.index) || attempt.index < 0) return false;
+ const width = 2 ** attempt.level;
+ const lo = attempt.index * width;
+ if (!Number.isSafeInteger(width) || width < 1) return false;
+ if (!Number.isSafeInteger(lo) || lo < 0 || lo > Number.MAX_SAFE_INTEGER - width) return false;
+ return typeof attempt.success === "boolean";
+};
+const validScenario = (scenario) =>
+ ["id", "title", "description", "takeaway"].every(
+ (field) => typeof scenario[field] === "string"
+ ) &&
+ Number.isSafeInteger(scenario.leaves) && scenario.leaves > 0 &&
+ Number.isSafeInteger(scenario.focus) &&
+ scenario.focus >= 0 && scenario.focus < scenario.leaves &&
+ (scenario.secondIndex === undefined ||
+ (Number.isSafeInteger(scenario.secondIndex) &&
+ scenario.secondIndex > scenario.focus &&
+ scenario.secondIndex < scenario.leaves)) &&
+ Array.isArray(scenario.attempts) &&
+ scenario.attempts.length > 0 &&
+ scenario.attempts.every(validAttempt);
+
+if (
+ !data ||
+ data.schemaVersion !== 1 ||
+ !Number.isSafeInteger(data.tileWidth) || data.tileWidth < 1 ||
+ !Array.isArray(data.scenarios) || data.scenarios.length === 0 ||
+ !data.scenarios.every(validScenario)
+) {
+ throw new Error("invalid proof visualization data");
+}
+
+console.log(`validated ${data.scenarios.length} proof visualization scenarios`);
+NODE
+
+if [[ "$output_dir" != "$script_dir" ]]; then
+ cp "$script_dir/index.html" "$script_dir/styles.css" "$script_dir/app.js" \
+ "$output_dir/"
+fi
\ No newline at end of file
diff --git a/tools/proof-viz/proof_viz.cpp b/tools/proof-viz/proof_viz.cpp
new file mode 100644
index 0000000..dca94c3
--- /dev/null
+++ b/tools/proof-viz/proof_viz.cpp
@@ -0,0 +1,393 @@
+#include "tiles_test_util.h"
+#include "util.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace fs = std::filesystem;
+
+using merkle::Hash;
+using merkle::tiles::CombinedHashSource;
+using merkle::tiles::HashSource;
+using merkle::tiles::MemoryHashSource;
+using merkle::tiles::ProofEngine;
+using merkle::tiles::TILE_WIDTH;
+using merkle::tiles::TileHashSource;
+using merkle::tiles::TileStore;
+using merkle::tiles::TileWriter;
+
+struct Attempt
+{
+ std::string source;
+ uint8_t level;
+ uint64_t index;
+ bool success;
+};
+
+using Attempts = std::vector;
+
+class TracingSource : public HashSource
+{
+public:
+ TracingSource(
+ const HashSource& source, std::string name, Attempts& attempts) :
+ source(source), name(std::move(name)), attempts(attempts)
+ {}
+
+ bool subtree_root(uint8_t level, uint64_t index, Hash& out) const override
+ {
+ const bool success = source.subtree_root(level, index, out);
+ attempts.push_back(Attempt{name, level, index, success});
+ return success;
+ }
+
+private:
+ const HashSource& source;
+ std::string name;
+ Attempts& attempts;
+};
+
+struct Scenario
+{
+ std::string id;
+ std::string title;
+ std::string description;
+ std::string takeaway;
+ uint64_t order;
+ uint64_t leaves;
+ uint64_t focus;
+ std::optional second_index;
+ Attempts attempts;
+};
+
+static Scenario read_scenario(const fs::path& path)
+{
+ static constexpr std::array keys = {
+ "name",
+ "proof",
+ "title",
+ "description",
+ "takeaway",
+ "order",
+ "leaves",
+ "focus",
+ "second"};
+ std::ifstream stream(path);
+ if (!stream)
+ {
+ throw std::runtime_error("could not open " + path.string());
+ }
+ const auto error = [&](size_t line, const std::string& message) {
+ return std::runtime_error(
+ path.string() + ":" + std::to_string(line) + ": " + message);
+ };
+
+ std::array values;
+ for (size_t index = 0; index < keys.size(); index++)
+ {
+ std::string line;
+ if (!std::getline(stream, line))
+ {
+ throw error(index + 1, "expected key " + std::string(keys[index]));
+ }
+ if (!line.empty() && line.back() == '\r')
+ {
+ line.pop_back();
+ }
+ const std::string prefix = std::string(keys[index]) + ": ";
+ if (line.compare(0, prefix.size(), prefix) != 0)
+ {
+ throw error(index + 1, "expected key " + std::string(keys[index]));
+ }
+ values[index] = line.substr(prefix.size());
+ if (
+ values[index].empty() || values[index].front() == ' ' ||
+ values[index].back() == ' ' ||
+ std::any_of(
+ values[index].begin(),
+ values[index].end(),
+ [](unsigned char character) { return character < 0x20; }))
+ {
+ throw error(index + 1, "invalid value for " + std::string(keys[index]));
+ }
+ }
+ std::string extra;
+ if (std::getline(stream, extra))
+ {
+ throw error(keys.size() + 1, "unexpected extra line");
+ }
+
+ const auto number = [&](size_t index) {
+ uint64_t value = 0;
+ const auto [end, parse_error] = std::from_chars(
+ values[index].data(), values[index].data() + values[index].size(), value);
+ if (
+ parse_error != std::errc{} ||
+ end != values[index].data() + values[index].size())
+ {
+ throw error(
+ index + 1, "invalid unsigned integer for " + std::string(keys[index]));
+ }
+ return value;
+ };
+ const std::string& proof = values[1];
+ if (proof != "inclusion" && proof != "consistency")
+ {
+ throw error(2, "proof must be inclusion or consistency");
+ }
+ Scenario scenario{
+ std::move(values[0]),
+ std::move(values[2]),
+ std::move(values[3]),
+ std::move(values[4]),
+ number(5),
+ number(6),
+ number(7),
+ std::nullopt,
+ {}};
+ const std::string& second = values[8];
+
+ if (proof == "inclusion")
+ {
+ if (second != "none")
+ {
+ throw error(9, "inclusion proof requires second: none");
+ }
+ }
+ else
+ {
+ if (second == "none")
+ {
+ throw error(9, "consistency proof requires a second index");
+ }
+ scenario.second_index = number(8);
+ }
+
+ if (path.stem() != scenario.id)
+ {
+ throw error(1, "name must match the file name");
+ }
+ if (scenario.order == 0)
+ {
+ throw error(6, "order must be greater than zero");
+ }
+ if (scenario.leaves == 0)
+ {
+ throw error(7, "leaves must be greater than zero");
+ }
+ if (scenario.focus >= scenario.leaves)
+ {
+ throw error(8, "focus must be less than leaves");
+ }
+ if (
+ scenario.second_index &&
+ (scenario.focus >= *scenario.second_index ||
+ *scenario.second_index >= scenario.leaves))
+ {
+ throw error(9, "invalid consistency indices");
+ }
+ return scenario;
+}
+
+static std::vector read_scenarios(const fs::path& directory)
+{
+ if (!fs::is_directory(directory))
+ {
+ throw std::runtime_error("not a scenario directory: " + directory.string());
+ }
+
+ std::vector scenarios;
+ for (const fs::directory_entry& entry : fs::directory_iterator(directory))
+ {
+ if (!entry.is_regular_file() || entry.path().extension() != ".scenario")
+ {
+ continue;
+ }
+ scenarios.push_back(read_scenario(entry.path()));
+ }
+
+ if (scenarios.empty())
+ {
+ throw std::runtime_error(
+ "scenario directory is empty: " + directory.string());
+ }
+ std::sort(
+ scenarios.begin(),
+ scenarios.end(),
+ [](const Scenario& left, const Scenario& right) {
+ return left.order < right.order;
+ });
+ for (size_t index = 0; index < scenarios.size(); index++)
+ {
+ if (scenarios[index].order != index + 1)
+ {
+ throw std::runtime_error(
+ "scenario order values must be contiguous from 1");
+ }
+ }
+ return scenarios;
+}
+
+static void run_scenario(
+ const fs::path& directory,
+ Scenario& scenario,
+ const std::vector& hashes)
+{
+ TileStore store(directory);
+ TileWriter writer(store);
+ const auto leaf_at = [&](uint64_t index) -> const Hash& {
+ return hashes.at(static_cast(index));
+ };
+ writer.write_up_to(scenario.leaves, leaf_at);
+
+ merkle::Tree oracle;
+ merkle::Tree frontier;
+ for (uint64_t index = 0; index < scenario.leaves; index++)
+ {
+ oracle.insert(hashes.at(static_cast(index)));
+ frontier.insert(hashes.at(static_cast(index)));
+ }
+ const uint64_t covered = (scenario.leaves / TILE_WIDTH) * TILE_WIDTH;
+ uint64_t frontier_start = covered;
+ if (frontier_start >= scenario.leaves)
+ {
+ frontier_start = scenario.leaves - 1;
+ }
+ if (frontier_start > 0)
+ {
+ frontier.flush_to(static_cast(frontier_start));
+ }
+
+ const MemoryHashSource memory(frontier);
+ const TileHashSource tiles(store, covered);
+ const TracingSource traced_memory(memory, "frontier", scenario.attempts);
+ const TracingSource traced_tiles(tiles, "tile", scenario.attempts);
+ const CombinedHashSource combined(traced_memory, traced_tiles);
+ const ProofEngine engine(combined);
+ const CombinedHashSource control_combined(memory, tiles);
+ const ProofEngine control_engine(control_combined);
+
+ if (!scenario.second_index)
+ {
+ const Hash root = oracle.root();
+ const auto proof = engine.inclusion_proof(scenario.focus, scenario.leaves);
+ const auto control_proof =
+ control_engine.inclusion_proof(scenario.focus, scenario.leaves);
+ if (
+ !proof->verify(root) || *proof != *oracle.path(scenario.focus) ||
+ *proof != *control_proof)
+ {
+ throw std::runtime_error("inclusion proof mismatch for " + scenario.id);
+ }
+ }
+ else
+ {
+ const uint64_t second_index = *scenario.second_index;
+ const Hash first_root = *oracle.past_root(scenario.focus);
+ const Hash second_root = *oracle.past_root(second_index);
+ const auto proof =
+ engine.consistency_proof_from_indices(scenario.focus, second_index);
+ const auto control_proof = control_engine.consistency_proof_from_indices(
+ scenario.focus, second_index);
+ if (
+ proof != control_proof ||
+ !ProofEngine::verify_consistency(
+ scenario.focus + 1, second_index + 1, first_root, second_root, proof))
+ {
+ throw std::runtime_error("consistency proof mismatch for " + scenario.id);
+ }
+ }
+}
+
+static void write_data(
+ const fs::path& output, const std::vector& scenarios)
+{
+ std::ofstream stream(output);
+ if (!stream)
+ {
+ throw std::runtime_error("could not open " + output.string());
+ }
+
+ stream << "{\"schemaVersion\":1,\"tileWidth\":" << TILE_WIDTH
+ << ",\"scenarios\":[";
+ for (size_t scenario_index = 0; scenario_index < scenarios.size();
+ scenario_index++)
+ {
+ const Scenario& scenario = scenarios[scenario_index];
+ stream << (scenario_index == 0 ? "{" : ",{");
+ stream << "\"id\":" << std::quoted(scenario.id);
+ stream << ",\"title\":" << std::quoted(scenario.title);
+ stream << ",\"description\":" << std::quoted(scenario.description);
+ stream << ",\"takeaway\":" << std::quoted(scenario.takeaway);
+ stream << ",\"leaves\":" << scenario.leaves;
+ stream << ",\"focus\":" << scenario.focus;
+ if (scenario.second_index)
+ {
+ stream << ",\"secondIndex\":" << *scenario.second_index;
+ }
+ stream << ",\"attempts\":[";
+ for (size_t attempt_index = 0; attempt_index < scenario.attempts.size();
+ attempt_index++)
+ {
+ const Attempt& attempt = scenario.attempts[attempt_index];
+ stream << (attempt_index == 0 ? "{" : ",{");
+ stream << "\"source\":" << std::quoted(attempt.source)
+ << ",\"level\":" << static_cast(attempt.level)
+ << ",\"index\":" << attempt.index
+ << ",\"success\":" << (attempt.success ? "true" : "false") << "}";
+ }
+ stream << "]}";
+ }
+ stream << "]}\n";
+}
+
+int main(int argc, char** argv)
+{
+ try
+ {
+ if (argc != 3)
+ {
+ throw std::runtime_error(
+ "usage: proof_viz SCENARIO_DIRECTORY OUTPUT_JSON");
+ }
+ const fs::path scenario_directory = argv[1];
+ const fs::path output = argv[2];
+ std::vector scenarios = read_scenarios(scenario_directory);
+ size_t max_leaves = 0;
+ for (const Scenario& scenario : scenarios)
+ {
+ max_leaves = std::max(max_leaves, static_cast(scenario.leaves));
+ }
+ const TemporaryDirectory temporary_directory("merklecpp_proof_viz");
+ const auto hashes = make_hashes(max_leaves);
+
+ for (Scenario& scenario : scenarios)
+ {
+ run_scenario(temporary_directory.path() / scenario.id, scenario, hashes);
+ std::cout << scenario.id << ": " << scenario.attempts.size()
+ << " source attempts\n";
+ }
+ write_data(output, scenarios);
+ std::cout << "wrote " << output << '\n';
+ }
+ catch (const std::exception& error)
+ {
+ std::cerr << "proof visualization failed: " << error.what() << '\n';
+ return 1;
+ }
+ return 0;
+}
\ No newline at end of file
diff --git a/tools/proof-viz/scenarios/boundary-overlap.scenario b/tools/proof-viz/scenarios/boundary-overlap.scenario
new file mode 100644
index 0000000..7325eb9
--- /dev/null
+++ b/tools/proof-viz/scenarios/boundary-overlap.scenario
@@ -0,0 +1,9 @@
+name: boundary-overlap
+proof: inclusion
+title: The exact two-tile boundary
+description: At 512 leaves, both tiles are complete. Compaction deliberately retains the final leaf in memory because merklecpp never flushes the entire tree.
+takeaway: The blue target pixel has a green overlap mark: both sources can answer it, but CombinedHashSource chooses memory first; its siblings come from tiles.
+order: 5
+leaves: 512
+focus: 511
+second: none
diff --git a/tools/proof-viz/scenarios/consistency-arbitrary-crossing.scenario b/tools/proof-viz/scenarios/consistency-arbitrary-crossing.scenario
new file mode 100644
index 0000000..ebee77b
--- /dev/null
+++ b/tools/proof-viz/scenarios/consistency-arbitrary-crossing.scenario
@@ -0,0 +1,9 @@
+name: consistency-arbitrary-crossing
+proof: consistency
+title: An arbitrary leaf pair crosses the boundary
+description: Leaves A=91 and B=287 end the 92- and 288-leaf tree states in a 300-leaf backing tree.
+takeaway: The tree ending at A sits wholly in the tiled past, while the tree ending at B requires both the durable tile and resident frontier.
+order: 10
+leaves: 300
+focus: 91
+second: 287
diff --git a/tools/proof-viz/scenarios/consistency-boundary.scenario b/tools/proof-viz/scenarios/consistency-boundary.scenario
new file mode 100644
index 0000000..59a40aa
--- /dev/null
+++ b/tools/proof-viz/scenarios/consistency-boundary.scenario
@@ -0,0 +1,9 @@
+name: consistency-boundary
+proof: consistency
+title: Consistency across the flush line
+description: Leaf indices 255 and 299 identify the 256- and 300-leaf checkpoints on opposite sides of the flush line.
+takeaway: There is no target leaf. Red marks the SUBPROOF recursion and its proof components, combining the old green root with blue hashes from the new frontier.
+order: 7
+leaves: 300
+focus: 255
+second: 299
diff --git a/tools/proof-viz/scenarios/consistency-frontier-only.scenario b/tools/proof-viz/scenarios/consistency-frontier-only.scenario
new file mode 100644
index 0000000..76d5e7e
--- /dev/null
+++ b/tools/proof-viz/scenarios/consistency-frontier-only.scenario
@@ -0,0 +1,9 @@
+name: consistency-frontier-only
+proof: consistency
+title: Two leaves before tiling
+description: Leaves A=47 and B=149 end the 48- and 150-leaf tree states inside a 192-leaf backing tree that has not completed its first tile.
+takeaway: Every consistency component is answered by the resident frontier, even though neither selected leaf is the backing tree's current end.
+order: 8
+leaves: 192
+focus: 47
+second: 149
diff --git a/tools/proof-viz/scenarios/consistency-frontier-pair.scenario b/tools/proof-viz/scenarios/consistency-frontier-pair.scenario
new file mode 100644
index 0000000..b8777bc
--- /dev/null
+++ b/tools/proof-viz/scenarios/consistency-frontier-pair.scenario
@@ -0,0 +1,9 @@
+name: consistency-frontier-pair
+proof: consistency
+title: Two leaves inside one frontier
+description: Leaves A=269 and B=493 end two tree states beyond the first tile in a 511-leaf backing tree.
+takeaway: Both roots include tiled history, but their changing suffixes are assembled from different portions of the same blue frontier.
+order: 11
+leaves: 511
+focus: 269
+second: 493
diff --git a/tools/proof-viz/scenarios/consistency-tiled-history.scenario b/tools/proof-viz/scenarios/consistency-tiled-history.scenario
new file mode 100644
index 0000000..043dc91
--- /dev/null
+++ b/tools/proof-viz/scenarios/consistency-tiled-history.scenario
@@ -0,0 +1,9 @@
+name: consistency-tiled-history
+proof: consistency
+title: Two leaves in tiled history
+description: Leaves A=127 and B=399 end historical tree states inside a 513-leaf backing tree with two durable tiles.
+takeaway: The current frontier begins after both checkpoints, so the complete consistency proof is recovered from green tile storage.
+order: 9
+leaves: 513
+focus: 127
+second: 399
diff --git a/tools/proof-viz/scenarios/frontier-only.scenario b/tools/proof-viz/scenarios/frontier-only.scenario
new file mode 100644
index 0000000..b974190
--- /dev/null
+++ b/tools/proof-viz/scenarios/frontier-only.scenario
@@ -0,0 +1,9 @@
+name: frontier-only
+proof: inclusion
+title: Before the first tile
+description: With 192 leaves, no full 256-entry tile exists. Every subtree request is answered by the resident tree.
+takeaway: The red route never changes source: the target, proof siblings, and root reduction all stay in the blue frontier.
+order: 1
+leaves: 192
+focus: 37
+second: none
diff --git a/tools/proof-viz/scenarios/frontier-to-tile.scenario b/tools/proof-viz/scenarios/frontier-to-tile.scenario
new file mode 100644
index 0000000..545fe2c
--- /dev/null
+++ b/tools/proof-viz/scenarios/frontier-to-tile.scenario
@@ -0,0 +1,9 @@
+name: frontier-to-tile
+proof: inclusion
+title: A frontier proof reaches backward
+description: Leaf 271 sits in the 44-leaf resident frontier beyond one completed tile.
+takeaway: Most local siblings are blue, but the final left sibling is the entire 256-leaf tiled prefix, resolved in one green lookup.
+order: 3
+leaves: 300
+focus: 271
+second: none
diff --git a/tools/proof-viz/scenarios/near-boundary.scenario b/tools/proof-viz/scenarios/near-boundary.scenario
new file mode 100644
index 0000000..5e6c226
--- /dev/null
+++ b/tools/proof-viz/scenarios/near-boundary.scenario
@@ -0,0 +1,9 @@
+name: near-boundary
+proof: inclusion
+title: One leaf short of the next tile
+description: At 511 leaves, the first tile is durable and the next 255 leaves still form the frontier.
+takeaway: The long blue frontier is not itself a perfect subtree. The engine assembles it from smaller resident ranges before joining the green past.
+order: 4
+leaves: 511
+focus: 510
+second: none
diff --git a/tools/proof-viz/scenarios/next-frontier.scenario b/tools/proof-viz/scenarios/next-frontier.scenario
new file mode 100644
index 0000000..79495c5
--- /dev/null
+++ b/tools/proof-viz/scenarios/next-frontier.scenario
@@ -0,0 +1,9 @@
+name: next-frontier
+proof: inclusion
+title: A new frontier after two tiles
+description: Leaf 512 is the first resident leaf after a 512-leaf tiled prefix.
+takeaway: The proof begins with one blue leaf and crosses immediately to a single green 512-leaf sibling that represents both completed tiles.
+order: 6
+leaves: 513
+focus: 512
+second: none
diff --git a/tools/proof-viz/scenarios/tile-to-frontier.scenario b/tools/proof-viz/scenarios/tile-to-frontier.scenario
new file mode 100644
index 0000000..5d9592f
--- /dev/null
+++ b/tools/proof-viz/scenarios/tile-to-frontier.scenario
@@ -0,0 +1,9 @@
+name: tile-to-frontier
+proof: inclusion
+title: A proof leaves the tiled past
+description: Leaf 42 is already represented by the first full tile, while leaves 256-299 remain resident in memory.
+takeaway: The proof starts in green tile storage, then needs blue frontier subtrees to complete the current 300-leaf root.
+order: 2
+leaves: 300
+focus: 42
+second: none
diff --git a/tools/proof-viz/styles.css b/tools/proof-viz/styles.css
new file mode 100644
index 0000000..bb0ab68
--- /dev/null
+++ b/tools/proof-viz/styles.css
@@ -0,0 +1,524 @@
+:root {
+ color-scheme: light;
+ --page-gutter: clamp(12px, 1.5vw, 40px);
+ --paper: #fff;
+ --paper-strong: #fff;
+ --surface: #f3f4f5;
+ --ink: #222832;
+ --muted: #48566b;
+ --line: #d1d5da;
+ --primary: #0a7d91;
+ --inline-code: #085d6c;
+ --tile: #00843f;
+ --frontier: #276be9;
+ --computed: #9ca4af;
+ --route: #d72d47;
+ --query: #f66a0a;
+ --endpoint: var(--ink);
+ --edge: rgba(72, 86, 107, 0.2);
+ --boundary: rgba(34, 40, 50, 0.48);
+ --tooltip: rgba(34, 40, 50, 0.96);
+ --shadow: rgba(0, 0, 0, 0.1);
+}
+
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --paper: #14181e;
+ --paper-strong: #222832;
+ --surface: #29313d;
+ --ink: #ced6dd;
+ --muted: #9ca4af;
+ --line: #48566b;
+ --primary: #3fb1c5;
+ --inline-code: #f3c7ee;
+ --tile: #5fb488;
+ --frontier: #79a3f2;
+ --computed: #677384;
+ --route: #e78894;
+ --query: #ff9245;
+ --edge: rgba(156, 164, 175, 0.22);
+ --boundary: rgba(206, 214, 221, 0.52);
+ --tooltip: rgba(20, 24, 30, 0.97);
+ --shadow: rgba(0, 0, 0, 0.2);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ color: var(--ink);
+ background-color: var(--paper);
+ font-family: system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
+ "Segoe UI Symbol", "Noto Color Emoji";
+ font-size: 16px;
+ line-height: 1.55;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button:focus-visible,
+input:focus-visible {
+ outline: 3px solid color-mix(in srgb, var(--primary) 55%, transparent);
+ outline-offset: 2px;
+}
+
+code {
+ padding: 0.1em 0.25em;
+ border: 1px solid var(--line);
+ border-radius: 2px;
+ color: var(--inline-code);
+ background: var(--surface);
+ font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
+ Consolas, "DejaVu Sans Mono", monospace;
+ font-size: 0.875em;
+}
+
+.masthead {
+ border-bottom: 1px solid var(--line);
+}
+
+.masthead__inner,
+.control-band__inner,
+.scene__inner,
+.method {
+ padding-inline: var(--page-gutter);
+}
+
+.masthead__inner {
+ padding-block: 42px;
+}
+
+.identity {
+ margin: 0 0 24px;
+}
+
+.identity,
+.section-label,
+.scene__type {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.masthead__copy {
+ max-width: 1000px;
+}
+
+h1,
+h2 {
+ font-weight: 600;
+}
+
+h1 {
+ max-width: 940px;
+ margin: 0;
+ font-size: clamp(40px, 5vw, 72px);
+ line-height: 1;
+}
+
+.masthead__copy>p {
+ max-width: 530px;
+ margin: 20px 0 0;
+ color: var(--muted);
+ font-size: 17px;
+}
+
+.masthead__stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 32px;
+ margin: 32px 0 0;
+}
+
+.scene__facts dt {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.masthead__stats span {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.masthead__stats b {
+ color: var(--ink);
+}
+
+.control-band {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ border-bottom: 1px solid var(--line);
+ background: var(--paper);
+}
+
+.control-band__inner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding-block: 14px;
+}
+
+.segmented {
+ display: inline-grid;
+ grid-template-columns: repeat(3, auto);
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.segmented button {
+ min-height: 38px;
+ padding: 7px 16px;
+ border: 0;
+ border-right: 1px solid var(--line);
+ color: var(--ink);
+ background: transparent;
+ cursor: pointer;
+}
+
+.segmented button:last-child {
+ border-right: 0;
+}
+
+.segmented button.is-active {
+ color: #fff;
+ background: var(--primary);
+}
+
+.toggles {
+ display: flex;
+ gap: 22px;
+}
+
+.toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.toggle input {
+ width: 15px;
+ height: 15px;
+ margin: 0;
+ accent-color: var(--primary);
+}
+
+.legend-band {
+ padding: 28px var(--page-gutter);
+ border-bottom: 1px solid var(--line);
+ background: var(--surface);
+}
+
+.method h2 {
+ margin: 5px 0 0;
+ font-size: 31px;
+ line-height: 1.1;
+}
+
+.legend {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 14px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.legend li {
+ display: grid;
+ grid-template-columns: 14px 1fr;
+ gap: 1px 9px;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.35;
+}
+
+.legend b {
+ color: var(--ink);
+ font-size: 12px;
+}
+
+.swatch {
+ grid-row: span 2;
+ width: 12px;
+ height: 12px;
+ margin-top: 2px;
+ border: 1px solid transparent;
+}
+
+.swatch--tile {
+ background: var(--tile);
+}
+
+.swatch--frontier {
+ background: var(--frontier);
+}
+
+.swatch--computed {
+ background: var(--computed);
+}
+
+.swatch--route {
+ background: var(--route);
+}
+
+.swatch--query {
+ border: 3px solid var(--query);
+ background: var(--paper-strong);
+}
+
+.swatch--endpoint {
+ border: 2px solid var(--ink);
+ border-radius: 50%;
+ background: var(--paper-strong);
+}
+
+.scene {
+ border-bottom: 1px solid var(--line);
+ background: var(--paper);
+}
+
+.load-status {
+ margin: 0;
+ padding: 64px var(--page-gutter);
+ color: var(--muted);
+}
+
+.load-status--error {
+ color: var(--route);
+}
+
+.scene__inner {
+ display: grid;
+ grid-template-columns: minmax(250px, 320px) minmax(0, 1fr);
+ gap: 32px;
+ padding-block: 48px;
+}
+
+.scene__index {
+ color: var(--route);
+}
+
+.scene h2 {
+ margin: 11px 0 18px;
+ font-size: 38px;
+ line-height: 1.05;
+}
+
+.scene__description {
+ margin: 0;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.scene__facts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
+ gap: 14px 18px;
+ margin: 24px 0;
+ padding-block: 14px;
+ border-block: 1px solid var(--line);
+}
+
+.scene__facts dd {
+ margin: 3px 0 0;
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.scene__takeaway {
+ margin: 0;
+ padding-left: 15px;
+ border-left: 3px solid var(--query);
+ color: var(--ink);
+ font-size: 17px;
+ line-height: 1.4;
+}
+
+.scene__visual {
+ min-width: 0;
+}
+
+.visual-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 10px;
+ color: var(--muted);
+ font-size: 11px;
+ text-transform: uppercase;
+}
+
+.visual-head strong {
+ color: var(--ink);
+}
+
+.canvas-scroll {
+ overflow-x: auto;
+ border: 1px solid var(--line);
+ background-color: var(--paper-strong);
+}
+
+.tree-canvas {
+ display: block;
+ cursor: crosshair;
+}
+
+.attempts {
+ margin-top: 18px;
+ padding-top: 14px;
+ border-top: 1px solid var(--line);
+}
+
+.attempts__head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 9px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.attempts__head strong {
+ color: var(--ink);
+ text-transform: uppercase;
+}
+
+.attempts__track {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.attempt {
+ width: 13px;
+ height: 13px;
+ border: 2px solid currentColor;
+}
+
+.attempt--frontier {
+ color: var(--frontier);
+}
+
+.attempt--tile {
+ color: var(--tile);
+}
+
+.attempt.is-success {
+ background: currentColor;
+}
+
+.node-tooltip {
+ position: fixed;
+ z-index: 50;
+ max-width: 280px;
+ padding: 10px 12px;
+ border: 1px solid var(--ink);
+ border-radius: 4px;
+ color: var(--paper-strong);
+ background: var(--tooltip);
+ box-shadow: 0 8px 24px var(--shadow);
+ pointer-events: none;
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.node-tooltip b {
+ display: block;
+ margin-bottom: 3px;
+ color: #fff;
+ font-size: 12px;
+}
+
+.method {
+ display: grid;
+ grid-template-columns: minmax(200px, 280px) minmax(0, 760px);
+ gap: 32px;
+ padding-block: 48px;
+}
+
+.method__copy {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 24px;
+ color: var(--muted);
+}
+
+.method__copy p {
+ margin: 0;
+}
+
+@media (max-width: 760px) {
+ body {
+ font-size: 14px;
+ }
+
+ .scene__inner,
+ .method {
+ grid-template-columns: 1fr;
+ }
+
+ .masthead__inner,
+ .scene__inner,
+ .method {
+ padding-block: 32px;
+ }
+
+ h1 {
+ font-size: 40px;
+ }
+
+ .control-band {
+ position: static;
+ }
+
+ .control-band__inner {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .segmented {
+ width: 100%;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .toggles {
+ justify-content: space-between;
+ }
+
+ .visual-head,
+ .attempts__head {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 3px;
+ }
+}
+
+@media print {
+ .control-band {
+ display: none;
+ }
+
+ .scene {
+ break-inside: avoid;
+ }
+
+ .canvas-scroll {
+ overflow: hidden;
+ }
+}
\ No newline at end of file