Skip to content

docs: documentation site, with the examples as embedded playgrounds - #233

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

docs: documentation site, with the examples as embedded playgrounds#233
dimitropoulos wants to merge 47 commits into
mainfrom
docs/astro-starlight-site

Conversation

@dimitropoulos

@dimitropoulos dimitropoulos commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

A documentation 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.

The site is built with Nimbus (@cloudflare/nimbus-docs), not Starlight. It started on Starlight; the later commits on this branch port it, which is why the title changed. Nimbus scaffolds the layouts, routes and components into the repo as ordinary files rather than hiding them behind a theme, so packages/docs/README.md can and does explain the whole site: the palette and the page shell, the CSS-only node field, the playground stage, and the traps the port hit. packages/docs/AGENTS.md is the operating manual next to it.

What the port had to keep working, and how it was checked:

  • 37 pages, 2,555 internal links, 0 broken.
  • 180 rendered-geometry measurements of the node field across viewport sizes and zoom levels: worst endpoint gap 1.01px, no orphaned nodes.
  • No horizontal overflow on 13 pages at 8 widths from 1920 down to 360.
  • Idle cost about 1% of a core, and exactly 0% under prefers-reduced-motion.
  • First painted frame is already in the reader's stored scheme: no flash of light.
  • All three embedded demos follow the docs theme toggle, and each still runs its real RPC path on the deployed site (1 round trip pipelined vs 3 sequential).
  • astro check and nimbus-docs check both clean.

Draft: the preview deploy 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: fa8221b

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: fa8221b

