From 4ba487fcad52586fc4f856ef801db8e927890d51 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Fri, 14 Aug 2026 15:26:16 +0200 Subject: [PATCH] Put the explanation in the README, and keep it from rotting The one-line `description` is what the index can afford. It is not what a reader wants once they have opened the example, and until now there was nothing else: `.md` went straight from the facts block to 200 lines of TSX. The explainer lives in the example's own `README.md`, under the badges. That file already exists in all 170 -- three badges, a `degit` line and a thumbnail -- so this is additive: no new file, no new convention. GitHub renders it, `degit` carries it into whatever the reader scaffolds, and `bin/build-llms.mjs` inlines it into `.md` under the facts, which is what finally puts an explanation into the document agents are served. `llms.txt` is untouched and keeps its single line: one line to choose, the full explainer once chosen. Structure is fixed, length is not -- the problem, the technique, the pitfalls. A fixed structure resists drift better than a word ceiling, and a demo that needs 200 words should not be padded to 400. Prose that explains code goes stale on its own, so it gets a mechanism, and the obvious one is wrong. A hash of the source stored next to the explainer would have fired on 161 examples the day the prettier hook landed, and the only rational response would have been to re-stamp 161 hashes without reading a word -- training precisely the reflex that makes a tripwire worthless. What fires on the right commits instead: every identifier the prose puts in backticks must still appear somewhere in the example's `src/`. A concatenation and a search, no import parsing, no package resolution. `useMask` disappears in a drei migration and it breaks on precisely the explainers that named it; a formatting sweep never wakes it. And it reaches further than renames -- in a declarative r3f scene the technique *is* the set of components used. Deliberately looser than the rule on `apis`, which is checked against the example's *imports*: prose may name the demo's own components (`Aquarium`, `Turtle`) and its props (`stencil` in `gl={{ stencil: true }}`). What it gives up is small -- an identifier deleted from the code but surviving in a comment still passes. `//#lint:metadata` gains `examples/*/{README.md,CONTEXT.md}` as inputs, and its source glob widens to the shaders and stylesheets the prose is now checked against. Cached past either, the rule stops being one, which is the same argument that put `examples/*/src/**` there for `apis`. `docs/agents/domain.md` goes multi-context in the same change. The per-example `CONTEXT.md` this relies on had no convention behind it -- that rewrite had been sitting in a stash since #193, with no issue and no branch, and adding this issue's two rules to the single-context version would have meant writing them twice. Those rules: `teach` is a legitimate producer of `CONTEXT.md` alongside `/domain-modeling`, and no demo gets one that merely restates its README. `aquarium` is the first example written this way, and the case the skill already used to name the problem. Its explainer is the one thing a reader cannot get from the source by looking: nothing writes the mask's reference into the stencil buffer, so the contents fail the test everywhere on the canvas and are drawn only into `MeshTransmissionMaterial`'s own render targets, which carry no stencil buffer. The glass is not showing what is behind it -- it is the only surface its contents appear on, which is why spheres sitting outside the box read as suspended in it. Its `description` and `apis` come from the same reading, so the ratchet list in `bin/description-exceptions.mjs` loses its first entry. Absence is the normal state. `--explain` is multi-session, interactive work driven by curiosity about a particular demo, not a programme across 170. An example with an explainer is better; one without is fine. Closes #199 --- .claude/skills/describe-example/SKILL.md | 4 +- AGENTS.md | 2 +- bin/build-llms.mjs | 13 +++ bin/description-exceptions.mjs | 11 +- bin/lib/explainer.mjs | 79 ++++++++++++++ bin/lib/render-llms.mjs | 6 ++ bin/validate-pmndrs-metadata.mjs | 114 ++++++++++++++++++-- docs/agents/domain.md | 70 +++++++++++-- examples/aquarium/CONTEXT.md | 11 ++ examples/aquarium/README.md | 26 +++++ examples/aquarium/pmndrs.json | 3 +- test/explainer.test.ts | 127 +++++++++++++++++++++++ test/render-llms.test.ts | 29 ++++++ test/turbo-cache.test.ts | 18 ++++ turbo.json | 11 +- 15 files changed, 492 insertions(+), 32 deletions(-) create mode 100644 bin/lib/explainer.mjs create mode 100644 examples/aquarium/CONTEXT.md create mode 100644 test/explainer.test.ts diff --git a/.claude/skills/describe-example/SKILL.md b/.claude/skills/describe-example/SKILL.md index b22b8c74e..fcb2fdad9 100644 --- a/.claude/skills/describe-example/SKILL.md +++ b/.claude/skills/describe-example/SKILL.md @@ -10,9 +10,9 @@ Two fields of one example's `pmndrs.json`, written by reading its source: the on ## Why this is a skill -The gallery publishes an agent-readable surface — `llms.txt`, `/examples/.md`, `/examples/.json`, built by `bin/build-llms.mjs` and served both over the pmndrs docs MCP server and by `rel="alternate"` on every example page. The pipeline is sound. The content it carries is not: **40 of the 170 descriptions are empty**, and the other 130 run to a median of **56 characters** that generally name no technique. +The gallery publishes an agent-readable surface — `llms.txt`, `/examples/.md`, `/examples/.json`, built by `bin/build-llms.mjs` and served both over the pmndrs docs MCP server and by `rel="alternate"` on every example page. The pipeline is sound. The content it carries is not: **39 of the 170 descriptions are empty**, and the rest run to a median of **56 characters** that generally name no technique. -`aquarium` is the case that names the problem. It ships an empty description and the single tag `transmission`, while what actually makes the demo work is a **stencil mask** — `useMask` plus a backside `MeshTransmissionMaterial` — named nowhere. An agent asking "how do I do refractive glass" cannot tell it from the other 169, and having opened it, pays full token price to rediscover the trick by reading 200 lines of TSX. +`aquarium` is the case that names the problem, and the one this skill has been run on. It shipped an empty description and the single tag `transmission`, while what actually makes the demo work is a **stencil mask** — `useMask` plus a backside `MeshTransmissionMaterial` — named nowhere. An agent asking "how do I do refractive glass" cannot tell it from the other 169, and having opened it, pays full token price to rediscover the trick by reading 200 lines of TSX. Fixing that is 170 acts of judgement. The two contracts below are what keeps those 170 acts consistent, which is why they live in this file rather than in a reviewer's memory. diff --git a/AGENTS.md b/AGENTS.md index 457b564f0..000cc8195 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Canonical vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-fo ### Domain docs -Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root (created lazily by `/domain-modeling`). See `docs/agents/domain.md`. +Multi-context: one `CONTEXT.md` + `docs/adr/` per workspace package — `apps/website/`, `packages/e2e/`, and one per `examples//` — with the root `docs/adr/` holding system-wide decisions (build, CI, dependency policy). The context is resolved by path, and every file is created lazily by `/domain-modeling`. See `docs/agents/domain.md`. ### UI components diff --git a/bin/build-llms.mjs b/bin/build-llms.mjs index d396f5532..cc00a2349 100644 --- a/bin/build-llms.mjs +++ b/bin/build-llms.mjs @@ -37,6 +37,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { explainerOf } from "./lib/explainer.mjs"; import { renderExample, renderIndex } from "./lib/render-llms.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -149,10 +150,22 @@ for (const name of names) { }; index.push(summary); + // The explanation a reader wants once they have chosen. It lives in the + // example's `README.md` -- where GitHub renders it and `degit` carries it + // into whatever the reader scaffolds -- and is inlined here so the document + // an agent is served is not the one that stops at a single line. It stays out + // of `summary`, and so out of `llms.txt`: that file is read at the start of + // every question, and one line each is what it can afford. + const readmePath = path.join(exampleDirectory, "README.md"); + const explainer = fs.existsSync(readmePath) + ? explainerOf(fs.readFileSync(readmePath, "utf8")) + : ""; + const example = { ...summary, repository: `https://github.com/pmndrs/examples/tree/main/examples/${name}`, install: `npx degit pmndrs/examples/examples/${name}`, + ...(explainer && { explainer }), dependencies: packageJson.dependencies ?? {}, files: paths.filter(isInlinable).map((file) => ({ path: file, diff --git a/bin/description-exceptions.mjs b/bin/description-exceptions.mjs index 93daf44d5..2f9afd3fc 100644 --- a/bin/description-exceptions.mjs +++ b/bin/description-exceptions.mjs @@ -41,16 +41,15 @@ export const DESCRIPTION_MAX_LENGTH = 120; // -// Examples that ship `"description": ""`. Forty of the hundred and seventy, on -// 2026-08-14; the other hundred and thirty run to a median of 56 characters. +// Examples that ship `"description": ""`. Thirty-nine of the hundred and +// seventy; the rest run to a median of 56 characters. // // Nothing groups them. They are not the old examples, or the small ones, or -// the ones nobody looks at -- `aquarium`, `caustics` and `portals` are here. -// The field was simply never required to hold anything, so for these forty it -// never got anything. +// the ones nobody looks at -- `caustics` and `portals` are here, and +// `aquarium` was until it became the first entry this list lost. The field was +// simply never required to hold anything, so for these it never got anything. // export const UNDESCRIBED = [ - "aquarium", "bloom-hdr-workflow-gltf", "cards", "cards-with-border-radius", diff --git a/bin/lib/explainer.mjs b/bin/lib/explainer.mjs new file mode 100644 index 000000000..b4aed2744 --- /dev/null +++ b/bin/lib/explainer.mjs @@ -0,0 +1,79 @@ +/** + * The long-form explainer an example carries in its own `README.md`, and the + * identifiers its prose names. + * + * Two readers share this file. `bin/build-llms.mjs` lifts the explainer out of + * the README and inlines it into `/examples/.md`, so the document agents + * are served carries the explanation rather than a one-line description and 200 + * lines of TSX. `bin/validate-pmndrs-metadata.mjs` reads the identifiers back + * out and checks them against the example's source, which is the only thing + * keeping the prose from rotting quietly. + * + * Both are textual, and deliberately so. No markdown parser, no import + * resolution: what the rule needs is the names as written, and a dependency + * here would sit on the critical path of the site build and of `turbo`'s cache. + */ + +/** + * The header every example's README opens with -- three badges, the `degit` + * line, the thumbnail -- and nothing else, in all 170 of them. It is generated + * scaffolding, so the explainer goes under it rather than into it: the badges + * and the scaffold command are one block, and prose spliced into the middle + * separates the command from the links that point at the same thing. + * + * The thumbnail is the last line of that block, which makes it the marker. A + * README without one has no explainer to find -- better than guessing, which + * would publish the badges as an explanation the first time the header moves. + */ +const THUMBNAIL = /^!\[[^\]]*\]\(\s*thumbnail[^)]*\)[^\S\n]*$/gm; + +/** The prose under the badge header, or `""` when the example has none. */ +export function explainerOf(readme) { + const markers = [...readme.matchAll(THUMBNAIL)]; + if (markers.length === 0) return ""; + + const last = markers[markers.length - 1]; + return readme.slice(last.index + last[0].length).trim(); +} + +/** + * A fenced block is quoted code, not prose about this example: it may show a + * reader how to carry the technique into their own scene, where the names are + * theirs and not ours. Exempt, so the rule stays a rule about what the prose + * claims. + */ +const FENCED_BLOCK = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1[^\n]*$/gm; + +/** An inline code span, single- or multi-backtick. */ +const CODE_SPAN = /(`+)([^`][\s\S]*?)\1/g; + +const IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/g; + +/** + * Every identifier-shaped word in `text`. Applied to source it is a vocabulary, + * applied to a code span it is what that span claims -- and the check is the + * difference, which is why both sides tokenize the same way. Matching whole + * words rather than substrings is the point: prose naming `Mask` must not pass + * because the source happens to contain `useMask`. + */ +export function identifiersIn(text) { + return new Set(text.match(IDENTIFIER) ?? []); +} + +/** + * The identifiers a document puts in backticks. Whole spans are tokenized + * rather than taken verbatim, because a technique carried by a prop is written + * as the prop is written -- `gl={{ stencil: true }}` names `gl` and `stencil`, + * and both are checkable. + */ +export function backtickedIdentifiers(markdown) { + const names = new Set(); + + for (const [, , span] of markdown + .replace(FENCED_BLOCK, "") + .matchAll(CODE_SPAN)) { + for (const name of identifiersIn(span)) names.add(name); + } + + return names; +} diff --git a/bin/lib/render-llms.mjs b/bin/lib/render-llms.mjs index 98d891ee1..52abaf22a 100644 --- a/bin/lib/render-llms.mjs +++ b/bin/lib/render-llms.mjs @@ -163,6 +163,12 @@ export function renderExample(example) { `# ${example.title}`, example.description.trim(), facts.join("\n"), + // The explainer, from the example's own README. Under the facts and above + // the source, because it is what the facts were chosen to introduce and + // what the source is read against -- an agent that stops here should have + // the technique, and one that reads on should know what to look for. + // Absent on most examples, and that is the expected state. + example.explainer?.trim(), example.assets.length && `## Asset attribution\n\n${attribution(example.assets)}`, ...example.files.map((file) => { diff --git a/bin/validate-pmndrs-metadata.mjs b/bin/validate-pmndrs-metadata.mjs index 7cf9f24f7..ee2c3fc64 100644 --- a/bin/validate-pmndrs-metadata.mjs +++ b/bin/validate-pmndrs-metadata.mjs @@ -9,6 +9,11 @@ import { OVERLONG, UNDESCRIBED, } from "./description-exceptions.mjs"; +import { + backtickedIdentifiers, + explainerOf, + identifiersIn, +} from "./lib/explainer.mjs"; import { importedIdentifiers } from "./lib/imports.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -62,29 +67,92 @@ const SOURCE_EXTENSIONS = new Set([ ".cjs", ]); -/** Every identifier the example's own `src/` imports, across all of its files. */ -function importsOf(exampleDirectory) { - const identifiers = new Set(); +/** + * What the prose is checked against, which is wider than what the imports are + * read from: a shader uniform or a class name is as much a thing an explainer + * can name as a drei export. + */ +const TEXT_EXTENSIONS = new Set([ + ...SOURCE_EXTENSIONS, + ".css", + ".json", + ".glsl", + ".vert", + ".frag", +]); + +/** Every file under the example's `src/`, at any depth, with those extensions. */ +function sourceFiles(exampleDirectory, extensions) { + const files = []; const walk = (dir) => { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const absolute = path.join(dir, entry.name); if (entry.isDirectory()) walk(absolute); - else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) { - for (const identifier of importedIdentifiers( - fs.readFileSync(absolute, "utf8"), - )) { - identifiers.add(identifier); - } - } + else if (extensions.has(path.extname(entry.name))) files.push(absolute); } }; walk(path.join(exampleDirectory, "src")); + return files; +} + +/** Every identifier the example's own `src/` imports, across all of its files. */ +function importsOf(exampleDirectory) { + const identifiers = new Set(); + + for (const file of sourceFiles(exampleDirectory, SOURCE_EXTENSIONS)) { + for (const identifier of importedIdentifiers( + fs.readFileSync(file, "utf8"), + )) { + identifiers.add(identifier); + } + } + return identifiers; } +/** + * Every identifier-shaped word anywhere in the example's `src/`, comments and + * strings included. Looser than `importsOf` on purpose -- see the rule below. + */ +function vocabularyOf(exampleDirectory) { + const vocabulary = new Set(); + + for (const file of sourceFiles(exampleDirectory, TEXT_EXTENSIONS)) { + for (const word of identifiersIn(fs.readFileSync(file, "utf8"))) { + vocabulary.add(word); + } + } + + return vocabulary; +} + +/** + * The two documents an example can carry prose in: the explainer under the + * badges in `README.md`, and the glossary in `CONTEXT.md`. Both are optional, + * and on most examples neither exists. + */ +function proseOf(exampleDirectory) { + const documents = []; + + const readmePath = path.join(exampleDirectory, "README.md"); + if (fs.existsSync(readmePath)) { + // Only what is under the badges: the header is generated scaffolding, and + // its `degit` line names the repository, not this example's source. + const explainer = explainerOf(fs.readFileSync(readmePath, "utf8")); + if (explainer) documents.push(["README.md", explainer]); + } + + const contextPath = path.join(exampleDirectory, "CONTEXT.md"); + if (fs.existsSync(contextPath)) { + documents.push(["CONTEXT.md", fs.readFileSync(contextPath, "utf8")]); + } + + return documents; +} + const exampleNames = fs .readdirSync(examplesDirectory) .filter((name) => @@ -192,6 +260,32 @@ for (const exampleName of exampleNames) { } } + // Prose that explains code goes stale on its own, and the obvious tripwire -- + // a hash of the source, stored next to the explainer -- would have fired on + // 161 examples the day a prettier hook landed, training the reflex of + // re-stamping without reading. This one fires on renames instead: every + // identifier the prose puts in backticks has to still appear somewhere in the + // example's `src/`. `useMask` disappears in a drei migration and it breaks on + // precisely the explainers that named it; a formatting sweep never wakes it. + // + // Deliberately looser than the rule on `apis`, because it guards prose rather + // than a data field: an entry in `apis` that is not *imported* has no + // business there, whereas an explainer may name the demo's own components + // (`Aquarium`, `Turtle`) and its props (`stencil` in `gl={{ stencil: true }}`). + // What it gives up is small -- an identifier deleted from the code but + // surviving in a comment still passes. + const prose = proseOf(exampleDirectory); + if (prose.length > 0) { + const vocabulary = vocabularyOf(exampleDirectory); + for (const [file, text] of prose) { + for (const name of backtickedIdentifiers(text)) { + if (!vocabulary.has(name)) { + addError(exampleName, `${file} names \`${name}\`, absent from src/`); + } + } + } + } + if (!Array.isArray(metadata.assets)) { addError(exampleName, '"assets" must be an array'); } else { diff --git a/docs/agents/domain.md b/docs/agents/domain.md index d0d741bb0..289ec6ea2 100644 --- a/docs/agents/domain.md +++ b/docs/agents/domain.md @@ -2,35 +2,87 @@ How the engineering skills should consume this repo's domain documentation when exploring the codebase. -This repo is **single-context**: one `CONTEXT.md` and one `docs/adr/` at the repo root cover the whole codebase (website + examples). +This repo is **multi-context**. A context is a workspace package, and there are three kinds: + +| Context | Where | Covers | +| ---------------------- | ------------------ | -------------------------------------------------------------- | +| The website | `apps/website/` | The catalog UI — Next.js static export, shadcn/ui, m3 tokens | +| The e2e harness | `packages/e2e/` | Playwright + Chromatic visual regression, snapshot determinism | +| One per example (×170) | `examples//` | That single demo — its scene, its loaders, its own vocabulary | + +Everything that spans packages — the build pipeline (`bin/`, `turbo.json`), CI (`.github/`), dependency policy (`pnpm-workspace.yaml` overrides and patches, `syncpack`) — is **system-wide** and lives in the root `docs/adr/`, not in any one context. + +## Resolving the context for a path + +Resolution is **by path**, not by lookup in a map — with 172 contexts a hand-maintained index goes stale on the first `examples/` addition. Given a file you are about to work on, walk up from it: + +1. `examples//…` → the context is `examples//` +2. `apps/website/…` → the context is `apps/website/` +3. `packages/e2e/…` → the context is `packages/e2e/` +4. anything else (`bin/`, `test/`, `schemas/`, `.github/`, root config) → system-wide only + +`CONTEXT-MAP.md` at the root, if it exists, is a human-facing reading list — it does not need to be exhaustive, and the path rule above wins when the two disagree. ## Before exploring, read these -- **`CONTEXT.md`** at the repo root -- **`docs/adr/`** — read ADRs that touch the area you're about to work in. +For the context you resolved: + +- **`/CONTEXT.md`** — its glossary +- **`/docs/adr/`** — its context-scoped decisions + +Then, always: -If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. +- **`docs/adr/`** at the root — system-wide decisions. Read the ones that touch the area you're about to work in. + +When a change spans contexts (a website feature that reads example metadata, an e2e change driven by an example), read every affected context's `CONTEXT.md`, not just the one you started in. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront; don't scaffold empty stubs across `examples/`. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved — so on most demos there will be nothing to read, and that is the expected state. + +## Who writes an example's `CONTEXT.md` + +`/domain-modeling` is not the only producer. **`teach` is the other one**, reached through `/describe-example --explain`: it runs `/mattpocock-skills:teach` in sub-agents over one demo's source, and two things come back out of the ephemeral workspace — the reference distillation, which becomes the explainer in that example's `README.md`, and the glossary, which becomes its `CONTEXT.md`. Everything else the passes grew (`MISSION.md`, `learning-records/`, `lessons/`) is one person's learning state and stays outside the repo. + +Both producers write the same file to the same contract, so a `CONTEXT.md` does not record which one wrote it. + +**No demo gets a `CONTEXT.md` that merely restates its `README.md`.** The explainer holds the technique — the problem, how it works, what it costs. The glossary holds terms that are genuinely local to the demo and that a reader would otherwise have to infer. When the only content would be a paraphrase of the explainer, the file should not exist: it doubles the surface that has to be kept true and says nothing the reader did not already have. ## File structure ``` / -├── CONTEXT.md -├── docs/adr/ -│ ├── 0001-....md -│ └── 0002-....md +├── CONTEXT-MAP.md ← optional reading list +├── docs/adr/ ← system-wide decisions +│ ├── 0001-….md +│ └── 0002-….md +├── bin/ ← system-wide, no context of its own ├── apps/website/ +│ ├── CONTEXT.md +│ └── docs/adr/ +├── packages/e2e/ +│ ├── CONTEXT.md +│ └── docs/adr/ └── examples/ + ├── aquarium/ + │ ├── CONTEXT.md + │ └── docs/adr/ + ├── arkanoid/ + │ ├── CONTEXT.md + │ └── docs/adr/ + └── … (one context per demo, 170 of them) ``` ## Use the glossary's vocabulary -When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in the relevant `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). +Terms shared by every demo — example, scene, canvas, snapshot, the `pmndrs.json` metadata — belong to the **system-wide** vocabulary, not to any one example's `CONTEXT.md`. A per-demo glossary should only hold what is genuinely local to that demo. + ## Flag ADR conflicts If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: > _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ + +A context-scoped ADR binds only its context; a root ADR binds all of them. If a context ADR contradicts a root ADR, the root one wins and the conflict is worth raising. diff --git a/examples/aquarium/CONTEXT.md b/examples/aquarium/CONTEXT.md new file mode 100644 index 000000000..a4f37a7be --- /dev/null +++ b/examples/aquarium/CONTEXT.md @@ -0,0 +1,11 @@ +# Aquarium + +Vocabulary local to this demo. The technique itself is in the README. + +## Glossary + +**Aquarium** — the component, which is the glass mesh _and_ the masked group together. The mesh alone is the box; neither half on its own is the aquarium, and a change that keeps the mesh but drops the group has not kept the aquarium. + +**Contents** — exactly what `Aquarium` is handed as children, and so exactly the subtree whose materials are given the stencil test. Not a loose word for "what is inside the box": something rendered next to `Aquarium` rather than within it is not contents, however close it sits, and it will be drawn on the canvas like anything else. + +**Mask** — used here in the opposite sense to drei's. A drei mask is a shape drawn to reveal what it covers; this one is written by nothing, so it reveals nowhere. Containment does not come from the mask succeeding, it comes from the mask being inert in the buffer the glass samples. Read `useMask` in this demo as "keep off the canvas", not as "show through the shape". diff --git a/examples/aquarium/README.md b/examples/aquarium/README.md index 15a89fa17..56acddeef 100644 --- a/examples/aquarium/README.md +++ b/examples/aquarium/README.md @@ -7,3 +7,29 @@ $ npx degit pmndrs/examples/examples/aquarium ``` ![](thumbnail.webp) + +## The problem + +Glass that _contains_ something is not the same problem as glass. Transparency alone gets you a pane: whatever is behind it is still drawn on its own terms, so contents that reach past the edges of the box stay visible past them, and the result reads as a sheet in front of a scene rather than as a vessel. The spheres here are placed well outside the box, some of them more than twice its half-width away — the containment is not geometric, and no amount of tuning the glass will produce it. + +## The technique + +The contents are drawn nowhere on the canvas, and only inside the glass. + +`useMask` returns a set of material properties that test the stencil buffer against a reference value, and `Aquarium` assigns them, once on mount, to every material it finds under the group holding its children. Nothing in this demo ever writes that reference into the canvas stencil buffer. The test therefore fails everywhere, and the turtle and the spheres are never drawn to the screen at all. + +`MeshTransmissionMaterial` is what draws them. It does not sample `three`'s shared transmission pass: it renders the whole scene itself, into its own render targets, with the glass mesh swapped out for a discarding material, and its refraction shader samples the result. Those render targets carry no stencil buffer — and a stencil test against a buffer that does not exist passes. The same materials that are rejected on the canvas are accepted there. + +So the contents exist only inside the refraction. The box is not showing what sits behind it; it is the only surface on which its contents appear anywhere, which is why spheres positioned outside it read as suspended in it — clipped exactly to its silhouette, carrying the `distortion` and `chromaticAberration` of the material that drew them. + +`backside` adds a pass over the box's back faces before the main one, so the far wall is refracted through the near one. That is what makes it read as a volume of glass rather than a single sheet. + +## The pitfalls + +**`gl={{ stencil: true }}` on the `Canvas` is load-bearing, and it is not the default.** A context is created without a stencil buffer unless one is asked for, and that puts the canvas in the same position as the render targets: the test passes, and every sphere is drawn on screen, floating outside the box. The prop is easy to drop in a rewrite because nothing else in the file mentions it. + +**The masking happens once, by traversal.** Anything mounted into the contents after that, or any material replaced later, is not masked and appears on the canvas. + +**Invisible is not absent.** The contents are still in the scene graph, so they are still raycast — a pointer handler on something inside the box fires from a region of the canvas where nothing was drawn. + +**The frame is three renders of the scene, not one:** the backside pass, the main transmission pass, and the canvas. `samples` and the buffer resolution are the two knobs that matter if it costs too much. diff --git a/examples/aquarium/pmndrs.json b/examples/aquarium/pmndrs.json index bcb1dcf7a..d389c641c 100644 --- a/examples/aquarium/pmndrs.json +++ b/examples/aquarium/pmndrs.json @@ -1,8 +1,9 @@ { "$schema": "../../schemas/pmndrs.schema.json", "title": "Aquarium", - "description": "", + "description": "Glass rendered as a stencil mask rather than transparency, with a backside transmission material.", "tags": ["transmission"], + "apis": ["useMask", "MeshTransmissionMaterial"], "authors": ["Paul Henschel"], "publishedAt": "2023-01-29", "source": "https://codesandbox.io/s/n7jf0f", diff --git a/test/explainer.test.ts b/test/explainer.test.ts new file mode 100644 index 000000000..003009daf --- /dev/null +++ b/test/explainer.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; + +// @ts-expect-error -- plain JS, shared with bin/build-llms.mjs +import { + backtickedIdentifiers, + explainerOf, + identifiersIn, +} from "../bin/lib/explainer.mjs"; + +/** + * Two rules, and they fail in opposite directions. Lifting too little out of a + * README publishes an example with no explanation; lifting too much publishes + * three badges as one. Reading too few identifiers out of the prose lets a + * renamed API sit in the document forever; reading too many turns a formatting + * sweep into a lint failure nobody can act on. + */ + +const HEADER = [ + "[![Static](https://img.shields.io/badge/example-x)](https://pmndrs.github.io/examples/aquarium)", + "", + "```sh", + "$ npx degit pmndrs/examples/examples/aquarium", + "```", + "", + "![](thumbnail.webp)", +].join("\n"); + +describe("explainerOf", () => { + it("is what sits under the badge header", () => { + expect( + explainerOf(`${HEADER}\n\n## The problem\n\nGlass, contained.\n`), + ).toBe("## The problem\n\nGlass, contained."); + }); + + it("is empty for the 170 READMEs that are only a header", () => { + expect(explainerOf(`${HEADER}\n`)).toBe(""); + }); + + /** + * The header is generated scaffolding. Guessing where it ends -- rather than + * finding the thumbnail that closes it -- would publish the badges and the + * `degit` line as an explanation the first time that block is rearranged. + */ + it("finds nothing when the thumbnail it keys on is gone", () => { + expect(explainerOf("[![Static](x)](y)\n\nGlass, contained.\n")).toBe(""); + }); + + it("keeps the header out even when the explainer has an image of its own", () => { + const readme = `${HEADER}\n\nBefore, and ![after](./after.png) it.\n`; + + expect(explainerOf(readme)).toBe("Before, and ![after](./after.png) it."); + }); +}); + +describe("backtickedIdentifiers", () => { + it("reads the identifiers the prose names", () => { + expect([ + ...backtickedIdentifiers("`useMask` returns props for a material."), + ]).toEqual(["useMask"]); + }); + + /** + * A technique carried by a prop rather than an import is written the way the + * prop is written, and every name in it is as checkable as an import. + */ + it("takes every name out of a span, not just the whole of it", () => { + expect([...backtickedIdentifiers("`gl={{ stencil: true }}`")]).toEqual([ + "gl", + "stencil", + "true", + ]); + }); + + it("leaves fenced code alone", () => { + // It may show a reader how to carry the technique into their own scene, + // where the names are theirs. + const prose = "```tsx\n\n```\n\nUses `useMask`."; + + expect([...backtickedIdentifiers(prose)]).toEqual(["useMask"]); + }); + + it("reads a doubled fence as one span", () => { + expect([...backtickedIdentifiers("``a`b``")]).toEqual(["a", "b"]); + }); +}); + +describe("the rule the lint applies", () => { + const missing = (prose: string, source: string) => { + const vocabulary = identifiersIn(source); + return [...backtickedIdentifiers(prose)].filter( + (name: string) => !vocabulary.has(name), + ); + }; + + const SOURCE = 'import { useMask } from "@react-three/drei";\n// the Cube'; + + it("passes prose whose identifiers are still in the source", () => { + expect(missing("`useMask`, on every material.", SOURCE)).toEqual([]); + }); + + /** + * The whole point. `useMask` goes in a drei migration, and the rule fires on + * precisely the explainers that named it. + */ + it("fails on an identifier the source no longer has", () => { + expect(missing("`useMask`, on every material.", "// nothing here")).toEqual( + ["useMask"], + ); + }); + + it("does not accept a name because a longer one contains it", () => { + expect(missing("`Mask` covers the contents.", SOURCE)).toEqual(["Mask"]); + }); + + /** + * Looser than the rule on `apis`, deliberately: prose may name the demo's own + * components and props, which are never imported. + */ + it("accepts a name that is written in the source but not imported", () => { + expect(missing("`Cube` is the box.", SOURCE)).toEqual([]); + }); + + /** What it gives up, stated so nobody reads it as a stronger rule. */ + it("accepts a name that survives only in a comment", () => { + expect(missing("`Cube` is the box.", "// Cube")).toEqual([]); + }); +}); diff --git a/test/render-llms.test.ts b/test/render-llms.test.ts index e73c5f00c..54c36e1f4 100644 --- a/test/render-llms.test.ts +++ b/test/render-llms.test.ts @@ -184,6 +184,35 @@ describe("renderExample", () => { ).toContain("Tags: transmission\nAPIs: useMask, MeshTransmissionMaterial"); }); + /** + * The explanation, from the example's own README. Under the facts because + * that is what they were chosen to introduce, and above the source because it + * is what the source is read against. + */ + it("carries the explainer between the facts and the source", () => { + const text = renderExample( + example({ + explainer: "## The problem\n\nGlass that contains something.", + }), + ); + + expect(text.indexOf("## The problem")).toBeGreaterThan( + text.indexOf("Scaffold:"), + ); + expect(text.indexOf("## The problem")).toBeLessThan( + text.indexOf("## src/App.tsx"), + ); + }); + + it("says nothing where an example has no explainer, which is most of them", () => { + expect(renderExample(example())).toContain( + "Scaffold: npx degit pmndrs/examples/examples/caustics\n", + ); + expect(renderExample(example({ explainer: "" }))).toBe( + renderExample(example()), + ); + }); + it("fences each file under its own path, tagged by extension", () => { const text = renderExample( example({ diff --git a/test/turbo-cache.test.ts b/test/turbo-cache.test.ts index e82bdd7ff..cbef9410d 100644 --- a/test/turbo-cache.test.ts +++ b/test/turbo-cache.test.ts @@ -210,6 +210,24 @@ describe("lint", () => { }); }); + /** + * The explainer's identifiers are checked against the same `src/`, so the + * prose is an input on exactly the same argument -- and this is the pair the + * rule exists for. Cached past a README, a stale identifier replays a pass + * that once looked at a different sentence. + */ + it.each([ + "examples/aquarium/README.md", + "examples/aquarium/CONTEXT.md", + "bin/lib/explainer.mjs", + ])("re-runs the metadata check when %s moves", (file) => { + const before = hashOf("//#lint:metadata", LINT); + + withTouched(file, () => { + expect(hashOf("//#lint:metadata", LINT)).not.toBe(before); + }); + }); + it("re-runs the metadata check when the import reader moves", () => { const before = hashOf("//#lint:metadata", LINT); diff --git a/turbo.json b/turbo.json index 40c268901..d0a000b42 100644 --- a/turbo.json +++ b/turbo.json @@ -59,14 +59,19 @@ // `apis` is checked against what the example imports, so the sources are // an input too: without them a dropped import replays a cached pass and // the metadata keeps advertising an identifier that is no longer there -- - // which is the one thing the rule exists to catch. + // which is the one thing the rule exists to catch. The same holds, one + // step wider, for the backticked identifiers in `README.md` and + // `CONTEXT.md`: the prose is checked against anything in `src/`, shaders + // and stylesheets included, so both the prose and that whole tree are + // inputs. Cached past either, the rule stops being one. "inputs": [ "bin/validate-pmndrs-metadata.mjs", "bin/description-exceptions.mjs", "bin/lib/imports.mjs", + "bin/lib/explainer.mjs", "schemas/pmndrs.schema.json", - "examples/*/{package.json,pmndrs.json}", - "examples/*/src/**/*.{js,jsx,ts,tsx,mjs,cjs}" + "examples/*/{package.json,pmndrs.json,README.md,CONTEXT.md}", + "examples/*/src/**/*.{js,jsx,ts,tsx,mjs,cjs,css,json,glsl,vert,frag}" ], "outputs": [] },