Skip to content

feat(site): bootstrap the site surface and build the landing page - #116

Closed
thejustinwalsh wants to merge 29 commits into
mainfrom
feat/docs-site-bootstrap-ac8db7
Closed

feat(site): bootstrap the site surface and build the landing page#116
thejustinwalsh wants to merge 29 commits into
mainfrom
feat/docs-site-bootstrap-ac8db7

Conversation

@thejustinwalsh

@thejustinwalsh thejustinwalsh commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Bootstraps site/, a third top-level category beside packages/ and apps/ for deployable web surfaces, and builds the landing page in it. Nothing publishes: there is no workflow file and no base path, both of which land with the publish decision.

Design and rationale live in docs/planning/docs-site.md; the decision is recorded as D-259.

What's here

The landing page. The hero sets glÿph in Playwrite US Trad through Slug, with a defineTextMaterial node material over a studio built from lightformers rather than a downloaded HDRI, and bloom → lensflare → chromatic aberration through useRenderPipeline. Behind it, one continuous stream of the word in 45 languages across 20 scripts, flowing through responsive justified columns — three down to tablet, two to phone, one below that. The camera drifts on filtered noise, orbiting so the mark sits on the pivot and the field parallaxes behind it.

The wordmark is encoded decomposed as y + U+0308, so it only renders correctly if GPOS mark-to-base attachment works. It is its own shaping proof.

The bake pipeline. site/scripts/bake-chorus.mts owns the whole job: it fetches the Noto statics when absent, reads each face's cmap, works out which face should own each code point in fallback order, and bakes every face to exactly that subset. Coverage is derived, never declared — adding a word is a one-line edit, and an uncoverable one fails loudly naming the code points. The derived mapping is written to chorus-coverage.json so it can be reviewed and diffed.

A development panel. Every value the look depends on is dialable through leva, with Reset and a button that emits the LOOK block as pasteable code. Space toggles the panel and the frame counter. All of it drops out of a production build.

Performance

Six frames a second to sixty, from two measured causes — neither of them the glyph count:

before after
draw calls 505 121
geometries 262 81
texture memory 732 MB 243 MB

The chorus takes its own TextGroup with compositing="independent". The default, ordered, forbids Rust from reordering compatible draws — right for text that overlaps itself, needlessly strict for a field of words that never touch. Separately, device pixel ratio is clamped: left alone the canvas backed a 3064×3224 buffer and the post chain allocated several full-size float targets over it.

Upstream issues found

Each came out of a measurement while building this, and each is filed with its evidence:

Known blocker

The docs build does not run on this machine. pnpm dlx and npx both fail identically:

Error: Could not find the Next.js package (next/package.json)

Next resolves its workspace root to the nearest lockfile, finds package.json, pnpm-lock.yaml and pnpm-workspace.yaml in $HOME, and then refuses that root because it would include the home directory. The documented invocation is correct; the environment is not. The robust fix is to take @pmndrs/docs as a devDependency of site/ so next resolves inside the workspace — that is not done here, pending a call on it.

Consequently site/docs/ holds only the introduction page and the MDX pipeline is unverified.

Also

React Three Fiber moves to 10.0.0-alpha.4 across the workspace in one step, so a single reconciler is installed rather than two.

@thejustinwalsh thejustinwalsh changed the title feat/docs site bootstrap ac8db7 feat(site): bootstrap the site surface and build the landing page Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
Core JS (gzip) 22.55 KB (0%)
Shaper Wasm (gzip) 431.37 KB (0%)
Three.js adapter JS (gzip) 89.23 KB (0%)
Inter font · Bitmap (gzip) 545.27 KB (0%)
Inter font · MTSDF (gzip) 6.48 MB (0%)
Inter font · Slug (gzip) 604.06 KB (0%)
Font Awesome icons · Bitmap (gzip) 439.53 KB (0%)
Font Awesome icons · MTSDF (gzip) 6.89 MB (0%)
Font Awesome icons · Slug (gzip) 642.64 KB (0%)
Font validator JS (gzip) 134.24 KB (0%)
Runtime bake host JS (gzip) 5.41 KB (0%)
Runtime bake Worker JS (gzip) 142.47 KB (0%)
Font baker JS (gzip) 2.41 KB (0%)
Font baker Wasm (gzip) 379.63 KB (0%)
Bitmap baker JS (gzip) 4.66 KB (0%)
Bitmap baker Wasm (gzip) 228.47 KB (+0.06% 🔺)
MTSDF baker JS (gzip) 5.39 KB (0%)
MTSDF baker Wasm (gzip) 209.22 KB (0%)
Slug baker JS (gzip) 4.01 KB (0%)
Slug baker Wasm (gzip) 181.26 KB (0%)