@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.
The prefetch added in ac56bc6 fixed the flash locally and did nothing once
deployed. Workers' default for a static asset is `public, max-age=0,
must-revalidate`, and a response that must be revalidated cannot be reused
from cache -- so the document Astro had already fetched on hover was thrown
away and fetched again on the click. Confirmed against the deployment: the
navigation request came back 200 from the network, not from cache, and the
blank frame was still there.

Giving HTML a minute of freshness makes a prefetched page reusable on the
click, which is the whole mechanism the fix depends on. Fingerprinted assets
get a year and never revalidate; their URLs already change with their content.
The canvas backdrop and the WebGL hero each ran a requestAnimationFrame loop
that redrew the whole viewport every frame, whether or not anything had
changed. They looked better than what replaces them. A documentation site
should not cost a CPU core to read.

The geometry is now computed at build time and the motion is CSS. The one
design constraint is that only `transform` and `opacity` on HTML elements are
animated, because those are the two the compositor can run without waking the
main thread. Measured over an 8s idle:

  transform on HTML elements       0.1%
  opacity on HTML elements         0.0%
  opacity on SVG children          0.6%
  stroke-dashoffset on SVG lines   3.6%

That table is why edges are rotated divs rather than SVG lines, which were the
first attempt and cost 3.8% on a doc page and 14.2% on the landing page. A
message crossing a connection is now a child element sliding with translateX.

Result: 0.9% on a doc page, 1.7% on the landing page, and exactly 0% under
prefers-reduced-motion, where the field is still drawn and simply holds still.

The stage has a fixed aspect-ratio and is sized to cover, so authored
coordinates map to pixels by a single uniform scale and the baked-in angles
stay true at every window size. Percentages alone resolve against width and
height separately, which would walk a rotated edge off its endpoints on
resize.

Splash pages no longer render the backdrop at all. It was hidden with CSS,
which still put a second field's worth of elements in the document.
The library is 15.9 kB minified and gzipped. The site claimed "under 10 kB" in
six places, which was true once. A number typed into prose goes stale the
moment it stops being someone's job to check it.

Prebuild now bundles src/index.ts, minifies, gzips, and writes the figure to
src/generated/bundle-size.json, rounded up to the next kB so the claim stays
true as the library drifts within a kilobyte. Pages carry a %BUNDLE_SIZE%
token instead of a number.

Substitution happens in two places because frontmatter never reaches remark: a
content collection parses and validates it before markdown is rendered, and
Starlight reads the page description and the hero tagline straight off the
parsed entry. So the remark plugin handles bodies and MDX attributes, and a
transform on the collection schema handles frontmatter.

The root README is outside the Astro build and was updated by hand.
RpcTarget: say plainly that instance properties are private, and show the
getter that exposes one. Also that a setter can never fire, since assigning to
a stub throws and the protocol has no message for assignment -- pairing a
setter with a getter is dead code as far as a peer is concerned.

Comparisons: the claim that tRPC and oRPC have "no schema language" was wrong.
They typically declare inputs with Zod or another Standard Schema library and
infer the TypeScript type from it, so they validate by default and we do not.
The row now goes against us, with the honest framing that the direction of
derivation is simply reversed.

Comparisons: the N+1 warning box stated the problem and left the answer in
prose below it, where a reader skimming the box would miss it. The box now
says which half of the problem is yours, and the section it points at explains
why in-process SQLite makes N+1 a non-issue.

Security: lead with capnweb-validate rather than listing it as an also-ran.
Its two real advantages, whole-class coverage and failing closed, were not
stated anywhere.

Validation: rewrite the opening. It described the mechanism before the problem
and read like a product blurb.

Interop: add onRpcBroken to the differences table, since there is no clean way
to reconstruct it against a native stub. Remove the ownership differences
section, which describes behaviour from before rpc_params_dup_stubs, along
with the same warning duplicated on the disposal page.

Sessions: point the onRpcBroken warning at #234.
The lines did not meet the dots, and the gap grew with browser zoom. Measured
on the deployed build, at 1440x900: worst endpoint gap 3.1px at 100% zoom,
6.3px at 200%.

The cause was parallax. Three groups drifted at three speeds, and 76% of the
edges in the backdrop and 63% in the hero joined two nodes that had been put in
different groups, so most of the field was pulled apart and back together over
the drift cycle. Zoom made it worse because the drift was authored in pixels
while everything else scaled.

This is not fixable by assigning layers more carefully. An edge is a single
element, pinned at one end and rotated, so it stays attached at both ends only
if both ends share a transform; a connected graph therefore admits exactly one
transform group. Parallax and a graph are mutually exclusive. The field now
drifts as one rigid body and depth is carried by radius and brightness, which
is where most of it was coming from anyway.

Worst endpoint gap is now 1.02px, across 180 measurements: three pages, ten
viewport sizes from 360x640 to 1920x1080, six zoom levels from 50% to 200%.
Idle cost drops too, one composited transform instead of three: 0.9% -> 0.6% on
a doc page, 1.7% -> 0.7% on the landing page.

Separately, buildField now guarantees a single connected component. It already
happened to produce one at both seeds -- the edge set is unchanged by this
commit -- but capping edge length and node degree can strand a node, and
nothing would have caught it. A second pass runs Kruskal over the pair list the
greedy pass already sorted and joins whatever is left apart, and the component
count is asserted at build time.
@teamchong

Copy link
Copy Markdown
Collaborator

/bonk review this

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown

Reviewed the substantive code paths again from a fresh checkout: the build-time bundler, the two shims, the example Workers/APIs/clients, the CI gate, and doc accuracy against the actual library. This is a docs site plus example scaffolding with no changes to src/.

Verified by building, not just reading:

  • npm run build (library + capnweb-validate) succeeds.
  • npm run lint:md (the new CI gate added in .github/workflows/test.yml) reports 0 issues, tables aligned.
  • node packages/docs/scripts/build-playgrounds.mjs builds all three playgrounds (batch-pipelining, worker-react, session-recovery). The emitted runtime.js/client.js correctly import the single shared ./vendor/capnweb.js, so RpcTarget identity holds across the session boundary as the API comments claim.

Correctness spot-checks:

  • The fetch shim (build-playgrounds.mjs:263) captures upstream before replacing globalThis.fetch, routes only the RPC path to worker.fetch, and passes everything else through. Installed before client code so a client that wraps fetch (React example) layers on top.
  • withSimulatedNetwork (batch-pipelining demo.js:20) captures realFetch first and restores in finally, so a failed run cannot leave the page patched. pipelined and sequential run sequentially in main.js:20-25, so there is no globalThis.fetch clobbering between them.
  • The WebSocket shim (socketShimSource) preserves the CONNECTING/OPEN/CLOSING/CLOSED constants on the swapped constructor and opens on a task boundary, matching what the transport reads and expects.
  • session-recovery/session.js handles disconnect correctly: onRpcBroken tears down all four session-owned refs while deliberately preserving #cursor (the resume token that lives in client state, not the session), and subscribe dups the sink stub since parameter stubs are disposed on return.

Doc accuracy: reference/api.md matches the real exports: newWorkersRpcResponse (index.ts:148), newBunWebSocketRpcHandler (index-bun.ts), newWorkersWebSocketRpcResponse, batch/websocket/messageport helpers. The "not supported: Map, Set, RegExp" claim matches serialize.ts (no wire cases for them). protocol.md was moved to packages/docs/src/content/docs/reference/protocol.md, and .opencode/agents/bonk.md was updated consistently to point at the new location.

Scope safety: no untrusted-input/deserialization changes, no wire-protocol changes (the protocol doc is a moved copy), no public API changes, no capability-safety concerns, no cross-runtime leaks into shared library paths. The batch-pipelining Worker reflects Origin back for CORS, which is fine for an unauthenticated public demo endpoint. No changeset needed: docs/examples only.

Note for merge: the playground build depends on the example client's node_modules (the worker-react React deps). npm run build-playgrounds fails with Could not resolve "react" unless npm --prefix examples/worker-react/client install has run first. npm run setup does this, but a CI job that runs predev/prebuild in packages/docs without that install step would break. Worth confirming the deploy pipeline runs setup (or the react-client install) before the docs build.

LGTM

Co-authored-by: teamchong <teamchong@users.noreply.github.com>
@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review posted to PR #233: #233 (comment)

Summary of what I did differently from the prior ask-bonk review: rather than only reading the code, I built it from a fresh checkout to verify the claims hold.

Verified working:

  • npm run build succeeds (library + validate package)
  • npm run lint:md (the new CI gate this PR adds) passes with 0 issues
  • The playground bundler builds all three playgrounds; the emitted output correctly imports a single shared vendor copy of the library

Correctness confirmed: the fetch shim captures upstream before swapping and restores in finally; pipelined/sequential run sequentially so no fetch clobbering; the WebSocket shim preserves readyState constants; session-recovery correctly preserves #cursor across teardown and dups the sink stub.

Doc accuracy: reference/api.md matches real exports (newWorkersRpcResponse at index.ts:148, newBunWebSocketRpcHandler, etc.); the unsupported-types claim matches serialize.ts; protocol.md was moved (not lost) and bonk.md updated consistently.

One actionable note for the maintainer: the playground build depends on examples/worker-react/client having its node_modules installed (React). node build-playgrounds.mjs fails with Could not resolve "react" otherwise. npm run setup handles this, but the deploy pipeline should be confirmed to run setup (or the react-client install) before the docs prebuild.

Verdict: LGTM. Scope is docs/examples only, no src/ or protocol changes, no changeset needed.

github run

Nimbus is Cloudflare's own Astro docs framework, and it is where the rest of
the org's docs are going. It also replaces things we had hand-built: OG image
generation, llms.txt, markdown twins, search, the theme toggle, and a 404.

The framework swap is structural, so this commit is large but mechanical. What
changed in kind rather than in name:

Everything visible is now in the repo rather than behind an override API.
Nimbus copies its components, layouts, pages and styles into the project and
the npm package is only plumbing, so the four Starlight overrides (Head,
PageFrame, ThemeToggle, and the hero) are gone: there is nothing left to
override. src/components.ts registers what MDX may use without an import, and
the build fails on a PascalCase tag that is neither registered nor imported.

The markdown pipeline is Sätteri, not unified. remark-bundle-size becomes
mdast-bundle-size: a visitor object whose nodes are read-only and mutated
through ctx.setProperty. It no longer touches JSX attributes, because that
mutation cannot be encoded in the op-stream and MDX has a better answer --
index.mdx imports the measured size and interpolates it directly. Prose in .md
still goes through the plugin, and frontmatter through a schema transform.

Our own pieces carry over unchanged: the constellation and its two mounts, the
playground, the example source reader, the bundle measurement. The playground
now takes Nimbus's <Code>, and drops the Starlight prose opt-out marker along
with it, because Nimbus scopes its prose rules to :not([class]) and anything
with a class is already exempt.

Sidebar order moves from a single src/sidebar.mjs into per-page frontmatter,
which is what Nimbus autogenerates from. Card icons move from Starlight's icon
set to Phosphor, the set Nimbus ships.

Two upstream snags, both worked around locally and worth reporting:
link-card@0.8.2 imports astro-icon/components, which is not a dependency of
nimbus-docs or of the starter -- Nimbus's own Icon is a documented drop-in, and
is what every other component here uses. And the starter's [...slug] route
serves a docs entry named `index` at /index; Nimbus's own site maps it to the
root, which is what this now does too.

Not yet done, and why the site does not look like itself yet: the palette is
still Nimbus's neutral grey, the node field is not mounted, and the OG card is
unstyled. Those follow.
The palette, the node field and the page shell, carried over from the theme
Starlight was wearing. Values are unchanged and stay as hex: they were tuned by
eye, and a round trip through oklch would move them for nothing.

Nimbus's tokens take them. Primary is the interactive azure rather than the
orange, because primary colours links and every hover state on the page, and
orange has to stay rare to mean anything: it is the hero's one button, the bar
on the current page in the sidebar, and the pulses in the field. Three places,
as before. The status triples keep the aside hues, amber caution included.

The shell is a sheet on a ground. The body paints the ground, the doc content
column paints itself and takes a hairline and a shadow, and the field is fixed
behind everything, so it shows exactly where the page paints nothing: the
margins and the table of contents. The navigation rail is opaque chrome instead
-- the field behind it crossed the labels, and legibility wins over the effect.

A `mode: custom` page gets a bare `<main>`: no width cap, and no `.docs-content`
either, so every prose rule is inert. The landing page was rendering unstyled
and edge to edge. It now brings its own container, sized from one token it
shares with the hero so the two have the same left edge, and wider than a doc
page because it carries two code samples side by side.

Three bugs found by looking at it rather than by reasoning about it:

The hero field is built to overhang the hero and dissolve into the page behind
the first band of prose. `overflow-hidden` on the section clipped it to a hard
seam; `isolate` then made the section a stacking context, which paints as one
unit in the positioned layer, so the field jumped on top of the prose instead of
under it. The section needs to be `relative` and nothing more.

The playground's prose cap leaked onto every page. `is:global` styles are
hoisted into the route's shared stylesheet, and every page here is built by
`[...slug].astro`, so a rule meant for pages with a playground on them was
capping the landing page too. It is now behind the same `:has` guard as the
width it pairs with.

The embedded demos read the embedding page's `data-theme` before their first
paint, and Nimbus signals through `data-mode`, which is absent rather than
"light" in light mode -- a document that may load either way round cannot read
it. The bootstrap now publishes `data-theme` explicitly for that reader, and the
frames come up on the right scheme again.

Also: the code language badge gets a row of its own rather than sitting on top
of the first line, which on this site is often long enough to run under it.
The theme bootstrap had no script directive, so Astro processed it and emitted
`<script type="module">`. Module scripts are deferred: the document is free to
paint before one runs, and a reader whose stored choice is dark then watches the
page arrive light and turn over. Screencast at 20x CPU throttling, reloading a
doc page with `ui-mode=dark` stored and the OS reporting light: the new document
painted light for about sixty frames before flipping. With `is:inline` it is a
classic blocking script that runs during head parsing, and the first painted
frame is already dark. Nothing in it needed processing -- no imports, no
TypeScript.

The 404 page's `<main>` had no `id`, and BaseLayout renders a "Skip to content"
link to `#main-content` on every page, so on that one page the link went
nowhere. Found by the internal link checker: 2555 links, and that was the only
broken one.
The port left the pane's CSS pointing at `.expressive-code`, which Nimbus
does not emit: its Shiki blocks are a `.nb-code-figure` wrapping a
`pre.astro-code`. None of the rules matched, so the code block kept its
own border, radius, margin and language badge inside a pane that is
already a frame, and -- the part that matters -- nothing made the `<pre>`
the scrolling element. The figure clipped each file at the pane's height
with no way to reach the rest: 4.6k pixels of session-recovery's source
were unreachable.

