Skip to content

docs: Astro Starlight documentation site - #233

Draft
dimitropoulos wants to merge 30 commits into
mainfrom
docs/astro-starlight-site
Draft

docs: Astro Starlight documentation site#233
dimitropoulos wants to merge 30 commits into
mainfrom
docs/astro-starlight-site

Conversation

@dimitropoulos

Copy link
Copy Markdown
Collaborator

A Starlight docs site under packages/docs, and the two examples/ 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 fetch of the RPC path is routed into the Worker's fetch handler, 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.

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.
@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ac56bc6

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@233

commit: ac56bc6

@ask-bonk

ask-bonk Bot commented Aug 7, 2026

Copy link
Copy Markdown

This demo code is correct and well-designed — the pipelining example genuinely demonstrates the feature. The fetch wrapping restores on finally. The playground shim installs before this runs, and this layers on top correctly.

There's one subtle interaction worth noting: in the playground shim (build-playgrounds.mjs:159), globalThis.fetch is replaced permanently (no restore), and the demo's withSimulatedNetwork captures globalThis.fetch as realFetch then calls it for the RPC path — so it correctly reaches the shim. This composes correctly.

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 src/ library code (the URL serialization commit is an inherited merge from main, PR #224). There are:

  • No untrusted-input/deserialization changes
  • No wire-protocol changes (protocol.md doc is a rendered copy, not a protocol change)
  • No public API changes
  • No capability-safety concerns
  • No cross-runtime leaks into shared library paths (the Unix-shell wrangler build command and example-local runtime code are fine)
  • No changeset needed (docs/examples only, not published-package behavior)

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

github run

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant