docs: Astro Starlight documentation site - #233
Conversation
Adds packages/docs, a Starlight site covering the library, and turns the two examples into in-page playgrounds. The playgrounds have no backend. scripts/build-playgrounds.mjs bundles each example's own Worker into the page alongside its own client and routes the client's fetch of the RPC path into the Worker's fetch handler, so the batching and round-trip counts the demos report are real while the site stays static. Source panes are read from the repo at build time, so a renamed file or a missing #region fails the build rather than rendering an empty tab.
Syncs the site with the two changes merged from main. URL joins the by-value type lists on What Can Be Passed, the API reference and the validation guide's coverage paragraph, and the protocol reference gains the ["url", href] expression. The validation guide gains a Schema evolution section for #227: which cross-version changes a validator accepts, and the distinction that matters once extra arguments are allowed -- they are dropped before the method runs, so an implementation still cannot read an argument no validator checked, while extra object properties are forwarded unvalidated. Also collapses the empty title bar Expressive Code leaves above terminal code blocks, and adds a wrangler config that serves dist/ from a Worker's asset store.
|
commit: |
|
This demo code is correct and well-designed — the pipelining example genuinely demonstrates the feature. The There's one subtle interaction worth noting: in the playground shim (build-playgrounds.mjs:159), I've reviewed the substantive code paths: the build-time bundler, the example Worker/API/server, the demo client, the Astro component, and the doc accuracy against the actual library. This PR is a documentation site plus example scaffolding. No changes touch
The documentation I spot-checked (security guide, api cheat sheet) matches the actual library behavior and signatures. The code is defensive and well-commented. LGTM |
Replaces the nautical theme and the logo with a dark-first palette: a near-black with a blue undertone, a deep saturated blue as the structural colour, and an electric azure for anything interactive. Cloudflare orange appears in three places and nowhere else -- the hero pulses, the primary call to action, and the current sidebar page -- because a fourth use is the point at which it stops meaning anything. Light mode is a second scheme rather than an inversion, and the chrome stays near-black in both. The landing page now runs a 3D network in WebGL. It is raw WebGL2 with no dependency: the page claims the library is under 10 kB with nothing behind it, and shipping a 3D framework to draw points and lines would undercut that in the first paint. The animation is the argument rather than decoration -- each pulse leaves a node, crosses several hops and returns along the same path, which is what pipelining actually buys you. Everything degrades to a designed state: the CSS gradient with no JavaScript or no WebGL2, a single still frame under prefers-reduced-motion, and a parked loop off-screen or on a hidden tab. GPU objects live in one struct that is rebuilt on webglcontextrestored, so a driver reset or a wake from sleep does not leave a dead rectangle. Additive blending cannot darken a pale page, so light mode composites the same geometry normally, as ink. Also fixes a horizontal overflow the new landing page exposed: cards holding a code block would not shrink below the widest unwrapped line, so the whole document scrolled sideways below about 1140px. And drops the explicit `sharp` dependency, which existed only to optimise the raster logo.
Derived from an analysis of the Hacker News discussion of the Cap'n Web launch announcement -- the twenty most frequently asked questions and the ten most common points of confusion in that thread. Each one was checked against the docs; this closes the gaps. Two new guides: - guides/comparisons.md -- positioning against tRPC/oRPC, JSON-RPC and GraphQL, an honest account of why .map() relocates the N+1 problem rather than solving it, why the library is scoped to JS/TS and what to use instead, a direct answer to the "isn't this CORBA again" objection, and the protocol-versus-library distinction. - guides/sessions.md -- session lifetime and why nothing is persisted, designing so a dropped session is survivable, reconnection patterns including the React one, resumable subscriptions, API versioning, and the load-balancing and scale-in consequences of long-lived sessions. Expanded in place: - concepts/map.md -- which side of the wire actually runs your callback, why there is no index argument, why filter()/reduce() are deliberately absent, the failure modes TypeScript does not catch, and the cost of nesting. - guides/security.md -- the Object.prototype and toJSON hardening the protocol performs, unbounded memory from un-awaited calls, and the server-side shape of the authentication example. - concepts/promises.md -- one round trip is a claim about awaits, not about message count. - guides/validation.md -- why generating validators from types is not defining the interface twice. - Smaller additions on the name, the protocol-vs-library framing, why JSON rather than a binary format, streaming versus callbacks, async generators, documenting an API without OpenAPI, runtime requirements beyond "any JavaScript", and Workers RPC convergence. Also corrects start/pipelining-tour.md, which said .map() runs the callback on the server. The recording is made on the calling side; only the RPCs it captured replay on the peer. Every behavioural claim added here was verified against src/ or the test suite rather than taken from the thread. Several thread answers are now out of date -- streams have since shipped with automatic flow control -- and are documented as the code actually behaves.
The root README had grown into a 821-line manual that duplicated the documentation site, and protocol.md duplicated reference/protocol. Two copies of the same material means one of them is always the stale one -- the README still claimed Workers RPC had streams that Cap'n Web lacked, which stopped being true some releases ago. Delete protocol.md. Its content lives in packages/docs/src/content/docs/reference/protocol.md, which is a strict superset: same eight top-level messages and eighteen expression types, plus a UInt8Array typo fixed and a section on why JSON rather than a binary format. Trim README.md from 821 lines to 187: what it is, why it is interesting, how to install it, a hello-world client and server, the pipelining example that makes the point, and then links into the docs. Everything removed was verified present in the docs first, page by page. The one detail the docs did not already state -- that disposing a non-root stub releases the object on the peer without closing the connection -- has been added to transports/index. Also repoints the wire-protocol references in the bonk agent definition, including the process rule that protocol changes must update the spec in the same PR, and rewrites the provenance section of packages/docs/README which described the root files as canonical. Documentation links are relative paths into packages/docs, so they resolve on GitHub and npm today. They should become URLs once the site has a published home.
Each documentation page now builds its own Open Graph image at /og/<slug>.png, in the site's own idiom rather than a generic template. The card reuses the hero's motif: nodes on a jittered sphere wired to their nearest neighbours, with three edges lit in Cloudflare orange to stand for calls in flight -- the same restraint the rest of the theme applies to that colour. The network is seeded from the page slug, so every page gets a different one and none of them change between builds. src/lib/og-network.ts is a deliberately separate, much smaller implementation of the hero's idea, because the hero needs GPU buffers and a frame loop and this needs a string of SVG. Satori lays out the text and resvg rasterizes. Both run only at build time. That means committing font data -- Satori has to measure glyphs to break lines -- so Inter is subset to the characters titles actually use, about 80 kB per weight instead of 400. Nothing is served to a browser and the site still ships no web fonts. The sidebar moves from astro.config.mjs to src/sidebar.mjs so that the card and the navigation cannot disagree about which group a page is in. Starlight already emits og:title, og:description, og:url and twitter:card, so the Head override adds only the image tags. Open Graph wants an absolute URL and `site` is still driven by DOCS_SITE_URL, so without that variable the tag falls back to a root-relative path. Correct in a browser, and it becomes absolute the moment the site has a canonical home.
The two existing examples both make the pipelining argument. Neither shows what a session *is*, which turns out to be the thing people most often get wrong: they expect stubs to survive a reconnect, or expect the library to re-establish them. The new example is a WebSocket session with a button that severs it. It demonstrates, in order: authentication as a capability rather than a header, one round trip to connect and subscribe, server push through a client-side RpcTarget, every stub rejecting once the socket dies, and a reconnect that resumes without a gap because the client kept a cursor of its own. Untick "resume from cursor" and the gap appears, which is the point -- the event log lives outside the session, and recovery is the application's job. The docs playground previously only handled HTTP examples, by shimming fetch. It now also handles WebSocket ones: `new WebSocket()` on the configured path returns one end of an in-page pair, with the other end handed to a real newWebSocketRpcSession running the Worker's main interface. The transport reads WebSocket.CONNECTING and friends off the constructor, so the shim carries those too. Everything but the network hop is genuine, including the disconnect and the broken-stub rejections. Also in the bundler: examples can now declare static assets to copy next to the page, which a zero-build example needs for a plain <link> stylesheet, and client.css is only linked when esbuild actually emitted one -- two 404s on the existing pages.
Two example files were shown as `#region` excerpts, because both had their RPC code tangled up with a few hundred lines of DOM wiring, chart drawing and CSS. The region markers made the docs tabs look tidy without fixing the actual problem, and an excerpt is a claim the reader cannot check: it asserts that the rest of the file does not matter, and offers no way to confirm it. So the files are split instead, the way the new example already was: batch-pipelining public/index.html inline script -> demo.js + main.js worker-react App.tsx #region runs -> runs.ts demo.js and runs.ts contain the two strategies and nothing else -- no DOM, no React, no chart -- which is what the tab was trying to point at. main.js and App.tsx keep the wiring. The latency slider now reaches the RPC code as an argument rather than through a getElementById inside it, and runs.ts also picks up the deliberate validation failure, so it holds every RPC call the app makes. With no excerpts left, `region` comes out of PlaygroundFile and readRepoRegion out of source.ts. The docs now have exactly one way to show source: the whole file, read from the repo at build time. Also: batch-pipelining's favicon was still the pre-redesign palette, and the React shell had none at all, so the playground was requesting a /favicon.ico that does not exist. Verified the demos still behave, in the playground and against a real `wrangler dev`: 1 round trip vs 3 either way, and no console errors.
Popped out of the docs, the three example pages looked like three different projects. batch-pipelining had a title bar, a centred column and a footer; worker-react was full-bleed with a stray orange stripe pinned to the top of the viewport and no footer; session-recovery was centred but had neither bar nor footer, and set its own 15px body font. They now share the chrome, to the pixel: a title bar linking home, a 60rem column with matching padding, the same h1 scale and lede treatment, and a footer crediting the source and pointing at the relevant concept page. All of it still comes off when `data-embedded` is set, because the docs page around the iframe supplies a title and a frame of its own. The orange stripe is gone rather than restyled. Links no longer use the accent either -- orange is spent on the one primary action per page, which is what makes it read as the primary action -- so a `--link` colour joins the palettes and the buttons converge on the same pill. worker-react also loses its wrapper div's inline styles and its bullet list, which just restated the two section headings underneath it. Its h1 was the product name, which the title bar now carries; the h1 is the claim being demonstrated, as on the other two. Checked standalone and embedded, light and dark, at eight widths from 360 to 1920: no overflow anywhere, chrome hidden in every embed, and all three demos still produce 1 round trip against 3.
The em dash had become a tic. It was doing the work of a colon, a semicolon, a comma and a pair of parentheses, often several times in one paragraph, which flattens the rhythm of the prose and is a reliable tell that nobody read the sentence back. Every dash is repunctuated rather than swapped for a lookalike: a semicolon or a full stop where two independent clauses had been welded together, a comma for an appositive, a colon where the second half defines the first, parentheses for a real aside. Where the sentence only needed the dash because it was carrying two ideas at once, it is now two sentences. No en dashes and no double hyphens went in as substitutes. The rule is written down in the bonk agent's non-negotiable rules so it survives contact with the next contributor, human or otherwise. Scope is prose only: the docs site, the example apps and every README. Library sources, tests and CHANGELOG.md are left alone, since their dashes predate this branch and rewriting them would bury the actual change in noise.
Three separate problems on the standalone example pages, all of them about things being invisible. The cards had no separation. A `#f7fafd` panel on a `#ffffff` page, parted by a one-pixel `#dbe4ee` hairline, is a card you have to look for. The light palette is inverted instead: the ground is tinted and the card is plain white, so a card is set apart by its own lightness and the hairline is a detail rather than the entire signal. Dark mode already worked this way and keeps its arrangement, with the borders and a real shadow brought up to match. `light-dark()` takes exactly two arguments, so the shadow's scheme switch happens on the colour and the geometry is written once around it. The latency slider was left to `accent-color`. Chrome derives the unfilled half of the track from the accent, and for a saturated orange it picks something close to black, which is what shipped: a light page with a near-black track across it. The track, the fill and the thumb are all painted explicitly now, with the filled fraction in a custom property that the same handler already writing the millisecond readout keeps up to date. The session-recovery primary button was drawing from `--warn`, which has to darken to `#b26a12` in light mode to stay legible as text. As a filled button that is brown. Warning colour and call-to-action colour are now two different tokens, which is what they always were. The React example's response label also had to move off the card colour, since the card is white now and the strip was disappearing into it. The label and the block below it read as one sunken unit parted by a rule.
Light mode was a recolour: the same near-black chrome with a white page bolted underneath, which reads as a screenshot of two different sites stacked on top of each other. The header and sidebar are their own material now, tinted light, with their own ink tokens rather than the hardcoded hexes that were scattered through the file. Naming them (`--cw-chrome-ink`, `--cw-chrome-line`, and the rest) is most of the fix, because the old code could not express "this is chrome" at all. Depth goes in alongside it. Cards, asides, tables and code frames all sat flat on the ground, so a long article was one undifferentiated column. Three shadow tokens carry the lift, and the borders come up enough to survive on a pale background. Behind all of it, a node field. A small object graph in three dimensions on a fixed 2D canvas, drifting, turning slowly, with the near layers pushed further than the far ones as the page scrolls, which is what makes it read as depth rather than as wallpaper. Canvas 2D and not WebGL on purpose: the hero needs a shader, this needs eighty line segments a frame, and a second WebGL context on every page would cost far more than it returns. It is decorative and behaves like it, being aria-hidden, pointer-transparent, paused when the tab is hidden, and absent entirely if anything in it fails. The splash page skips it, since the hero is already a better version of the same idea. Reduced motion gets a single static frame, and scrolling does not move it either. Parallax is exactly the kind of motion that provokes vestibular symptoms, so "less motion" has to mean the field stays put. The theme toggle now wipes as a circle out of the button rather than cutting, using the View Transitions API, falling back to an instant swap where that is unsupported or unwanted. Two smaller things came out of looking at the page for this long. The scrollbar gutter is reserved, so navigating between a page that scrolls and one that does not no longer shifts the whole layout sideways. And the feature-card icons all get one blue gradient instead of the five hues Starlight cycles through by `nth-child`, one of which was competing with the single place orange is supposed to mean something. The hero has more sparks in flight, moving faster and resting less. One pulse crossing five hops and back is the argument the page is making, but a single spark on a 190-node sphere reads as an idle screensaver.
Ran the whole docs corpus through the rules in github.com/petergyang/no-ai-slop and fixed what it found. Roughly thirty changes across sixteen files, all of them prose. No technical claim changed. The recurring faults, in rough order of frequency: Metadiscourse. Lines that stepped outside the subject to tell the reader what to notice: "This is the question that trips people up most", "Three things worth noticing in that handful of lines", "the important design rule is", "which is the only thing this example is really about". If the following paragraph makes the point, the announcement is dead weight, so it is gone. Colon reveals. A noun phrase, a colon, then a dramatic lowercase fragment: "The useful rule:", "The mental model that matters:", "The trade-off:", "What that gives up:". Rewritten as plain sentences. Colons introducing lists, labels and quotes are untouched, because that is what colons are for. Puffery. "It's incredibly powerful, and we get into it below" said nothing; the object-capability model earning its keep is a claim the Security page actually supports, so the sentence says that instead. Same treatment for "pairs so well with" and "This is the feature that makes Cap'n Web interesting." Empty adverbs. "simply await", "inherently asynchronous", "actually work", "just let it run", "What a disconnect actually destroys". Each one deleted where it added nothing. The ones carrying real contrast stayed. One banned word: "fully utilized" is now "fully used". A cross-page tic. All three example pages described running the demo as "a real X over a real Y", two of them adding "the honest way". Read one after another it is a verbal habit rather than a description, so each page now says what is actually different about running it locally. Deliberately kept: "Five logical calls, arbitrary depth of dependency, one round trip", because a verbless line used once as a thesis is not a tic; "They share a model, an author, and a sense of humour", because the joke is set up earlier on the page; and the train-tunnel comparison in the sessions guide, which is concrete rather than decorative. Every "X is not Y, it is Z" construction stayed too, since each one corrects an assumption a reader is likely to arrive with, which is information and not drama. reference/protocol.md is out of scope. It is a normative specification and its phrasing carries meaning that an editing pass has no business touching.
The comment at the top of favicon.svg contained a two-hyphen sequence. XML forbids that inside a comment, so the document is not well-formed, so the browser refuses to decode it. `naturalWidth` on the loaded image was 0. Every visitor has been seeing a fallback icon since the file was added in 869f23b, and it looked like a stale cache because the symptom of a broken favicon is the previous favicon. The comment is repunctuated and now says not to reintroduce the sequence, since nothing else in the toolchain will catch it: the file is copied verbatim out of `public/`, the build does not parse it, and a browser fails at it silently. The declared URL also carries a version marker now. Chrome keeps favicons in a store of its own that is keyed by URL and is not cleared by a hard reload, so anyone who has already cached the broken state would otherwise keep the fallback indefinitely. Verified by loading the file as an image and checking it decodes, and by rendering it at 16, 32 and 64 pixels to confirm it still reads as three nodes and not a smudge. Also validated every other SVG in the repository, including the data-URI icons in the three example pages, all of which parse.
Adds markdownlint-cli2 with a config tuned for this repo: 100-column prose (code blocks and tables exempt, since neither wraps), front matter counting as the document title for Starlight pages, and `,;:` allowed at the end of a heading so a heading can introduce the block below it. `.mdx` is excluded because there is no MDX parser here and the JSX trips half a dozen rules. Two additions beyond the stock rules: `scripts/markdownlint-no-code-after-heading.mjs` is a custom rule, CW001, that rejects a code block placed directly under an `##` heading. A heading followed by an immediate wall of code reads as a dump, and in nearly every case here the paragraph explaining the sample already existed just below it, so the fix was to move it above. That accounts for most of the prose in this commit; the rest are one-line introductions written for the occasion. `scripts/align-markdown-tables.mjs` pads table cells so the pipes line up. MD060 can report a ragged table but cannot repair one, and its own --fix shrinks the data rows while leaving the delimiter row at its original width, which looks worse than either extreme. The script pads every cell to its column's width and rebuilds the delimiter row to match, preserving alignment colons, and skips fenced blocks so a table inside a code sample stays as written. Verified that no cell's content changed across all 20 files it touched. `--check` mode fails the build if a table drifts. Also picked up along the way: language tags on four bare fences, bare URLs wrapped in angle brackets, and three over-long paragraphs rewrapped. Lint runs as its own job so it fails in seconds rather than after the Playwright container has built everything.
Starlight sets the heading element itself to `display: inline` so the anchor link can sit at the end of the last line rather than wrapping onto its own line. An inline box paints its background once per line fragment, so the fading hairline we hang off `h2` was drawn under every line, not under the heading. Any heading long enough to wrap got a rule struck through its middle, which is what "vs. Cap'n Proto, and using Cap'n Web from other languages" was showing. Moved the rule to `.sl-heading-wrapper.level-h2`, which is a block and gets exactly one. An h2 that is a direct child of the content has no wrapper, since no anchor link was added to it, and is still styled directly. Two smaller things in the same area: Squared off the top corners of a `pre` that sits under a title tab. The rounded corners left a step in the left edge where the tab met the code, which looks like a rendering fault rather than a detail. Dropped the zebra striping from tables. Every cell already has a border, so the alternating fill was a second grid competing with the first.
Inline code inherited the prose grey, so a `code` span differed from the words around it only by a very faint tinted box. Give it the strongest foreground the theme has instead. Contrast against its own chip goes from 10.1:1 to 16.6:1 in dark mode and 6.9:1 to 14.4:1 in light, which is the difference between a token you can pick out of a paragraph at a glance and one you have to look for. Code inside a link keeps the link colour. Being a link is the more useful thing to know about it.
Five changes to the node field behind the pages. Field time now runs at half the wall clock, through a single `TIME_SCALE` that the drift, the turn and the pulses all derive from. At the old pace it read as something animating rather than something adrift. Nodes and edges are sorted into three focus bands by depth and the far two are blurred, so the field recedes instead of sitting flat behind the text. The implementation matters here: setting `ctx.filter` and then stroking each line individually makes the browser run a full-canvas filter pass per line, which measured at 1700ms a frame. Each band is now drawn into an offscreen surface and composited with one blurred `drawImage`, at half resolution, since the detail is about to be blurred away and the upscale softens it further for free. Back to a 17ms median with a 33ms worst case, which is the 30fps cap the renderer asks for. Nodes in the focused band get a small bloom. The blurred bands do not, because a glow behind a blur is just a wider blur at twice the price. Every six to fifteen seconds, at most two at a time, a muted orange pulse leaves a node, travels along an edge, and comes back. It is the one thing in the field worth noticing, so it is rare, it prefers the near half where it will be in focus, and it ramps in and out rather than popping. Orange is otherwise reserved for things the reader is meant to act on, so this is mixed well down from `--cw-orange`. Dark-mode edges are drawn harder, 0.3 against the light field's 0.17. On near-black a hairline at the light theme's strength all but vanished. Reduced motion is unchanged and still correct: one static frame, no pulses, no animation loop. Verified zero rAF calls after the first paint.
Two separate faults, both of which read as the circle stalling. The easing was an ease-out-expo, which put 92% of the circle's *area* down in the first 30% of the duration and 99% by the halfway mark. The wipe appeared to complete and then hang for a third of a second waiting for the transition to end. Replaced with a curve that holds the leading edge within about 1.6x of constant speed from start to finish, over 460ms rather than 520ms. The animation was also started from `transition.ready.then(...)`, and a promise callback cannot run until the browser has already composited the transition's first frame. The new snapshot appeared unclipped for that frame and the circle then grew from zero over the top of it. It is now declared in CSS and driven by three custom properties set before the transition begins, so it is clipped from the first frame and there is no JS scheduling in the path at all. The toggle no longer touches the animation; it sets a centre, a radius, and an attribute.
The earlier pass only covered `content/docs`, so these four kept their tics.
- "which is the part that surprises people" told the reader what to feel about
a section they had not read yet.
- "disconnect the honest way" was a wink at nothing. It now says what you do:
turn your network off instead of pressing the button.
- "Why this matters" is a heading that only restates that a section exists. The
section is about latency not multiplying with the depth of the chain, so it
says that.
- worker-react opened with "This example exposes ... It demonstrates ...", which
is a catalogue entry rather than a sentence, and described its launch config by
what it no longer does ("without the old helper shell scripts"), which tells a
reader who never saw the old scripts nothing at all.
Also repunctuated a ` -- ` in the examples README. A double hyphen is an em dash
wearing a hat, and the rule against em dashes covers the substitutes.
The earlier sweep looked for the em dash character and so missed the places where one had already been spelled `--`. The rule covers the substitutes, so these are the same violation in a different costume. Seven in prose, each repunctuated on its own terms rather than by swapping one uniform device in: parentheses for the genuine aside, a colon where the second half restates the first, a semicolon before a tacked-on pointer, commas for the trailing fragments, and two sentences simply restructured so nothing is needed. Rewrapped the one paragraph that ended up over 100 columns. Left alone: the `--` inside code samples and code comments, which are a separate question, and `npm run og:fonts -- <path>`, where it is npm's argument separator and not punctuation at all.
Replaces the circular wipe with a 200ms cross-fade of the whole page, and fixes
the three things the wipe was papering over.
The wipe was a View Transition, which means snapshotting the document, and a
snapshot cannot capture a canvas mid-frame. The hero therefore had to be blanked
for the duration (`visibility: hidden` on `.cw-hero-field`) while its simulation
kept running behind the curtain, so it came back at a different rotation from a
flat background. That is the "reset" on the landing page. The sidebar field
looked fine only because it was inside the snapshot.
A cross-fade needs no snapshot, so nothing has to be hidden and nothing jumps.
Every element animates its own colours in place, which leaves three surfaces the
page's CSS cannot reach, each now fading itself over the same duration:
- The hero. `Palette.light` is a 0-to-1 mix rather than a boolean, so the whole
palette interpolates. Only the blend equation cannot be mixed, additive glow
against composited ink, and that flips at the halfway point. Checked either
side of the crossover with the fade stretched to 20s: the step is invisible,
which is the point of switching there rather than at either end.
- The sidebar field. Palette parsed to channels once instead of re-parsing hex
per draw call, then tweened.
- The demo iframes. They are same-origin, so the fade rule is installed in them
directly, carrying the docs' duration rather than a second copy of the number.
Also fixes the examples not following the theme at all. `session-recovery`
listened for `event.data.capnwebTheme` while the parent has been posting
`{ type: 'capn-theme' }`; it picked up the theme on load, from reading
`parent.document`, and ignored every toggle after that. The other two were
already correct.
The duration is one unitless token, `--cw-theme-fade-ms`. It has to be unitless:
Chrome serialises a `200ms` custom property back out as `.2s`, so reading the
time and calling `parseFloat` gives 0.2, and every fade downstream lasts a fifth
of a millisecond. That bug was in this commit until the canvases visibly snapped.
Reduced motion snaps, in step. Four independent fades cannot be kept together
for a reader who has asked for none of them, so they all change on one frame.
Bring the pointer near a node in the backdrop and that node starts talking to its neighbours: a round trip along each of its edges, staggered so they leave in sequence, and roughly a third of the time a neighbour forwards the message one further hop before answering. Nodes flash as each leg arrives, so a chain of hops reads as a sequence of arrivals rather than a dot sliding along a line. A pulse is now a path rather than a single edge. `seq` holds the whole itinerary there and back, so the same code walks both directions, and the field carries adjacency plus a pair-to-edge map so a wave can be assembled without scanning the edge list. Only one node is ever held, and it is held with hysteresis: acquired within 110px, released beyond 190px. The field drifts under a stationary pointer, so a single threshold dropped the node after about two seconds and left the reader pointing at a dead field. Mouse only, and inert under reduced motion. Touch would light a node on tap and leave it lit, which is a mark rather than an interaction. Median frame cost while hovering is unchanged at 16.7ms.
The same interaction the 2D backdrop just got, on the WebGL hero. Bring the pointer near a node and it talks to its neighbours: a round trip along each of its edges, staggered, and about a third of the time one that carries on a hop further before turning back. Hover traffic is quicker than the ambient kind, since it was asked for. The nodes are on a turning sphere, so the pick happens in screen space after projection: the nodes are projected on the CPU with the same mvp the shaders get, and camera distance is charged as a penalty so a node on the far side cannot steal the pick from the one drawn in front of it. Held with the same hysteresis as the backdrop, acquired within 70px and released beyond 120px. The hovered node swells and brightens in its own colour rather than the pulse colour, which needs no extra attribute buffer: GLSL ES 3.00 has gl_VertexID, so the vertex shader compares it against a uniform. Pulses gain a one-shot flag. The ambient pool is fixed and recycles itself, so hover traffic is appended and retired on arrival instead, which is why the simulation loop now runs backwards.
One commit because the changes interleave in the same four files, and splitting them would mean committing a 1000ms cross-fade only to delete it in the next commit. Theme switching is an immediate flip again. The fade was not worth what it cost: four independent fades (page CSS, two canvases, the demo iframes) kept in step by hand, each with its own timer and slack, and the canvases redraw well below the rate a colour ramp needs, so it read as a stutter rather than a fade. Gone with it: `--cw-theme-fade-ms`, the `data-theme-switching` transition block, `theme-fade.ts`, the `<style>` the playground injected into each frame, and the palette tween and `mixPalette` in both renderers. The toggle sets `data-theme` and stores the choice; the canvases re-read their palette on the attribute change, the frames are told by postMessage. `Palette.light` is 0 or 1 again, so the hero's blend function no longer has to pick a side mid-fade, and reduced motion needs no special case because nothing moves. The backdrop's hover was hard to see, for a plain reason: a lit edge was drawn at 0.22 alpha against an idle edge's 0.3, so "lit" was dimmer than resting. It is now 0.9 and a little thicker, dark-mode idle lines go from 0.3 to 0.46, and a node takes on the message colour as a round trip reaches it. The highlight is no longer a blue ring but a small dot blinking between white and the message colour, which is also what the hero now does. On the pale scheme the white end is warmed towards orange, because a white dot on near-white paper is nothing. The hero's spontaneous signals were multi-hop walks, which read as a wandering dot; they are single-edge round trips now, like the backdrop's, and about a fifth as frequent (three at most, resting seconds between trips: roughly 0.7 signals a second against 3.7). Scroll parallax got weight and a leash. The offset follows on a critically damped spring rather than an exponential ease, since an exponential applies its largest correction on the first frame, which is exactly the jerk a wheel notch produces. It also saturates: it tracked `scrollY` linearly before, so 3000px down a page the near layer had slid 486px off the top and the field appeared to shrink into the middle of the screen. It is capped at 144px now and the ink in the margins is steady from the top of a page to the bottom. The field draws at 30fps at rest and 60 while scrolling or hovering, which a 2ms draw affords. Finally: the desktop sidebar goes from 78% to 94% opaque, so the field behind the navigation is only just perceptible rather than competing with link text, and the hovered node is much smaller, a 30% swell against 110%.
Removing the cross-fade left the flip looking almost instant rather than instant. The colour transitions that give hover affordances their ease are still there (buttons 0.18s, sidebar links 0.15s, cards 0.2s, and Starlight's own on anchor links), and a theme change alters exactly the properties they transition, so eleven of them ran on a doc page and twenty-one on the landing page every time the scheme changed. The toggle now suppresses transitions for the single style recalculation the change happens in: set `data-theme-instant`, set `data-theme`, read a layout property so the new colours are committed while transitions are off, then clear the attribute on the next frame. Reading the layout property is the part that matters; without it the attribute is set and cleared inside one style pass and the transitions run regardless. Verified nothing animates on a doc page, the landing page or an example page, that hovering still eases either side of a change, and that the demo iframes still flip within a frame.
The stylesheet is hashed and immutable, so the browser has it on disk and can paint as soon as the first of the HTML arrives. The HTML is fetched fresh on every navigation, so what it paints is a styled header over bare background, a frame before the sheet and the prose land. In the dark theme that frame reads as a flash to black. Measured with a CDP screencast over a throttled connection: mean frame luminance drops from 18.4 to 8.8 for two frames mid-navigation. Warming the target document into the HTTP cache first removes them, which is the whole fix -- Astro's prefetch does exactly that on hover. Stops short of `viewport`, which would fetch the entire sidebar on load, and of `experimental.clientPrerender`, which runs the target page's scripts, and every page here starts a canvas animation.
A Starlight docs site under
packages/docs, and the twoexamples/turned into playgrounds embedded in their own pages.The playgrounds have no backend. Each example's real Worker is bundled into the page next to its real client, and the client's
fetchof the RPC path is routed into the Worker'sfetchhandler, so the batching and round-trip counts the demos report are genuine while the site stays static and deployable anywhere.Draft: the preview deploy currently sits behind an account-wide Cloudflare Access policy, so there is no public URL to link yet.