Spec — Distilled docs: rewrite description, add apis, author them with a skill
Problem
The gallery publishes an agent-readable surface — llms.txt, /examples/<name>.md, /examples/<name>.json — built by bin/build-catalog.mjs and served both by the pmndrs docs MCP server and by rel="alternate" on every example page. The pipeline is sound. The content it carries is not.
Measured across the 170 examples:
| What |
Today |
description |
40 empty; the other 130 median 56 characters |
notes |
2 uses, both the same string: "Updated for MeshTransmissionMaterial in 2023." |
assets[].notes |
0 uses, and attribution() never renders it |
| Tags |
195 distinct, 139 used exactly once |
<name>.md body |
Facts block, then every source file inlined. No explanation layer |
| APIs imported per example |
median 9, p90 15, max 34 |
The failure is concrete. aquarium ships with an empty description and the single tag transmission. What actually makes the demo work is a stencil mask (useMask + a backside MeshTransmissionMaterial) — named nowhere. An agent asking "how do I do refractive glass" cannot discriminate it from 169 others in the index, and having opened it, pays full token price to rediscover the trick by reading 400 lines of TSX.
Both readers are underserved by the same missing sentence: the one choosing from llms.txt, and the one implementing from <name>.md.
Decisions
1. One line, in description
The existing field is rewritten — no new prose field. It already flows into both surfaces, so one edit serves both readers with zero plumbing.
Contract:
- English
- ≤ 120 characters
- Prose only — no identifiers (they have their own field, below)
- Names the technique, not the code. A line that describes what the code does rots at the first refactor; a line that names which technique is employed survives.
97 characters.
2. New field: apis
Identifiers are data, not prose — keeping them out of description is what makes the 120-character budget comfortable. Three median-length API names cost 34 characters, 28% of the budget, which was the objection that produced this field.
- Imported identifiers only. Not props. A technique carried by a prop rather than an import —
gl={{ stencil: true }}, frameloop="demand" — is said in the prose instead, which the aquarium line above already does without naming the prop.
- A short selection, not a list. Typically two or three of the nine an example imports at the median. The criterion is what you would have to bring over to reproduce the effect; everything else the example happens to import stays out. How many that comes to depends on the demo, so no number is fixed — but "no ceiling" is not "as many as you like". The limit is editorial: the skill selects, the maintainer reviews. An earlier draft added a numeric ceiling to catch someone pasting the import list; it was dropped as a premature constraint of exactly the kind this spec avoids elsewhere, not as a licence to dump.
apis ⊆ imports is the whole mechanical check.
- Rendered in
<name>.md only, not in llms.txt. Adding it to the index would cost ~34 characters on every line — median 107 → ~141, taking llms.txt from ~19 kB to ~25 kB on a file read at the start of every question. The identifiers are there the moment you open the example, which is when they matter.
3. apis is lint-verified — locally, and only locally
Each entry must appear in that example's own src/ as an imported identifier. A hard error in lint:metadata, not a warning.
Two validations are easy to conflate here, and only the first is in scope:
apis ⊆ the example's imports. Purely local and textual — no network, no type resolution, no per-version package introspection. This is the check.
- The identifier really exists in the library at the pinned version. Out of scope, because the pipeline already proves it: Rollup fails the bundle on a missing named export, and the e2e suite then renders the scene. There is no reason for the lint to reprove what the build enforces.
The "stays up to date" property falls out of the local check alone. drei renames an API → someone fixes the import → apis still lists the old name → the lint breaks. That is the silent-rot failure mode, caught without knowing anything about drei.
Restricting entries to imports is what keeps this finite. Matching a prop means a hand-written matcher per prop and a text search that is brittle to whatever the source looks like next year — the open-ended version of this work, deliberately not taken.
4. Tags: normalise first, then close a core
Order matters, and getting it backwards is the trap. The 139 tags used exactly once are not mostly noise — they are mostly variants of tags already in the core:
- Typos:
gtlf (while gltf has 23), clell-fracture, frosted-glas, godray against god-rays
- Spaces instead of hyphens:
box projected, contact shadows — the latter alongside an existing contact-shadows
- Singular/plural and synonyms:
animation(9)/animations, instances(5)/instancing(2), portal(3)/portals, soft-shadows(3)/softshadows, scrollcontrols(3)/scroll-controls, dof(2)/depth-of-field, decal/decals, trail/trails, spring/springs/spring-animation, mesh-line/meshline, merged/merging, transform/transforms, shader(8)/shaders(4), postprocessing(14)/effects(3), bloom(9)/glow(3)
Thresholding the raw data would freeze gltf and gtlf as two separate tags, and would exclude concepts that earn their place once glued back together: dof(2)+depth-of-field(1)=3 crosses the bar, instances+instancing=7, animation+animations=10.
So: normalise, then apply ≥3 usages to the merged counts. The core will be wider and truer than the ~27 a raw threshold suggests. The mapping table itself is mechanical and belongs to the tags PR.
Hapax that survive normalisation are kept, as free tags. minecraft, tag-heuer, pinball genuinely describe their demo and nothing else does. The schema requires at least one tag from the core; anything beyond that is free.
29 examples carry no tag at all. With no tag and no description, they exist in the index as a directory name. The ≥1-core-tag rule is a lint error, carved out by the same shrinking exception list as description (§8).
libraries is already a closed enum and lint:metadata already runs, so enforcement costs nothing new.
5. notes is deleted
Both fields go: top-level notes (2 uses, the same maintenance sentence, which belongs in git history and reads as documentation once rendered into <name>.md) and assets[].notes (0 uses, never rendered).
6. Rename build-catalog → build-llms
Not cosmetic — the current name is contradicted by the output. render.mjs claims /catalog/<name>.md sits next to the JSON, and turbo-cache.test.ts says /catalog/*.{json,md}, but the script writes to public/examples/. The word "catalog" no longer names anything the script emits.
| From |
To |
bin/build-catalog.mjs |
bin/build-llms.mjs |
bin/lib/render.mjs |
bin/lib/render-llms.mjs |
test/render.test.ts |
test/render-llms.test.ts |
"llms" names the audience, which is what unifies the three outputs. 8 real references to update: turbo.json, apps/website/package.json (×2), the two tests, the internal import, plus the two stale /catalog/ comments. The copies under .claude/worktrees/ are separate checkouts and are left alone.
7. An authoring skill, not a build step
.claude/skills/describe-example/ — vendored in the repo, following the precedent already set by the vendored shadcn skill, because it encodes repo conventions and should be reviewed and versioned with them.
- One example at a time. Reads the source, proposes the
description and apis, writes to pmndrs.json after the maintainer's ok.
- Two modes. Bare, it writes the one-line
description and apis from the source. With --explain, it runs teach in sub-agents first and writes the long-form explainer of §10 from what they return. Same skill because both modes enforce contracts that must not drift apart.
- Output is committed to git. Git is the source of truth;
render-llms.mjs stays dumb and deterministic.
- No LLM in
bin/. It sits on the critical path of the site build and of turbo caching — a model call there makes the build non-reproducible, offline-hostile, and busts the cache on every run.
8. Verification
Mechanical lint plus human review:
description non-empty becomes an error in lint:metadata (the JSON schema currently types it as a string with no minLength, which is how 40 empties are legal today)
- length bounded
- every
apis entry is imported somewhere in that example's src/
- every backticked identifier in that example's
README.md and CONTEXT.md appears somewhere in its src/ (§10)
Judgment stays human. An eval — N questions, does llms.txt lead to the right example? — is the real test of a distilled doc, but it needs an oracle and is a second project.
9. Scope of the first pass
A pilot of ~8 examples, chosen to break the contract rather than flatter it: 2 that already have a description (does rewriting earn its keep?), 1 whose technique is a prop rather than an import — so apis is empty and the prose carries it alone — 1 with no notable drei API, 1 very large, 1 very small.
Only then decide between filling the 40 empties and rewriting all 170 — the 130 existing descriptions have a median of 56 characters and generally name no technique, so "fill the gaps" probably undershoots.
10. The long-form explainer
The one-line description is what the index can afford. It is not what a reader wants once they have opened the example. That reader gets a second artifact, written in the example's own README.md, under the badges.
README.md already exists in all 170 examples — badges, degit line, thumbnail, and nothing else. GitHub renders it, degit carries it into whatever the reader scaffolds, and it is where a human lands. Writing under the badges is additive: no new file, no new convention.
render-llms.mjs inlines it into <name>.md. This is what finally puts an explanation into the document agents are served. llms.txt is untouched — it keeps its single line, and the index budget stays intact. The split is the whole design: one line to choose, the full explainer once chosen.
Structure is fixed, length is not. Sections — the problem, the technique, the pitfalls — because a fixed structure resists drift better than a word ceiling, and a demo that needs 200 words should not be padded to 400 nor a demo that needs 600 be truncated.
Where it comes from
/mattpocock-skills:teach runs a stateful, multi-session teaching workspace: MISSION.md, learning-records/, lessons/*.html, RESOURCES.md, NOTES.md. Half of that is one person's learning state — why they want to learn this, what they have already understood. It does not belong in a public repo of 170 shared demos.
The workspace lives outside the repo. Only the synthesis comes back, and it lands in two places that already exist for it:
From teach |
Lands in |
Read by |
| Reference documents — "the compressed essence" |
examples/<demo>/README.md |
Humans, and <name>.md |
| The glossary — "adhered to in every lesson" |
examples/<demo>/CONTEXT.md |
Agents, before exploring |
This is the first concrete supplier the per-example CONTEXT.md has had. Until now only /domain-modeling filled it, and only in reaction.
How --explain drives it
The skill does not read the source and write from it directly. It runs teach in a sub-agent to understand the demo first, and that pass decides whether the topic needs more.
- The first pass judges. It works the example, and reports whether a load-bearing sub-topic is still unclear enough to warrant another pass. Capped at three. Without a cap, an obscure demo can chain passes indefinitely.
- The skill supplies the mission.
teach is built for a human learner and its first move is to ask why you want to learn this. Headless there is nobody to ask, so the skill answers on the example's behalf — understand this demo well enough to explain its technique to an r3f developer — and a pass without that runs on nothing.
- The workspaces are ephemeral and outside the repo. Lessons, quizzes, learning records are scaffolding for the synthesis, not artefacts of the gallery. Only the reference distillation and the glossary come back, into
README.md and CONTEXT.md.
Two rules this creates
teach is a legitimate producer of CONTEXT.md, alongside /domain-modeling. docs/agents/domain.md should say so.
- No demo gets a
CONTEXT.md that merely restates its README. The glossary holds terms local to that demo; the README explains the technique. docs/agents/domain.md already says a per-demo glossary should only hold what is genuinely local — this adds that if the only content would be a paraphrase of the README, the file should not exist.
Keeping it from rotting
Prose that explains code is the one thing in this spec that goes stale on its own. It gets a mechanism rather than a hope, and the mechanism is chosen from how this repository actually changes.
How examples actually change. Seven commits in twenty-four months touched any example's src/:
| Commit |
Files |
Examples |
abdf0770 migrate demos → examples |
1159 |
161 |
3f913a0d pre-commit prettier hook |
593 |
161 |
d05ff3b6 convert the examples to TS |
550 |
158 |
| the other four |
2–51 |
1 to 9 |
Nobody edits aquarium on its own. Change here is either a mechanical sweep across the whole gallery, or a targeted fix on a handful.
Which rules out the obvious mechanism. Storing a hash of the example's source next to its explainer, and failing the lint when the source moves, would have fired 161 times on a formatting commit. The only rational response would be to re-stamp 161 hashes without reading a word — which trains precisely the reflex that makes a tripwire worthless. Rejected on the numbers.
What works instead: every identifier the prose names in backticks must still appear somewhere in the example's src/. A concatenation and a search — no import parsing, no package resolution.
It fires exactly where it should. useMask disappears in a drei migration → the lint breaks on precisely the explainers that named it, and on no others. A prettier sweep never wakes it.
And it reaches further than renames. In a declarative r3f scene the technique is the set of components used: an explainer saying "the spheres are instanced" names Instances, so the day instancing goes, the identifier goes and the lint rings. Semantic drift and API drift come through the same door here.
Note the check is deliberately looser than §3's, because it guards prose rather than a data field:
| Field |
Checked against |
Why |
apis (§3) |
the example's imports |
a list of library APIs — an entry that is not imported has no business there |
README, CONTEXT.md (§10) |
anywhere in the example's src/ |
prose may name the demo's own components (Aquarium, Turtle) and its props (stencil in gl={{ stencil: true }}) |
The looser rule is what lets an explainer name a prop-borne technique and be verified — something apis cannot offer. What it gives up is small: an identifier deleted from the code but surviving in a comment still passes.
Second, free: the writing contract. The explainer names the technique and its APIs; it never walks the code, cites no line numbers, no variable names, no "the file X does Y". The same principle as §1's one-liner — what cannot be checked mechanically is at least made hard to write wrong.
Absence is the normal state. teach is multi-session and interactive; realistically this happens for a handful of examples driven by curiosity, not as a programme across 170. An example with an explainer is better; one without is fine — the same rule docs/agents/domain.md already sets for CONTEXT.md.
11. The eval, and its baseline
Human review answers "is this sentence true and well turned". It does not answer the only question that matters: does an agent asking about refractive glass land on aquarium? That is what the line is for, and it is measurable.
- ~20 realistic questions — "how do I do refractive glass?", "how do I drive the camera from scroll?"
- For each, the right example(s), written by hand. This is the oracle, and the expensive part: an hour or two.
- An agent is given the question and
llms.txt alone — 170 lines, nothing else — and answers which example it would open.
- Count the hits.
Several questions must have their answer among the pilot's eight. The pilot rewrites eight descriptions; if no question points at any of them, the score cannot move and the pilot teaches nothing. Aiming part of the set at it is what makes PR 6 measurable rather than merely finished.
Measure the baseline before rewriting anything. A score after the pass, with nothing to compare it against, says nothing. Run it on today's llms.txt — 40 empty descriptions and all — then again after, and the pass either moved the number or it did not.
Never in CI: it calls a model, so it is non-deterministic, costs money and needs the network — the same reasoning that keeps a model out of bin/. A script run on demand.
Rejected in its favour: a deterministic "distinctiveness" lint, checking that each index line carries a term few other lines carry. It runs in CI and needs no model, but it measures whether the lines differ from each other, not whether an agent finds the right one. A proxy for the thing, where the thing itself is affordable.
Rejected
| Option |
Why not |
A 5–8 line "how it works" section in pmndrs.json |
Neither one thing nor the other. The index gets one line (§1), the reader gets the full explainer (§10) |
Committing the teach workspace into the example |
MISSION.md and learning-records/ are one person's learning state, not documentation |
| A source-hash tripwire beside the explainer |
Would have fired 161 times on a formatting commit — this repo's sweep-shaped churn turns it into a re-stamping reflex (§10) |
| Stripping boilerplate from the inlined source |
vite-env.d.ts is 170 identical copies, but styles.css is not uniform — verbatim fidelity wins over 8% |
| Generating the prose at build time |
Non-deterministic build, busted turbo cache |
| Deriving everything from imports, no prose |
100% verifiable and says nothing: 9–34 names summarise nothing |
A separate technique field |
Two fields to keep in sync, and llms.txt carries only one |
apis in llms.txt too |
+30% on a file read at the start of every question |
Props in apis (gl.stencil, frameloop) |
One hand-written matcher per prop, and a text search that rots. The prose says it instead |
Verifying apis against library exports |
Per-package, per-version resolution across 170 pins — and the bundle already fails on a missing export |
Work sequence
Seven PRs, stacked. Each is reviewable alone, and the first five ship no prose — they only make the prose possible, and measurable.
PR 1 — Rename build-catalog → build-llms
Pure refactor, no behaviour change, independent of everything below. First, so nothing else has to rebase over a file move. The three renames of §6, the 8 references, and the two stale /catalog/ comments.
PR 2 — Schema: +apis, −notes
apis added to schemas/pmndrs.schema.json, optional for now — it becomes required only once every example carries one, which is PR 6's job. A field that is required before it is populated is a red CI on day one.
render-llms.mjs renders it in the facts block of <name>.md. Not in summaryLine() — the index does not carry it.
- Top-level
notes and assets[].notes removed from the schema and from the two files that use them.
- Lint: every
apis entry is imported somewhere in that example's src/. Goes directly under the existing libraries block in validate-pmndrs-metadata.mjs, which already cross-checks that field against package.json in both directions.
- Tests for the new render output and the new lint rule.
PR 3 — The description ratchet
description non-empty becomes an error in lint:metadata, plus a bounded length — with a written list of the 40 currently-empty examples carved out, and a test that fails when the list and reality disagree.
This is the pattern bin/e2e-exceptions.mjs already establishes, down to its reasoning: "a suite that is red for known reasons is a suite nobody reads, and 'known' has to mean written down", and "an entry is a promise to come back to it". The list only shrinks. It reaching zero is what closes this spec.
Ordering matters here: turning the error on before the content exists would put CI red for 40 known reasons, which is the failure that file was written to prevent.
PR 4 — The describe-example skill
.claude/skills/describe-example/. One example at a time; reads the source, proposes description and apis, writes after the maintainer's ok. Encodes the two contracts of §1 and §2 so they live in one place rather than in a reviewer's memory.
Ships with no content changes, so it can be reviewed as a document.
PR 5 — The eval and its baseline
§11: the ~20 questions, their hand-written answers, the script, and the baseline score measured on today's llms.txt.
This has to land before any prose is written. PRs 1–4 change no description, so the baseline they leave behind is still today's — but the moment PR 6 rewrites eight of them, the "before" number is gone for good. It can move earlier in the stack freely; it cannot move later.
PR 6 — The pilot
The ~8 hard cases of §9, run through the skill. Then the decision this spec deliberately defers: fill only the 40, or rewrite all 170. The pilot's job is to answer that, so it is the first thing in the stack that produces prose — and the first thing the eval can score against its baseline.
PR 7 — The long-form explainer
§10, once the chain above has landed. describe-example gains its --explain mode, render-llms.mjs inlines the README under the facts block, docs/agents/domain.md gains the two rules of §10, and lint:metadata gains the backticked-identifier check.
Last because it depends on the skill (PR 4) and on the renderer (PR 2), and blocks nothing in front of it. Its prose is the only output in the stack no lint can judge — which is a reason to have every mechanical part already settled underneath it, and a reason the identifier check exists at all.
Later, independent
The tags work of §4 — normalisation pass, then the core at ≥3 merged usages, the ≥1-core-tag lint and its exception list for the 29 untagged examples. It touches all 170 files and shares nothing with the chain above. Its own PR, whenever.
Open
No decisions remain open. What is left is execution that the PRs above carry:
- The normalisation mapping table for tags, and the core list it produces (§4, tags PR)
- The ~20 eval questions and their hand-written answers (§11, PR 5)
- Fill the 40 or rewrite all 170 — answered by the pilot, not before (§9, PR 6)
Implementing this
Each sub-issue is written to be self-sufficient: a cleared session that reads only that issue has the decision, its reasoning, the exact files, and the criterion for done. It does not need this parent — and should not carry the design discussion, which contains as many rejected options as accepted ones.
Every sub-issue carries its own branch name, its base, and the command. The order:
| Issue |
Branch |
After |
| #193 rename |
rename-build-llms |
— |
| #194 schema |
catalog-apis-field |
#193 |
| #195 ratchet |
description-ratchet |
#194 (same file) |
| #196 skill |
describe-example-skill |
— (parallel with #194/#195) |
| #197 eval |
eval-baseline |
— (parallel), before any rewrite |
| #198 pilot |
pilot-eight |
#196 + #197 |
| #199 explainer |
readme-explainer |
#194 + #196 |
The tags work of §4 is its own issue, whenever.
What gets deleted when this is done
Three of the things this spec introduces are scaffolding, and saying so now is
what keeps them from becoming furniture. Done means every example carries a
description, an apis list and a README.md explainer. At that point:
| Goes |
Stays |
the describe-example skill |
apis ⊆ imports, in lint:metadata |
bin/eval.mjs |
the description bound and non-emptiness |
bin/eval/baseline.md, bin/eval/pilot-eight.md |
backticked identifiers ⊆ src/ |
bin/description-exceptions.mjs, once both lists are empty |
bin/eval/questions.json |
The rule is the one #198 settled: keep what runs on its own and fails loudly,
drop what only a human can be relied on to run. questions.json is the
exception, and earns it — twenty hand-written answers are the expensive half of
an eval, and the half a future run could not reconstruct.
The description and apis contracts move out of the skill and onto their two
fields in schemas/pmndrs.schema.json before the skill is deleted. That is
where the author of a new example meets them, in an editor tooltip, and it
outlives every tool listed above.
Each of those files repeats its own expiry in its own header, so that reaching
the finish line is not mistaken for a reason to keep them.
Spec — Distilled docs: rewrite
description, addapis, author them with a skillProblem
The gallery publishes an agent-readable surface —
llms.txt,/examples/<name>.md,/examples/<name>.json— built bybin/build-catalog.mjsand served both by the pmndrs docs MCP server and byrel="alternate"on every example page. The pipeline is sound. The content it carries is not.Measured across the 170 examples:
descriptionnotes"Updated for MeshTransmissionMaterial in 2023."assets[].notesattribution()never renders it<name>.mdbodyThe failure is concrete.
aquariumships with an empty description and the single tagtransmission. What actually makes the demo work is a stencil mask (useMask+ a backsideMeshTransmissionMaterial) — named nowhere. An agent asking "how do I do refractive glass" cannot discriminate it from 169 others in the index, and having opened it, pays full token price to rediscover the trick by reading 400 lines of TSX.Both readers are underserved by the same missing sentence: the one choosing from
llms.txt, and the one implementing from<name>.md.Decisions
1. One line, in
descriptionThe existing field is rewritten — no new prose field. It already flows into both surfaces, so one edit serves both readers with zero plumbing.
Contract:
97 characters.
2. New field:
apisIdentifiers are data, not prose — keeping them out of
descriptionis what makes the 120-character budget comfortable. Three median-length API names cost 34 characters, 28% of the budget, which was the objection that produced this field.gl={{ stencil: true }},frameloop="demand"— is said in the prose instead, which theaquariumline above already does without naming the prop.apis ⊆ importsis the whole mechanical check.<name>.mdonly, not inllms.txt. Adding it to the index would cost ~34 characters on every line — median 107 → ~141, takingllms.txtfrom ~19 kB to ~25 kB on a file read at the start of every question. The identifiers are there the moment you open the example, which is when they matter.3.
apisis lint-verified — locally, and only locallyEach entry must appear in that example's own
src/as an imported identifier. A hard error inlint:metadata, not a warning.Two validations are easy to conflate here, and only the first is in scope:
apis⊆ the example's imports. Purely local and textual — no network, no type resolution, no per-version package introspection. This is the check.The "stays up to date" property falls out of the local check alone. drei renames an API → someone fixes the import →
apisstill lists the old name → the lint breaks. That is the silent-rot failure mode, caught without knowing anything about drei.Restricting entries to imports is what keeps this finite. Matching a prop means a hand-written matcher per prop and a text search that is brittle to whatever the source looks like next year — the open-ended version of this work, deliberately not taken.
4. Tags: normalise first, then close a core
Order matters, and getting it backwards is the trap. The 139 tags used exactly once are not mostly noise — they are mostly variants of tags already in the core:
gtlf(whilegltfhas 23),clell-fracture,frosted-glas,godrayagainstgod-raysbox projected,contact shadows— the latter alongside an existingcontact-shadowsanimation(9)/animations,instances(5)/instancing(2),portal(3)/portals,soft-shadows(3)/softshadows,scrollcontrols(3)/scroll-controls,dof(2)/depth-of-field,decal/decals,trail/trails,spring/springs/spring-animation,mesh-line/meshline,merged/merging,transform/transforms,shader(8)/shaders(4),postprocessing(14)/effects(3),bloom(9)/glow(3)Thresholding the raw data would freeze
gltfandgtlfas two separate tags, and would exclude concepts that earn their place once glued back together:dof(2)+depth-of-field(1)=3crosses the bar,instances+instancing=7,animation+animations=10.So: normalise, then apply ≥3 usages to the merged counts. The core will be wider and truer than the ~27 a raw threshold suggests. The mapping table itself is mechanical and belongs to the tags PR.
Hapax that survive normalisation are kept, as free tags.
minecraft,tag-heuer,pinballgenuinely describe their demo and nothing else does. The schema requires at least one tag from the core; anything beyond that is free.29 examples carry no tag at all. With no tag and no description, they exist in the index as a directory name. The ≥1-core-tag rule is a lint error, carved out by the same shrinking exception list as
description(§8).librariesis already a closed enum andlint:metadataalready runs, so enforcement costs nothing new.5.
notesis deletedBoth fields go: top-level
notes(2 uses, the same maintenance sentence, which belongs in git history and reads as documentation once rendered into<name>.md) andassets[].notes(0 uses, never rendered).6. Rename
build-catalog→build-llmsNot cosmetic — the current name is contradicted by the output.
render.mjsclaims/catalog/<name>.mdsits next to the JSON, andturbo-cache.test.tssays/catalog/*.{json,md}, but the script writes topublic/examples/. The word "catalog" no longer names anything the script emits.bin/build-catalog.mjsbin/build-llms.mjsbin/lib/render.mjsbin/lib/render-llms.mjstest/render.test.tstest/render-llms.test.ts"llms" names the audience, which is what unifies the three outputs. 8 real references to update:
turbo.json,apps/website/package.json(×2), the two tests, the internal import, plus the two stale/catalog/comments. The copies under.claude/worktrees/are separate checkouts and are left alone.7. An authoring skill, not a build step
.claude/skills/describe-example/— vendored in the repo, following the precedent already set by the vendoredshadcnskill, because it encodes repo conventions and should be reviewed and versioned with them.descriptionandapis, writes topmndrs.jsonafter the maintainer's ok.descriptionandapisfrom the source. With--explain, it runsteachin sub-agents first and writes the long-form explainer of §10 from what they return. Same skill because both modes enforce contracts that must not drift apart.render-llms.mjsstays dumb and deterministic.bin/. It sits on the critical path of the site build and ofturbocaching — a model call there makes the build non-reproducible, offline-hostile, and busts the cache on every run.8. Verification
Mechanical lint plus human review:
descriptionnon-empty becomes an error inlint:metadata(the JSON schema currently types it as a string with nominLength, which is how 40 empties are legal today)apisentry is imported somewhere in that example'ssrc/README.mdandCONTEXT.mdappears somewhere in itssrc/(§10)Judgment stays human. An eval — N questions, does
llms.txtlead to the right example? — is the real test of a distilled doc, but it needs an oracle and is a second project.9. Scope of the first pass
A pilot of ~8 examples, chosen to break the contract rather than flatter it: 2 that already have a description (does rewriting earn its keep?), 1 whose technique is a prop rather than an import — so
apisis empty and the prose carries it alone — 1 with no notable drei API, 1 very large, 1 very small.Only then decide between filling the 40 empties and rewriting all 170 — the 130 existing descriptions have a median of 56 characters and generally name no technique, so "fill the gaps" probably undershoots.
10. The long-form explainer
The one-line
descriptionis what the index can afford. It is not what a reader wants once they have opened the example. That reader gets a second artifact, written in the example's ownREADME.md, under the badges.README.mdalready exists in all 170 examples — badges,degitline, thumbnail, and nothing else. GitHub renders it,degitcarries it into whatever the reader scaffolds, and it is where a human lands. Writing under the badges is additive: no new file, no new convention.render-llms.mjsinlines it into<name>.md. This is what finally puts an explanation into the document agents are served.llms.txtis untouched — it keeps its single line, and the index budget stays intact. The split is the whole design: one line to choose, the full explainer once chosen.Structure is fixed, length is not. Sections — the problem, the technique, the pitfalls — because a fixed structure resists drift better than a word ceiling, and a demo that needs 200 words should not be padded to 400 nor a demo that needs 600 be truncated.
Where it comes from
/mattpocock-skills:teachruns a stateful, multi-session teaching workspace:MISSION.md,learning-records/,lessons/*.html,RESOURCES.md,NOTES.md. Half of that is one person's learning state — why they want to learn this, what they have already understood. It does not belong in a public repo of 170 shared demos.The workspace lives outside the repo. Only the synthesis comes back, and it lands in two places that already exist for it:
teachexamples/<demo>/README.md<name>.mdexamples/<demo>/CONTEXT.mdThis is the first concrete supplier the per-example
CONTEXT.mdhas had. Until now only/domain-modelingfilled it, and only in reaction.How
--explaindrives itThe skill does not read the source and write from it directly. It runs
teachin a sub-agent to understand the demo first, and that pass decides whether the topic needs more.teachis built for a human learner and its first move is to ask why you want to learn this. Headless there is nobody to ask, so the skill answers on the example's behalf — understand this demo well enough to explain its technique to an r3f developer — and a pass without that runs on nothing.README.mdandCONTEXT.md.Two rules this creates
teachis a legitimate producer ofCONTEXT.md, alongside/domain-modeling.docs/agents/domain.mdshould say so.CONTEXT.mdthat merely restates its README. The glossary holds terms local to that demo; the README explains the technique.docs/agents/domain.mdalready says a per-demo glossary should only hold what is genuinely local — this adds that if the only content would be a paraphrase of the README, the file should not exist.Keeping it from rotting
Prose that explains code is the one thing in this spec that goes stale on its own. It gets a mechanism rather than a hope, and the mechanism is chosen from how this repository actually changes.
How examples actually change. Seven commits in twenty-four months touched any example's
src/:abdf0770migrate demos → examples3f913a0dpre-commit prettier hookd05ff3b6convert the examples to TSNobody edits
aquariumon its own. Change here is either a mechanical sweep across the whole gallery, or a targeted fix on a handful.Which rules out the obvious mechanism. Storing a hash of the example's source next to its explainer, and failing the lint when the source moves, would have fired 161 times on a formatting commit. The only rational response would be to re-stamp 161 hashes without reading a word — which trains precisely the reflex that makes a tripwire worthless. Rejected on the numbers.
What works instead: every identifier the prose names in backticks must still appear somewhere in the example's
src/. A concatenation and a search — no import parsing, no package resolution.It fires exactly where it should.
useMaskdisappears in a drei migration → the lint breaks on precisely the explainers that named it, and on no others. A prettier sweep never wakes it.And it reaches further than renames. In a declarative r3f scene the technique is the set of components used: an explainer saying "the spheres are instanced" names
Instances, so the day instancing goes, the identifier goes and the lint rings. Semantic drift and API drift come through the same door here.Note the check is deliberately looser than §3's, because it guards prose rather than a data field:
apis(§3)CONTEXT.md(§10)src/Aquarium,Turtle) and its props (stencilingl={{ stencil: true }})The looser rule is what lets an explainer name a prop-borne technique and be verified — something
apiscannot offer. What it gives up is small: an identifier deleted from the code but surviving in a comment still passes.Second, free: the writing contract. The explainer names the technique and its APIs; it never walks the code, cites no line numbers, no variable names, no "the file X does Y". The same principle as §1's one-liner — what cannot be checked mechanically is at least made hard to write wrong.
Absence is the normal state.
teachis multi-session and interactive; realistically this happens for a handful of examples driven by curiosity, not as a programme across 170. An example with an explainer is better; one without is fine — the same ruledocs/agents/domain.mdalready sets forCONTEXT.md.11. The eval, and its baseline
Human review answers "is this sentence true and well turned". It does not answer the only question that matters: does an agent asking about refractive glass land on
aquarium? That is what the line is for, and it is measurable.llms.txtalone — 170 lines, nothing else — and answers which example it would open.Several questions must have their answer among the pilot's eight. The pilot rewrites eight descriptions; if no question points at any of them, the score cannot move and the pilot teaches nothing. Aiming part of the set at it is what makes PR 6 measurable rather than merely finished.
Measure the baseline before rewriting anything. A score after the pass, with nothing to compare it against, says nothing. Run it on today's
llms.txt— 40 empty descriptions and all — then again after, and the pass either moved the number or it did not.Never in CI: it calls a model, so it is non-deterministic, costs money and needs the network — the same reasoning that keeps a model out of
bin/. A script run on demand.Rejected in its favour: a deterministic "distinctiveness" lint, checking that each index line carries a term few other lines carry. It runs in CI and needs no model, but it measures whether the lines differ from each other, not whether an agent finds the right one. A proxy for the thing, where the thing itself is affordable.
Rejected
pmndrs.jsonteachworkspace into the exampleMISSION.mdandlearning-records/are one person's learning state, not documentationvite-env.d.tsis 170 identical copies, butstyles.cssis not uniform — verbatim fidelity wins over 8%turbocachetechniquefieldllms.txtcarries only oneapisinllms.txttooapis(gl.stencil,frameloop)apisagainst library exportsWork sequence
Seven PRs, stacked. Each is reviewable alone, and the first five ship no prose — they only make the prose possible, and measurable.
PR 1 — Rename
build-catalog→build-llmsPure refactor, no behaviour change, independent of everything below. First, so nothing else has to rebase over a file move. The three renames of §6, the 8 references, and the two stale
/catalog/comments.PR 2 — Schema:
+apis,−notesapisadded toschemas/pmndrs.schema.json, optional for now — it becomes required only once every example carries one, which is PR 6's job. A field that is required before it is populated is a red CI on day one.render-llms.mjsrenders it in the facts block of<name>.md. Not insummaryLine()— the index does not carry it.notesandassets[].notesremoved from the schema and from the two files that use them.apisentry is imported somewhere in that example'ssrc/. Goes directly under the existinglibrariesblock invalidate-pmndrs-metadata.mjs, which already cross-checks that field againstpackage.jsonin both directions.PR 3 — The
descriptionratchetdescriptionnon-empty becomes an error inlint:metadata, plus a bounded length — with a written list of the 40 currently-empty examples carved out, and a test that fails when the list and reality disagree.This is the pattern
bin/e2e-exceptions.mjsalready establishes, down to its reasoning: "a suite that is red for known reasons is a suite nobody reads, and 'known' has to mean written down", and "an entry is a promise to come back to it". The list only shrinks. It reaching zero is what closes this spec.Ordering matters here: turning the error on before the content exists would put CI red for 40 known reasons, which is the failure that file was written to prevent.
PR 4 — The
describe-exampleskill.claude/skills/describe-example/. One example at a time; reads the source, proposesdescriptionandapis, writes after the maintainer's ok. Encodes the two contracts of §1 and §2 so they live in one place rather than in a reviewer's memory.Ships with no content changes, so it can be reviewed as a document.
PR 5 — The eval and its baseline
§11: the ~20 questions, their hand-written answers, the script, and the baseline score measured on today's
llms.txt.This has to land before any prose is written. PRs 1–4 change no description, so the baseline they leave behind is still today's — but the moment PR 6 rewrites eight of them, the "before" number is gone for good. It can move earlier in the stack freely; it cannot move later.
PR 6 — The pilot
The ~8 hard cases of §9, run through the skill. Then the decision this spec deliberately defers: fill only the 40, or rewrite all 170. The pilot's job is to answer that, so it is the first thing in the stack that produces prose — and the first thing the eval can score against its baseline.
PR 7 — The long-form explainer
§10, once the chain above has landed.
describe-examplegains its--explainmode,render-llms.mjsinlines the README under the facts block,docs/agents/domain.mdgains the two rules of §10, andlint:metadatagains the backticked-identifier check.Last because it depends on the skill (PR 4) and on the renderer (PR 2), and blocks nothing in front of it. Its prose is the only output in the stack no lint can judge — which is a reason to have every mechanical part already settled underneath it, and a reason the identifier check exists at all.
Later, independent
The tags work of §4 — normalisation pass, then the core at ≥3 merged usages, the ≥1-core-tag lint and its exception list for the 29 untagged examples. It touches all 170 files and shares nothing with the chain above. Its own PR, whenever.
Open
No decisions remain open. What is left is execution that the PRs above carry:
Implementing this
Each sub-issue is written to be self-sufficient: a cleared session that reads only that issue has the decision, its reasoning, the exact files, and the criterion for done. It does not need this parent — and should not carry the design discussion, which contains as many rejected options as accepted ones.
Every sub-issue carries its own branch name, its base, and the command. The order:
rename-build-llmscatalog-apis-fielddescription-ratchetdescribe-example-skilleval-baselinepilot-eightreadme-explainerThe tags work of §4 is its own issue, whenever.
What gets deleted when this is done
Three of the things this spec introduces are scaffolding, and saying so now is
what keeps them from becoming furniture. Done means every example carries a
description, anapislist and aREADME.mdexplainer. At that point:describe-exampleskillapis ⊆ imports, inlint:metadatabin/eval.mjsbin/eval/baseline.md,bin/eval/pilot-eight.mdsrc/bin/description-exceptions.mjs, once both lists are emptybin/eval/questions.jsonThe rule is the one #198 settled: keep what runs on its own and fails loudly,
drop what only a human can be relied on to run.
questions.jsonis theexception, and earns it — twenty hand-written answers are the expensive half of
an eval, and the half a future run could not reconstruct.
The
descriptionandapiscontracts move out of the skill and onto their twofields in
schemas/pmndrs.schema.jsonbefore the skill is deleted. That iswhere the author of a new example meets them, in an editor tooltip, and it
outlives every tool listed above.
Each of those files repeats its own expiry in its own header, so that reaching
the finish line is not mistaken for a reason to keep them.