Identities for entities did.bot
agent llm did
README.md

site #

The did.bot website: marketing pages, the prose documentation, and the Rust API reference, built into one static tree by Astro.

What lives where #

  • src/pages/index.astro — the landing page: a scroll-driven, deterministic 2-D Ising-model simulation that crystallises into the "did.bot" wordmark. This is the one page on the site where the full, live simulation runs — see "The landing page's simulation" below, and "How the Ising direction was chosen" for why this direction and not one of the five others tried.
  • src/pages/features/, src/pages/architecture/, src/pages/about/ — the three sections that have subpages. Each is exactly two routes, index.astro and [slug].astro, both reading one data file; the pages underneath are whatever that file's list holds, so adding one is a data edit and never a routing edit. See "One file per page" below.
  • src/pages/*.astro (the rest) — get started, privacy, terms, 404.
  • src/config/nav.ts — the header and footer nav, as data. This is the one place to add, remove or reorder a header or footer link — Header.astro, Footer.astro and the landing page's own header/footer all render from headerNav/footerNav here, so a link list never needs editing in more than one place.
  • src/config/anim.ts — every landing-page animation parameter that is not part of the physics schedule itself (lattice size, camera/zoom pacing, the reveal band's position in scroll space, mesh/caption timing, the free-run threshold). This is the file to open to tune the simulation without touching Rust or the ?tune=1 panel. The schedule itself (temperature, field strength/ramp, sweep count) is not here — see the next section for why, and where it actually lives.
  • src/pages/docs/ — renders the repository's ../docs/*.md at /docs/. The markdown itself is not here: scripts/prepare-docs.mjs copies it from ../docs/ into the gitignored src/content/docs/, rewriting its relative links so they resolve on the built site (see that script's own comments for the rewriting rules). Never hand-edit anything under src/content/docs/ — it is regenerated on every build and dev run and the edit will vanish.
  • /api/ — not built by this project at all. ../scripts/build-site.sh copies cargo doc's own target/doc/ output here after the Astro build. There is no Astro page for it; site/tests/check-links.mjs only checks that the mount point itself is reachable, not rustdoc's own internal links.
  • src/data/*.ts — the marketing copy, one file per page or section: features.ts, architecture.ts, about.ts, get-started.ts, privacy.ts, terms.ts, not-found.ts. Each is the only place its page's words live. section.ts holds the shape the three sectioned ones share and filler.ts the PH() marker for copy nobody has written yet — see "One file per page" below.
  • src/data/landing-beats.ts — the landing page's copy: its beats (hero, the long-dwell captions, the post-reveal footer line) as an ordered list. This is the one place to add, remove, reorder or reword a landing-page beat: the page renders the list, and the scroller's height and each beat's position down it are derived from the list's length.
  • src/data/testimonials.ts — provisional real copy: genuine, attributable quotations (three testimonials, one each for Opus, Fable and Haiku) sitting in slots expected to be refilled later. See "One file per page" below for how this differs from an invented placeholder and why that difference matters.
  • crates/didbot-site-anim (at the repo root, not under site/) — the landing page's simulation engine (the Ising lattice, the deterministic checkpoint-and-replay anneal, free-run), in Rust compiled to wasm by ../scripts/build-wasm.sh. Ordinary cargo test -p didbot-site-anim covers the simulation itself, no browser needed.

The landing page's simulation #

src/pages/index.astro is not built like the other pages: it is a complete, self-contained HTML document with its own <style> and <script type="module">, not a component tree under BaseLayout, because the simulation owns the whole viewport (a fixed <canvas> behind everything else) for the length of the scroller (whose height follows from how many beats src/data/landing-beats.ts holds). Two things are deliberately split across languages:

  • The anneal schedule (t_high, t_low, field_start, field_ramp, field_strength, anneal_temperature, and the total sweep count) is Rust's alone: crates/didbot-site-anim/src/anneal.rs's AnnealSchedule::default() and DEFAULT_TOTAL_SWEEPS. The page reads these seven numbers from the wasm module at startup (default_anneal_schedule(), called through anim.ts's loadDefaultSchedule) rather than hand-copying them into TypeScript — that hand-copy used to be a real bug (the two copies drifted), and a native cargo test (default_anneal_schedule_values_are_the_schedule_and_the_sweep_count) now asserts the wasm binding is nothing but a forward of the same Rust function the crystallisation property test itself calls, so it is structurally impossible to change one without the other.
  • Everything else — lattice size, camera pacing, the reveal band's scroll-space position, mesh/caption timing, the free-run threshold — is page composition, not physics, and lives in src/config/anim.ts.

To try a new value: open the page with ?tune=1 (e.g. http://localhost:4321/?tune=1) for a dev-only panel with a slider per schedule parameter (plus reveal timing, easing, and the free-run threshold), each with a one-sentence description of what it visibly changes, its own reset-to-default button, and a live "crystallisation agreement" readout (the same measure cargo test's property test checks). The panel's copyable settings-line at the bottom is how a value found there gets reported back — turn it into an edit to anim.ts (the non-schedule numbers) or anneal.rs's AnnealSchedule::default() (if it should become the actual shipped default; re-run cargo test -p didbot-site-anim afterward — see "the crystallisation threshold" below).

The crystallisation threshold. the_lattice_reliably_crystallises_into_did_bot (in anneal.rs) requires the anneal's final state to agree with the did.bot mask on at least 93% of lattice sites, for every one of twelve seeds. That 93% is not a physical constant — it is calibrated to the current shipped schedule plus a small margin, purely to catch a future regression, not to certify "93% is legible." If you change the schedule and this test fails, the options are: tune further until it passes at the current threshold, or — if the new schedule is one the owner actually wants and it's only just below the line — recalibrate the threshold the same way this round did, documenting the new number's real per-seed measurements in the constant's own doc comment, not just lowering it quietly.

Drift and free-run. Deterministic replay is a pure function of scroll position, which means a page nobody is scrolling does not move. So when scrolling stops, the frame is handed to a separate scratch lattice (crates/didbot-site-anim/src/anneal.rs's FreeRunLattice, spawned by spawn_free_run_at) seeded from the sweep the reader stopped on and held at that sweep's own temperature and field strength, and it keeps sweeping there. That happens animConfig.idleDriftDelayMs after the last scroll event anywhere on the page, and immediately past animConfig.freeRunThreshold of scroll (fraction of the scroller's own height, not the whole page), where no further scroll is coming — the end state churns while the field holds the letters, exactly as it always has. The sweep rate eases up from a standstill over animConfig.driftRampMs rather than switching on, which is what makes this read as the lattice still being alive instead of a glitch; that ramp is also why the delay before it is only a couple of frames, since a drift that starts and is immediately cancelled by the next flick of a scroll wheel never moved anything. The scratch lattice is a copy, so the next scroll event discards it and resumes deterministic replay exactly where it left off, however long it drifted; drifting_from_a_mid_scroll_sweep_moves_without_disturbing_the_replay is the test for that at an arbitrary sweep, alongside the end-state one. Drift never engages under prefers-reduced-motion, and pauses while the tab is hidden. free_running_holds_the_wordmark_legible_over_ten_thousand_sweeps is the test that matters here: it samples agreement repeatedly across ten thousand free sweeps rather than only checking one endpoint, because a schedule whose field is just strong enough to reach the mask once but too weak to hold it would pass every other test and still dissolve into noise after a reader leaves the tab open.

How the Ising direction was chosen #

The landing page went through six built, working visual directions before this one shipped. All six shared one engine crate (crates/didbot-site-anim) behind the same rlib/cdylib split; a direction being cut never meant deleting its engine, since each is real, tested code and one (wave.rs) already has a second consumer outside this site.

direction palette idea outcome
Ising lattice (ising.rs, anneal.rs) black/green 2-D Metropolis lattice; scroll drives temperature down through a magnetic field biased toward the wordmark, so the field fills the screen at every scale before crystallising into "did.bot" shipped, this page
Multi-scale automaton black/green an elementary CA (ca.rs) resolving into a force-directed graph superseded by the Ising rebuild — the CA "grows a triangle, never fills the screen," and its graph was a second, unrelated simulation drawn beside the field rather than derived from it
Moire instrument greyscale summed travelling plane waves (wave.rs) kept as a live direction through several rounds, ultimately not the one promoted; the engine has a second consumer today
Field/instrument white/black a readout-box layout retired as a direction; its layout ideas live on in the shipped page
Bulletin greyscale a reusable interior-page shell not carried forward — no shipped page uses it currently
Terminal takeover black/green terminal-emulator chrome (life.rs, Game of Life gliders) cut — "the terminal-isms are just not good enough"
Specimen plate white/black reaction-diffusion texture (rd.rs), including a real ~7.8x perf fix (removing two full-grid allocations per step) cut on visual grounds, not performance; the fix and the engine are kept

The Ising direction won because it fills the screen by construction (every lattice site holds a spin, unlike the CA), because the wordmark reveal is one field derived from the simulation rather than a second unrelated one drawn beside it, and because temperature-as-scroll gives it a legible throughline: uncorrelated noise at high temperature, multi-scale domain structure near the critical point (~2.269 in this model's units), large ordered domains once cooled. value noise (noise.rs) was a candidate engine for the retired Field/Instrument direction and is kept the same way: tested, unused today, cheap to keep.

Tuning history, in brief. The shipped AnnealSchedule::default() went through several owner-driven rounds of hand-tuning via the ?tune=1 panel (see below); the crystallisation property test's pass threshold was recalibrated once, from 95% to 93%, when a schedule the owner explicitly chose measured a real (not noisy) worst case of 94.79% — documented in both the schedule's and the test's own doc comments as a regression catch, not a legibility certification. A separate, permanent product decision came out of the same period: motion safety is requestAnimationFrame at native rate plus the standard prefers-reduced-motion query, full stop — no bespoke per-frame luminance-budget instrumentation anywhere in this codebase.

Where the copy lives. The landing page prints no hand-written string of its own: every beat comes from src/data/landing-beats.ts, which is the owner's copy to edit freely. Nothing validates that text against a second list, so there is never a second place to update.

The atmosphere pages #

Features, architecture, about — index pages and subpages alike — and 404 use AtmosphereLayout rather than the plain BaseLayout the docs, /api/, get-started and the launch-required pages use. That layout adds the site's ambient texture — QuietAtmosphere.astro (a static, CSS-only scanline+vignette echo of the landing page's own register, cheap and non-live on purpose: the landing page is the only place the full simulation runs), a context meter that fills with scroll depth and quietly folds sections that have scrolled by (ContextMeter.astro), and a margin ticker of this repository's own real git log history (MarginTicker.astro, which reads actual commits at build time and renders nothing if git is unavailable rather than inventing activity). All of it degrades to nothing with JS off or prefers-reduced-motion set — see each component's own comments. The docs pages carry one small piece of this too: a hover-visible content hash and git commit for the actual file each page renders (DocsLayout.astro), real values computed by scripts/prepare-docs.mjs, never fabricated.

Every page — atmosphere or refuge — shares one palette and type scale (src/styles/global.css's :root tokens), the same ones the landing page's own <style> block uses. Adding a new subpage that inherits the theme means picking one of the two layouts: AtmosphereLayout for a page that is part of the marketing surface (gets the quiet scanline/context-meter texture), or BaseLayout directly for a reference/legal/utility page that should stay plain and fast (the refuge rule) — either way, import the layout, wrap the page's content in it, and the tokens, header and footer come for free.

Setup: one command to run and iterate #

From the repository root:

scripts/dev-site.sh              # builds the landing page's wasm if missing, then astro dev

This is the command to reach for when changing copy, nav, or an animation parameter and wanting to see it live: it builds site/public/wasm/site-anim_bg.wasm if it does not already exist (the one input astro dev's own watcher does not know how to produce), runs npm ci if site/node_modules is missing, then starts astro dev at http://localhost:4321/ (pass flags through, e.g. scripts/dev-site.sh --port 4322). Editing a page, src/config/*.ts, or src/data/*.ts shows up on save through Astro's own hot reload; editing crates/didbot-site-anim does not — the wasm build is not part of Astro's watch graph, so re-run scripts/build-wasm.sh (or delete site/public/wasm/ and re-run scripts/dev-site.sh) and reload the page.

Local dev has no /api/ — that only exists once something has copied cargo doc's output into dist/api/, which is ../scripts/build-site.sh's job, not this project's. Running cd site && npm ci && npm run dev directly also works and is equivalent minus the wasm-build check.

Building and testing everything #

From the repository root, not from here:

scripts/build-site.sh          # cargo doc + astro build + mount /api/ + site/tests
scripts/build-site.sh --skip-tests
scripts/publish-site.sh        # build, then a dry-run S3 sync plan
scripts/publish-site.sh --publish   # build, then actually sync + invalidate

See those scripts' own comments for what each step does and assumes. The tests under site/tests/ (npm test, from inside site/) need the full pipeline to have run first — several of them check the /api/ mount point, which only exists once cargo doc's output has been copied in.

One file per page #

Every page's copy lives in its own file under src/data/, and that file is the only place its words appear. Rewording is an edit there and nowhere else: the pages read from these files, nothing validates them against a second list, and no test holds a copy of any string, so nothing goes stale when a line changes.

page its copy
/ landing-beats.ts (the owner's own copy already)
/features/ and its subpages features.ts
/architecture/ and its subpages architecture.ts
/about/ and its subpages (including /about/contact/) about.ts
/get-started/ get-started.ts
/privacy/, /terms/ privacy.ts, terms.ts
404 not-found.ts
the testimonials block testimonials.ts

Features, architecture and about are lists, not fixed layouts. Each of those three data files exports one Section (src/data/section.ts): a title, an intro, and an ordered list of subpages, each with a slug, title, lede and body paragraphs. Adding a fourth feature means adding a fourth entry to features.ts — the index page renders the list and [slug].astro derives one route per entry from the same list, so nothing else learns about it. Removing or reordering works the same way. SectionIndex.astro and SubpageBody.astro are the shared markup, so a fourth section of this kind is a data file plus two three-line routes.

architecture.ts is where properties of the system go; features.ts is what a user gets. Same shape, different question.

Images. A subpage can carry one, next to its body:

import shot from "../assets/features/accounts.png";
...
  body: ["..."],
  image: { src: shot, alt: "what the picture shows" },

Drop the file under src/assets/<section>/, import it at the top of the data file, and hand the import — not a path string — to image.src. Importing is what lets astro:assets hash the file, emit a responsive srcset in webp, and stamp the real width/height on the tag so the page does not reflow when the picture lands; it also turns a misspelled filename into a build error rather than a broken image on the live site. Any format astro:assets reads works (png, jpg, webp, avif, svg). Omit the image key entirely and the subpage renders without one — nothing else changes. about.ts uses the same Subpage type, so its entries can take an image too.

The images seeded under src/assets/ today are generated placeholder plates, one per subpage, marked ⟦slug⟧ on their face for the same reason the text filler is: they are obviously not the real thing. Replace the file in place and the import needs no edit.

CLAUDE.md's Copywriting section is why nearly every slot still holds filler rather than copy: brand voice is the owner's call, not something an agent decides while shipping infrastructure. Filler is invented text, obviously so, wrapped in src/data/filler.ts's PH() helper, which renders it inside a ⟦…⟧ marker (guillemet-and-bracket, chosen because nothing else on the site uses it) so it is visible on the built page too, not only in the source. To find what is still unwritten:

grep -rn 'PH(' site/src/data/   # in the source
grep -rn '⟦' site/dist          # in a built page, to see it in context

Writing a real line means replacing the filler and dropping the PH() wrapper with it. When a file's last PH() is gone, its import goes too; when nothing imports filler.ts, delete it. Slugs are URL structure rather than brand voice, so they are written plainly — but the filler ones (apricity, nightjar, …) are placeholders like everything else and are meant to be renamed with the copy.

The three testimonials in src/data/testimonials.ts are a different thing: real, attributable quotations sitting in slots the owner expects to refill later, marked provisional: true (which Testimonials.astro renders as data-provisional, so a copy pass can spot them in a built page too). "Provisional" describes how long the slot lasts, not what may go in it. That marker is never license to invent a plausible-sounding line — any text that lands in these slots must be an actual quotation from an actual, correctly-named entity. Each entry carries a full field (the complete, verbatim quotation) and a quote field (a short trim of it, elided with … for the page's layout, reachable in full through the page's "read the full quote" disclosure); replacing one means supplying a new full quote and choosing a fresh trim from it, never hand-editing the trim in isolation, since on its own it is not a real thing anyone said.

Pages under /docs/ and /api/ carry no filler at all: they render real, already-reviewed content from elsewhere in the repository. Neither does the landing page's dev-only ?tune=1 panel, whose per-slider descriptions are developer documentation for whoever is tuning the simulation rather than anything a visitor sees.

Building the landing page's wasm #

../scripts/build-wasm.sh (called by ../scripts/build-site.sh) compiles crates/didbot-site-anim for the browser. It needs, on top of the Rust toolchain the rest of the workspace already needs:

rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version <version in Cargo.lock> --locked

The script checks both and fails with the exact command to run if either is missing or the wasm-bindgen-cli version does not match Cargo.lock's wasm-bindgen exactly (the crate and the CLI speak a private ABI to each other). wasm-opt (from binaryen) is optional — used to shrink the output if it is on PATH, skipped with a note otherwise. Neither the target nor the CLI is needed for anything else in this workspace: cargo build --workspace and cargo test --workspace never touch wasm32-unknown-unknown.

The AWS / CloudFront assumptions #

scripts/publish-site.sh assumes:

  • The destination bucket is configured for S3 static-website hosting (index document index.html, error document 404.html), fronted by CloudFront as a custom origin pointed at that website endpoint — not at the bucket's REST/S3 origin, which has no concept of a directory index. That combination, together with astro.config.mjs's build.format: "directory" (every page gets its own index.html), is what makes a bare /features/ request resolve.
  • $DIDBOT_SITE_BUCKET names the bucket; nothing here hardcodes one.
  • $DIDBOT_SITE_DISTRIBUTION, if set, names a CloudFront distribution to invalidate (/*) after a publish. Unset, no invalidation is issued, and something else (a short default TTL, a manual invalidation) is assumed to handle staleness.
  • aws is on PATH and already has working credentials — the script fails fast, before writing anything, if either is missing.
  • The hashed build output under /_astro/ gets a one-year immutable cache; everything else (HTML, and the unhashed cargo doc tree under /api/) gets a five-minute cache so a publish shows up promptly.