Verified on all three example pages at eight widths: no horizontal
overflow, and every file scrolls to its last line.
astro-og-canvas resolves font paths from the project root at build time,
so the 420 kB Inter it rasterizes titles with does not have to sit in
`public/`, where the starter puts it and where it is copied into `dist/`
and served. Nothing requests it: the site's own type is loaded through
@fontsource. The cards come out byte-identical.
`tsc` errors on `baseUrl` as of TypeScript 6 (ts/5101), which
`nimbus-docs check --types` reports and `astro check` does not. `paths`
has resolved relative to its own tsconfig since 4.4, so the `@/` alias
needs nothing in its place.
Renamed from NIMBUS-AGENT.md to the name agents actually look for, so it
loads when work happens in this directory. Nothing in the Nimbus CLI
tracks the filename. The commands are npm rather than pnpm, the file tree
is the one that is here, and the rules this site adds -- the registry
scope on install, no wrangler dependency, <Prose> on custom pages,
%BUNDLE_SIZE%, the prose style, three uses of orange -- are written down
instead of being folklore.
It still described Starlight: component overrides that no longer exist,
`--sl-*` variables, `theme.css`, the Satori OG pipeline that was deleted,
Expressive Code's traps, and a sidebar defined in a config file that is
now per-page frontmatter. The parts that are still true -- the node field,
the playgrounds, the source panes -- are kept as they were.

New: which scaffold files diverge and why, so an upgrade is a merge with
reasons rather than a guess; the two token families and the sheet-on-ground
shell; how the scheme is chosen and why `data-theme` is published
alongside `data-mode`; the machine-readable routes; and the traps this
port actually hit, including the one about selectors that match nothing.

Also records the web-font trade the port introduced (86.6 kB, kept
deliberately) instead of repeating the old claim that the site ships none.
Keeps ask-bonk's lockfile tidy-up on the worker-react example. The docs
package's own lockfile was replaced wholesale by the Nimbus port, so its
side of that commit no longer applies.
@github-actions

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


1 out of 2 committers have signed the CLA.
✅ (dimitropoulos)[https://github.com/dimitropoulos]
@ask-bonk[bot]
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@dimitropoulos dimitropoulos changed the title docs: Astro Starlight documentation site docs: documentation site, with the examples as embedded playgrounds Aug 11, 2026
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.

2 participants