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.astroand[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.astroand the landing page's own header/footer all render fromheaderNav/footerNavhere, 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=1panel. 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/*.mdat/docs/. The markdown itself is not here:scripts/prepare-docs.mjscopies it from../docs/into the gitignoredsrc/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 undersrc/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.shcopiescargo doc's owntarget/doc/output here after the Astro build. There is no Astro page for it;site/tests/check-links.mjsonly 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.tsholds the shape the three sectioned ones share andfiller.tsthePH()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 undersite/) — 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. Ordinarycargo test -p didbot-site-animcovers 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'sAnnealSchedule::default()andDEFAULT_TOTAL_SWEEPS. The page reads these seven numbers from the wasm module at startup (default_anneal_schedule(), called throughanim.ts'sloadDefaultSchedule) rather than hand-copying them into TypeScript — that hand-copy used to be a real bug (the two copies drifted), and a nativecargo 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 document404.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 withastro.config.mjs'sbuild.format: "directory"(every page gets its ownindex.html), is what makes a bare/features/request resolve. $DIDBOT_SITE_BUCKETnames 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.awsis onPATHand 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 unhashedcargo doctree under/api/) gets a five-minute cache so a publish shows up promptly.