@thejustinwalsh
thejustinwalsh changed the base branch from main to fix/bake-technique-options August 25, 2026 20:24
@thejustinwalsh
thejustinwalsh force-pushed the feat/docs-site-bootstrap-ac8db7 branch 2 times, most recently from dfa3051 to 377d907 Compare August 26, 2026 02:06
Base automatically changed from fix/bake-technique-options to main August 26, 2026 02:36
@thejustinwalsh
thejustinwalsh force-pushed the feat/docs-site-bootstrap-ac8db7 branch from 4d1eed3 to 123d8c5 Compare August 26, 2026 02:36
Add the site/ design: one private package composing a pmndrs/docs MDX
export under /docs and a WebGPU landing page at /, into site/dist exactly
as GitHub Pages serves it. Root docs/ stays the knowledge bundle, so the
generator reads site/docs.

The landing composition ships three justified columns with no exclusions,
because the v1 boundary cannot flow text around a contour. Milestone 12's
authored-exclusion core is committed immediately after it lands.
The hero sets glÿph in Playwrite US Trad through Slug, decomposed as
y + U+0308 so the mark proves the mark attachment it advertises. Six
codepoints bake to 59 KB.

The material is built on the technique's base contract — double-sided,
no depth write — because a material that misses it is silently culled.
Its normal rides positionLocal rather than the per-glyph renderCoordinate,
so the sheet is continuous across letters instead of lighting each glyph
on its own, and a two-dimensional relief field pushes the crossings
forward and back the way a written stroke sits over itself.

Workspace moves to react-three-fiber 10.0.0-alpha.4 in one step so a
single reconciler is installed.
A single close point light made the specular explode wherever it sat
nearest, so the rig is now directional: a travelling key, a fixed fill,
and a rim, with the environment carrying reflections only.

Leva drives the look in development behind one shared store. Calling
useControls from each consumer gave every component its own leva state,
so the panel bound to one copy while the material read another and
nothing appeared to update; a provider publishes one value to all three.
Material dials ride uniforms and update live, while pipeline parameters
are compiled into the graph and rebuild on change.

The panel's copy button emits the LOOK block verbatim, so values chosen
by eye come back as code rather than as a description.
…o the mark

Leva's values never reached the scene. Scene and Effects render inside
Canvas, which is its own reconciler root, and React context does not
cross it — both consumers were reading the default value while the panel
held another. The look is a plain mutable record now: the panel writes,
the frame loop reads, and dragging a slider no longer re-renders the
scene graph at all. A version counter covers the values that are compiled
into the render graph rather than read from uniforms.

Reset restores the committed block; Copy emits it.

