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. meta.ts holds the two strings no page has of its own: the fallback description and what the social card depicts.
  • 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.
  • crates/didbot-brand (also at the repo root) — the brand images, built by annealing that same engine and writing the result into public/. See "The brand images" below.

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 brand images #

Everything under public/brand/, plus public/favicon.ico, public/favicon.svg and public/apple-touch-icon.png, is generated — crates/didbot-brand, at the repository root — and committed. Do not edit one by hand; the next rebuild overwrites it and CI fails in between.

Each file is a snap of the same simulation this site's landing page runs: the Ising lattice annealed by AnnealSchedule::default() for DEFAULT_TOTAL_SWEEPS, stopped at the end of the schedule, painted one block a lattice site in --ground and --accent. So the logo is the frame the page ends on rather than a picture of it, and changing the schedule changes the images.

scripts/build-brand.sh           # rewrite them
scripts/build-brand.sh --check   # what scripts/ci.sh runs; fails if stale

The icons are their own marks, not the wordmark shrunk, and they are a size ladder rather than one mark scaled: a lattice does not survive being made small, so each tier is the largest thing its tile can actually hold. The wordmark's "did.bot" appears only on the wordmark and the social card — an icon is a "d".

tile mark why not the tier above
64 sites, painted at 1 to 8 pixels a site (icon-64/128/256/512, apple-touch-icon, the round icons) a "d" cut out of a disc, three specks in the ring around it —
48 sites (icon-48) the same badge, same proportions 64 does not divide 48
16 sites at 1 and 2 pixels a site (icon-16, icon-32) the dot a hole needs sites too: a "d" cut small enough to leave the specks their ring has a two-site stroke here, and surface tension closes its counter

The big tiles are the 64-site badge magnified, not a finer lattice: the stray lit sites an anneal leaves scale with the site count, so a 256-site version of this is not a crisper icon, it is a dusty one. Every raster size is a whole multiple of its own lattice, so no brand image is ever a resampled picture of a lattice, and favicon.ico holds the three small tiles, one PNG an entry, each from the mark that size is drawn in.

Everything an icon draws stays inside the tile's inscribed circle (mark::SAFE_RADIUS), because an uploader that shows a square icon in a round frame crops to exactly that. It is why a round icon here is the same mark as the square one with a different cut rather than a mark of its own, and every_icon_keeps_its_ink_inside_the_safe_circle is the test that keeps it true — measured from each lit site's far corner, since a crop cuts pixels rather than sites.

The three specks in the ring around every icon are the one deliberate mark in the set: pinned sites (IsingModel::pin_spin) with a small island of field around them, so the lattice grows its own shape on each. At 16 sites that is a single pixel — where they came from, having fallen out of the first anneal that shipped — and on a bigger tile the island is bigger and the anneal makes it irregular. Their ring is narrow and both edges are hard: outside it the round crop cuts them, inside it they merge with the badge, which is also what fixes how large the badge can be. every_speck_survives_the_anneal is the test that they are still there afterwards; without the pin the field alone cannot hold anything that small and they vanish.

To change the set — a size, a shape, a mark — edit ASSETS in crates/didbot-brand/src/lib.rs and the marks in its mark.rs, then rebuild. That list is the inventory; nothing here holds a second copy of it. The <head> tags that point at these files are src/components/BrandHead.astro, which both layouts and the landing page include.

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, upload a release, point did.bot at it
scripts/publish-site.sh --dry-run   # build, then a plan of what that would do

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.

Two of those tests check the built tree against something outside it, because neither half is checkable alone: check-serving.mjs runs infra/site/viewer-request.js over the addresses the pages link to, and check-csp.mjs reads the Content-Security-Policy from infra/site/main.tf and holds the shipped scripts to it. The policy's script-src grants 'wasm-unsafe-eval' and not 'unsafe-eval', so nothing in the tree may build JavaScript from a string — which is why the landing page loads its simulation with a plain dynamic import() and not an eval'd one. A build that breaks either pairing passes every other test here and then fails only in a reader's browser.

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.

Pasting an address from this site into a chat window renders a card, built from the tags src/components/PageHead.astro writes into every page's <head>: title, description, canonical address, the social card, and twitter:card, which is the one thing the og: tags have no equivalent for. Both layouts use that component, and so does the landing page, which is its own document.

A page's description is the line it already shows. Nothing here is a second sentence written about a page — a section hands its intro to the layout, a subpage its lede, a /docs/ page the opening paragraph scripts/prepare-docs.mjs flattens out of its own markdown. So a card is never stale, and a card built from filler says ⟦…⟧ exactly as the page does. A page with no line of its own falls back to src/data/meta.ts's siteDescription, which is the landing page's hero beat on one line — the owner's copy, and the same text a scraper that ignores tags would take off the front page anyway.

tests/check-meta.mjs holds it together: every authored page carries the whole set, its og:title and og:description match its own title and description rather than a second copy of them, its og:url and canonical are the address the built tree actually serves it at, and its og:image is an absolute address of a file this build ships. The 404 is the one exception it encodes — it answers from every missing path, so it is noindex and names no canonical address.

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.

Where the built site is served from #

infra/site/ is the OpenTofu stack behind https://did.bot: a private S3 bucket, a CloudFront distribution in front of it, the ACM certificate that distribution serves, and the records in the did.bot hosted zone — the apex alias pair pointing at the distribution, and the _atproto TXT record that lets did.bot resolve as an ATProto handle. That stack's own comments carry the detail; three things about it shape this project:

  • A publish is a pointer move. scripts/publish-site.sh uploads the build as an immutable tree under s3://did-bot-site/releases/<git sha>/ and then applies release_sha, which flips the distribution's origin_path to it, waits for that config to reach every edge, and invalidates. A reader gets the old tree or the new one. Rolling back is tofu -chdir=infra/site apply -var release_sha=<an earlier sha>, and tofu -chdir=infra/site output release_sha says what is live.
  • Pretty URLs are a CloudFront function, not the bucket. The bucket is private behind an origin access control and answers by key alone, so infra/site/viewer-request.js is what resolves /features/ to features/index.html and redirects /features to /features/. It pairs with astro.config.mjs's trailingSlash: "always" and build.format: "directory" — change either and read that function alongside it.
  • 404.html is load-bearing. A private bucket answers a key outside the release tree with 403, which the distribution maps to this page; the publish script checks the built tree carries one before uploading it.

aws and tofu are on PATH with working credentials for the did.bot account (AWS_PROFILE=jmm-did-bot-admin); the script checks both before it writes anything. Astro's /_astro/ and rustdoc's api/static.files/ carry a content hash in the filename and are uploaded with a one-year immutable cache, and every other object takes the distribution's caching, which each publish invalidates.