feat(site): bootstrap the site surface and build the landing page - #116
Closed
thejustinwalsh wants to merge 29 commits into
Closed
feat(site): bootstrap the site surface and build the landing page#116thejustinwalsh wants to merge 29 commits into
thejustinwalsh wants to merge 29 commits into
Conversation
size-limit report 📦
|
thejustinwalsh
changed the base branch from
main
to
fix/bake-technique-options
August 25, 2026 20:24
thejustinwalsh
force-pushed
the
feat/docs-site-bootstrap-ac8db7
branch
2 times, most recently
from
August 26, 2026 02:06
dfa3051 to
377d907
Compare
thejustinwalsh
force-pushed
the
feat/docs-site-bootstrap-ac8db7
branch
from
August 26, 2026 02:36
4d1eed3 to
123d8c5
Compare
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
force-pushed
the
feat/docs-site-bootstrap-ac8db7
branch
from
August 26, 2026 02:36
123d8c5 to
1930951
Compare
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.
Collaborator
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bootstraps
site/, a third top-level category besidepackages/andapps/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ÿphin Playwrite US Trad through Slug, with adefineTextMaterialnode material over a studio built from lightformers rather than a downloaded HDRI, and bloom → lensflare → chromatic aberration throughuseRenderPipeline. 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.mtsowns the whole job: it fetches the Noto statics when absent, reads each face'scmap, 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 tochorus-coverage.jsonso 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
LOOKblock 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:
The chorus takes its own
TextGroupwithcompositing="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:
Textpublishes no bounding box, soBox3.setFromObjectreturns nothing usable and drei'sCenter/Boundssilently no-op.outline.draw()succeeds on a CFF face and emits zero pen commands, taking the samemark_absentpath as a glyph that was never requested. This is why CJK laid out at full width and drew holes. This PR adds a guard: a bake where every requested glyph rasterised empty is now an error rather than a well-formed artifact with no coverage.lineHeightonly ever adds leading and is resolved per cluster from whichever face drew it, so one word from a tall face sets the height of its whole line and no value can bring it back. Measured across this stack: Myanmar 2.18 em and Arabic 2.11 em against ~1.30 for most faces.Known blocker
The docs build does not run on this machine.
pnpm dlxandnpxboth fail identically:Next resolves its workspace root to the nearest lockfile, finds
package.json,pnpm-lock.yamlandpnpm-workspace.yamlin$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/docsas a devDependency ofsite/sonextresolves 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.4across the workspace in one step, so a single reconciler is installed rather than two.