The controls sit a measured distance under the mark instead of at the
bottom of the frame. Anchoring to the baseline put them through the tails
of g, y and p, so the clearance is taken below the line box with an
allowance in ems, dialled until Text can publish ink extents (#113).
The tooltips live in the top layer: the canvas and the control strip are
each stacking contexts, and a popover is the one place a small panel
escapes both without a z-index argument. Hover drives them on a pointer;
touch has none, so a held finger stands in, and lifting early still
copies or navigates.

Reset only ever worked on dials nobody had touched. Registering an input
with onChange makes it transient in leva — its value is withheld from the
returned object and set() will not write to it — so the restore silently
skipped exactly the values that had been moved. The panel reads its
values back instead and bridges them into the record the frame loop uses;
it renders nothing, so the scene is still never re-rendered by a drag.

The controls sit a small measured gap under the tails, taken from the
baseline in ems of the type so it holds at any viewport.
One continuous stream of the word in forty-nine languages across twenty-one
scripts, flowed through responsive justified columns behind the wordmark.
Three columns down to tablet, two to phone, one below that.

site/scripts/bake-chorus.mts owns the whole pipeline: it fetches the Noto
statics if absent, reads each face's cmap, works out which face should own
each code point in fallback order, and bakes every face to exactly that
subset. Coverage is derived rather than declared, so adding a word is a
one-line edit and an uncoverable one fails loudly naming the code points.
The derived mapping is written to chorus-coverage.json so it can be
reviewed and diffed.

The chorus renders through bitmap strikes rather than MSDF. MSDF bakes a
full-size atlas per face regardless of glyph count, and twenty-one of them
cost 448 MB of texture for a few hundred glyphs; strikes brought the whole
asset set to 352 KB.

Ragged rather than justified by default, and letters never spread. At
twenty-five characters a line the deficit has too few spaces to hide in,
so flush columns were paying for their edge with visible rivers.
Two measured causes, neither of them the glyph count.

The chorus batches through its own TextGroup with compositing set to
independent. The default, ordered, forbids Rust from reordering compatible
draws — correct for text that overlaps itself and needlessly strict for a
field of words that never touch. Draw calls fell from 505 to 121 and
geometries from 262 to 81.

Device pixel ratio is clamped. Left alone the canvas backed a 3064x3224
buffer and the post chain allocated several full-size float targets over
it, measured at 732 MB of texture; clamping to 1.5 took that to 243 MB.
Neither an exact curve nor a native strike was buying much from the extra
samples.

Run-length grouping stays as a dial but defaults to one. It was a fix for
a wrong diagnosis — draw calls are flat whatever the run length — and it
costs variety where a script has a single word in the list.
…a middle dot

Slug rather than a strike: it carries curve data instead of an atlas, so
the cost follows the glyphs actually baked rather than the number of
faces. Twenty-two faces came to 193 MB of texture against 243 MB for
strikes and 448 MB for MSDF.

Words are separated by U+00B7, baked from the Latin face that leads the
stack. Without a mark between them a run of unfamiliar scripts reads as
one continuous string rather than as a list of words in different
languages.

CJK renders. Noto Sans CJK is CFF-only and the baker rasterises a CFF
face to nothing, so Japanese, Korean and Chinese come from static
TrueType faces instead. Filed upstream as #114, and the bitmap baker now
refuses a bake where every requested glyph rasterised empty rather than
writing a well-formed artifact with no coverage.

The column gutter was always geometrically correct and simply too small
to see: three 499px columns separated by 18px, about one word space.

Also: draw calls fell from 505 to 121 once the chorus took its own
TextGroup with independent compositing, and clamping device pixel ratio
took the post chain's render targets from 732 MB to 243 MB. Six frames a
second to sixty.
lineHeight can only add leading, never remove it, and it resolves per
cluster from whichever face drew it — so one word from a face with tall
vertical metrics sets the height of its whole line and no leading value
brings it back. Measured across the stack: Myanmar 2.18 em and Arabic
2.11 em against roughly 1.30 for everything else, which meant nearly
every line took the tallest metrics on it.

Both are removed and the ceiling falls to 1.65 em. Hebrew stays, so the
field still changes direction mid-line and bidi still has work to do.

Filed upstream as #115: line height should normalise across a stack and
should be able to set a line shorter than a face's natural metrics, which
is what it means everywhere authors are coming from.

The chorus renders through MSDF again. Slug resolves curves per pixel and
there are tens of thousands of small static glyphs back there, which cost
more frame time than sampling an atlas.
Parallax by orbiting rather than sliding. A lateral camera move shifts the
nearer object further across the frame, so translating alone would swing
the wordmark and leave the field behind it nearly still — the wrong way
round. Aiming at the origin puts the mark on the pivot: it barely moves
while the chorus several units behind it travels.

Nothing in the scene animates but the camera and the key light; the
chorus holds a static transform.

The noise is two octaves of cubic-faded value noise, seeded, rather than
a dependency whose surface would go almost entirely unused. Deterministic
so the motion is the same on every load and can be judged rather than
just watched.

Chorus type is up two points.
Justification is on, which is what was asked for from the start; it had
been defaulted to ragged on a misdiagnosis of the gaps and left there.
Bounds are tight rather than generous: a space that can shrink absorbs
most deficits without opening a river, and a small per-gap letter
allowance takes what is left. Type is up to editorial size.

Camera motion is filtered through a one-pole low pass. Stacked noise
octaves are continuous but their derivative is not, and reading a target
straight onto the transform each frame is frame-rate dependent besides;
the coefficient is derived from elapsed time and a time constant, so the
same damping holds at 30fps and at 120. The lag it introduces is the
point, because a camera has mass. Shake speed halved.

Two features would end the line-fitting whack-a-mole and neither exists
yet: hyphenation (D-157, and roadmap 11.13 proved only that the contract
can represent a break-inserted hyphen) and non-greedy line breaking
(D-043). Greedy decides each line locally, so improving one line pushes
the deficit into the next.
Three stacked drops rather than one: a tight contact shadow, a mid
falloff, and a wide low-opacity spread that lifts the strip off a field
of type it would otherwise sit flat against. The outer glow is unchanged.
… alpha

Space shows and hides the panel and the frame counter together, starting
shown. The flag is a subscribable module value rather than context, for
the same reason the look is: the counter renders inside Canvas and the
panel outside it, and context does not cross that boundary. Leva is
hidden rather than unmounted so a toggle never discards a dialled value,
and the key is ignored while the caret is in a field.

Chorus alpha settles at 0.065. It dims through paint opacity rather than
a darker colour, so the field blends toward whatever is behind it and
picks up the mark's halo where they overlap.
Values taken from the panel's handoff block verbatim. The material moved
most: metalness 0.18 to 0.61 and sheet curvature 0.40 to 0.87, which
carries a far stronger highlight across the stroke, with roughness opened
to 0.425 so it reads as sheen rather than mirror.

Sorted to match the file rather than the paste; every key cross-checked
against the interface.
The MSDF em size defaults to 64 atlas texels, and the chorus sets at
roughly 28 css px — more than twice the resolution it can show. Atlas
cost is area, so baking at 32 with a 6px range took the face set from
4.64 MB to 1.33 MB with no visible change.

Raster options are part of the raster identity: a request naming
different options than the artifact was baked with derives a different
key, finds no match, falls back to runtime generation, and fails for
want of source bytes. The runtime request names the same values, and
both sides read them from one constant.

The docs build runs. Its npx cache is redirected into the workspace
because npx and pnpm dlx both unpack under $HOME, where Next resolves
its workspace root to the nearest lockfile and then refuses that root
for including the home directory.

Adds the core API page shell, sectioned to match the canonical planning
document.
The rebase left two minimumReleaseAgeExclude keys, which pnpm rejects as
a duplicated mapping. Both lists belong.
Rebasing onto current main moved two things the landing page uses.

measureLayout() is now layout(). And the material context is a union: each
known technique carries its own typed shader, while a generic arm carries
an untyped outputs map for techniques a build does not know, so the union
only narrows once the technique is checked. The Slug material narrows
explicitly and falls back to the technique's own default otherwise, rather
than casting past the question.
Adopts the measurement API's inkBounds. contentWidth/contentHeight are
advance extents — the space the text claims — and are right for a host
laying out boxes; they are wrong for centring, because a cursive face
overhangs its advances and Playwrite's line box is far taller than its
ink. Centring on the box left the mark low and put the controls through
the tails of g, y and p, which the dialled descender allowance was there
to paper over. inkBounds is the union of the positioned outlines, so that
allowance is deleted rather than retuned.

Two placement bugs went with it. The offset lived in a ref, which never
triggers a render, so the position prop held its initial value until an
unrelated resize forced one — resizing appeared to fix the page because
it did. Position is now set on the object inside the frame loop.

And a paragraph is pending on the frame it is authored and committed on
the next, so frame one has no layout to measure: layout() returning
undefined there is truthful, not missing data. The mark stays hidden until
it has been measured once, so no unplaced frame is ever drawn.
The r3f alpha bump and the bake option plumbing changed package sources,
and the rebase resolved these generated files to upstream, so their pins
described a tree that no longer existed.
oxfmt reads .mdx as markdown and normalises the asterisks in an MDX
expression comment into emphasis, turning {/* … */} into {/_ … _/}. That
is not an expression acorn can parse, so the page compiled until the
formatter first touched it and the docs build then failed with a parse
error a long way from the cause.

The formatter is scoped to the sources it can actually format, and the
page is repaired. Found by running the composed build for the first time:
each half passed alone, because nothing between them reads MDX.
The npx cache has to sit inside the workspace so Next resolves its root
here rather than in $HOME, and a docs build writes thousands of files
into it and into dist. Vite watched both, so every docs build became a
reload storm and the dev page never survived long enough to render — the
server was up the whole time, and thrashing.
The motion swayed on a diagonal and read as rhythmic because every axis
sampled one clock with one set of octave ratios: decorrelated sequences
still turn over together when they share a rate, and shared harmonics
realign on a period the eye learns. Each axis now carries its own octave
ratios and its own offset into the sequence, sampled at its own rate,
none of them rational against another.

A slow envelope gates the amplitude so the camera drifts, settles for a
few seconds, and picks up again — constant-amplitude noise is relentless,
and relentless is what turns small movement into motion sickness. Each
axis also settles on a slightly different time constant, so the filter
stops imposing a rhythm the noise no longer has.

Column gap down four points.
@thejustinwalsh
thejustinwalsh force-pushed the feat/docs-site-bootstrap-ac8db7 branch from 123d8c5 to 1930951 Compare August 26, 2026 02:36
Isolation buys exactly one thing, SharedArrayBuffer, and neither the
shaper nor the baker Wasm declares a shared memory — read out of the
module section tables rather than assumed. The page renders identically
without the headers: crossOriginIsolated goes false, WebGPU is untouched,
draw calls and frame rate unchanged.

Requiring isolation costs reach. COEP require-corp rejects every
cross-origin subresource that does not carry CORP, which surfaces as a
CORS error for anyone embedding this page or serving its assets from
another origin. The headers were carried over from the r3f example on the
assumption the Wasm needed them.
The docs-site branch had bumped @react-three/fiber to 10.0.0-alpha.4 across
packages/glyph, apps/benchmarks and apps/r3f-hello-world. Nothing in the site
requires it: drei 11.0.0-alpha.5 peers >=10.0.0-0, so alpha.3 satisfies it.

Moving the alpha is a core change with a core blast radius, and alpha.4 was
measured to break <Text paint={...}> type-checking in tests/types/r3f-v1-api.test.ts.
That belongs in its own pull request.
…roduction

The dev overlays were guarded with `import.meta.env.DEV`, which is a
build-time constant, so the branches did fold away. The imports did not:
leva builds its stitches theme at module scope, and a production build
carried its whole panel UI — theme, CSS-in-JS runtime and all.

Split the look into a module production actually needs (`live`, read every
frame) and a dev module that writes to it, and reach the dev modules only
through `import()` from a branch that folds to `null`. Rolldown then never
emits the chunk.

Measured on `vite build`:

  bundle   3,301.75 kB -> 3,117.62 kB   (gzip 975.85 -> 913.60)
  emitted dev chunks: none
  `titleBar`, `stitches`, `__look`: absent

The remaining `leva` substring matches are inside our own `keyElevation`.

Also guard the nullable `renderPipeline`: r3f alpha.3 types it `T | null`,
since the callback also runs for teardown.
Vite's default `server.host` is `localhost`, which listens on `[::1]` and
nothing else, so http://10.0.0.62:5275 was a refused connection rather than
a page. Measured before and after on the same config:

  before  TCP [::1]:5299 (LISTEN)   curl 10.0.0.62 -> exit 7, refused
  after   TCP *:5299     (LISTEN)   curl 10.0.0.62 -> HTTP 200

Vite's own host allowlist was never involved: isHostAllowedInternal returns
true for any ipv4 or ipv6 host header.
@thejustinwalsh

Copy link
Copy Markdown
Collaborator Author

Superseded by #132, which rebases this site/docs work onto the recovered renderer baseline now on main. A scoped tree comparison confirms the #116 site, docs-site planning, bake guard, font assets, and fixture-sync changes are retained in #132. Keeping this branch intact as historical backup.

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