Skip to content

A tag is a filter now, so the vocabulary is closed - #221

Merged
abernier merged 3 commits into
mainfrom
tag-vocabulary
Aug 16, 2026
Merged

A tag is a filter now, so the vocabulary is closed#221
abernier merged 3 commits into
mainfrom
tag-vocabulary

Conversation

@abernier

@abernier abernier commented Aug 16, 2026

Copy link
Copy Markdown
Member

195 tags for 170 examples, 145 of them on a single example, 29 examples with none, and 13 whose line in llms.txt was the name alone. This closes the vocabulary at 117 tags, gives every example at least one, and makes the badges the way into a ?tag= filter.

The design was settled question by question before any of it was written; the two things that need your eyes are the list and the 31 examples that were re-read, both below.

Decisions

What tags are for A navigable facet — and the only thematic axis on examples://index
Register One: the technique. The API register (meshreflectormaterial, useanimations) and the subject register (arkanoid, codrops, tag-heuer) leave
How the list was built Bottom-up: the 195 minus the evicted registers, minus the merges. No frequency threshold, so no pill ever returns nothing
Governance Closed enum on tags in the schema, exactly what libraries gets. Unknown tag = error, not warning
Minimum ≥ 1 tag, enforced by the validator
Form kebab-case, lower-case; the more frequent form wins a singular/plural pair, ties to the singular
Re-tag depth Targeted: the code of the 31 that would have been left empty was read, not their titles
UI Clickable badge only, no picker. Multi-selection, ANDed, with the dead ends disabled rather than left to be found
MCP Nothing — the index already enumerates the tags, and the schema is published at its $id

What to review

Six terms the catalog did not carry, introduced by the re-reading: fisheye (2) · lod (2) · views (2) · debug (1) · hmr (1) · ssgi (1).

One call worth contesting: instancinginstances follows the frequency rule (5 vs 2), but instances is also drei's component name, which the API-register rule would have evicted. The rule won over the register. Say the word and it flips.

The 31 read out of their own source

example tags
bvh bvh raycast
canvas-text html events
cards scroll infinite text
cards-with-border-radius scroll infinite shader
clouds volumetric
csg-house csg controls soft-shadows environment
dbismut-furniture transitions spring gltf html
ecctrl-fisheye fisheye physics controls game
enter-portals portal router text gltf
flying-bananas lod gltf dof postprocessing
gltf-animations-re-used gltf animation clone spring
gltf-animations-tied-to-scroll gltf animation scroll soft-shadows
ground-projected-envmaps-lamina ground-projection environment contact-shadows material
image-gallery reflections router text
magic-box portal controls gltf
motionpathcontrols controls bezier postprocessing gltf
multiple-views-with-uniform-controls views controls state soft-shadows
pairing-threejs-to-ui html suspense contact-shadows gltf
portals portal gltf contact-shadows environment
rapier-ping-pong physics game collisions audio
re-using-geometry-and-level-of-detail lod re-use gltf environment
room-with-soft-shadows pcss soft-shadows gltf
shader-hmr shader material hmr
shopping outlines postprocessing bvh mask
simple-physics-example-with-debug-bounds physics debug
ssgi-spheres-with-rapier-physics ssgi physics postprocessing environment
threejs-journey-lv-1-fisheye fisheye controls environment gltf
thunder-clouds volumetric camera-shake physics contact-shadows
trigger-meshes physics collisions
video-cookies video-texture gobo soft-shadows postprocessing
view-tracking views scroll html controls

The 117, by weight

gltf 36 · physics 23 · html 19 · postprocessing 19 · scroll 15 · transmission 15 · shader 14 · controls 13 · animation 12 · bloom 10 · environment 10 · reflections 10 · soft-shadows 9 · game 8 · instances 7 · portal 7 · contact-shadows 6 · text 6 · audio 5 · material 5 · particles 5 · refraction 5 · spring 5 · annotations 4 · collisions 4 · dof 4 · outlines 4 · volumetric 4 · bvh 3 · camera-shake 3 · csg 3 · events 3 · gradient 3 · ground-projection 3 · infinite 3 · raycast 3 · router 3 · ssr 3 · state 3 · stencil 3 · transitions 3 · ambient-occlusion 2 · bezier 2 · bounds 2 · cell-fracture 2 · clone 2 · configurator 2 · decal 2 · fisheye 2 · gobo 2 · god-rays 2 · hdr 2 · horizontal 2 · interaction 2 · lighting 2 · lod 2 · lut 2 · mask 2 · merging 2 · noise 2 · pcss 2 · re-use 2 · shadows 2 · spotlight 2 · stage 2 · suspense 2 · texture 2 · trail 2 · vertex-colors 2 · vertical 2 · video-texture 2 · views 2 · aspect-ratio 1 · backdrop 1 · baking 1 · barycentric 1 · batching 1 · blur 1 · bones 1 · chromatic-aberration 1 · compression 1 · convex-polyhedron 1 · custom-renderer 1 · debug 1 · depth 1 · distortion 1 · dot-screen 1 · expand 1 · film 1 · flexbox 1 · geometry 1 · gpgpu 1 · grid 1 · highlight 1 · hmr 1 · hud 1 · intersection 1 · lerp 1 · lightformer 1 · lightmap 1 · loading 1 · occlusion 1 · parallax 1 · pixelation 1 · progressive 1 · rectarea-lights 1 · scanline 1 · selection 1 · selective 1 · sepia 1 · snap 1 · soft-particles 1 · ssgi 1 · svg 1 · text-geometry 1 · vignette 1 · wireframe 1

Merges and renames

gtlfgltf · clell-fracturecell-fracture · shadersshader · animationsanimation · portalsportal · instancinginstances · trailstrail · springsspring · spring-animationspring · decalsdecal · godraygod-rays · softshadowssoft-shadows · shadowshadows · contact shadowscontact-shadows · scrollcontrolsscroll · scroll-controlsscroll · depth-of-fielddof · markersannotations · html-annotationsannotations · glowbloom · fit-allbounds · gradinglut · gpugpgpu · curlnoise · simplexnoise · mergedmerging · pointer-eventsevents · layoutflexbox · svg-renderercustom-renderer · lightslighting · spotspotlight · orbitcontrols · orbit-controlscontrols · transformcontrols · transformscontrols · transform-controlscontrols · custom-controlscontrols · pointerlock-controlscontrols · box projectedground-projection · ground mappingground-projection · cube cameraground-projection · videovideo-texture

Evicted — API names, subjects, and category words a sibling tag already said

meshreflectormaterial · rendertexture · useanimations · quadraticbezierline · line2 · useintersect · skinnedmesh · meshline · mesh-line · arkanoid · minecraft · pinball · tag-heuer · codrops · baubles · drone · shield · epoxy · diamond · ring · stars · grass · confetti · racing · vehicle · cycling · ragdoll · viewcube · minimap · fire · water · tiles · dome · graph · nodes · image · spline · clouds · frosted-glas · metal · effects · distance · input · analyser · positional

Multi-selection, and why it needs the disabling

Tags AND together. On its own that is a trap: 117 tags make 6,786 pairs, 336 of which occur together at all, and 263 of those on a single example — so a second click empties the list about nineteen times in twenty, which is the one thing the vocabulary was rebuilt to avoid.

So the second click is not offered when it would. TagFilterProvider indexes the catalog by tag once, useTagFilter intersects that with the tags picked so far, and a badge that would leave nothing goes disabled. It weighs the tags alone, not ?q= or ?library= — a pill greyed out because of what sits in the search box reads as broken rather than as narrow.

The cards need none of this and get it for free: a card is in the list because it carries every active tag, so every pill on it has at least that card behind it and can never be the one that empties it. It is the info panel, whose example may not be in the list at all, where a tag can be a dead end.

The UI, and the two things that had to move for it

Clicking a tag is the only way in — there is no picker. With most tags on a single example a menu of 117 is not something anyone reads, whereas "show me the others like this one" is the gesture people actually have. The pressed badge is therefore also the answer to "why is this list short" and "how do I undo it", and it leads its card's strip — otherwise a card can be filtered by a tag its four visible pills do not show.

  • The card is no longer an anchor. A button inside an <a> is as invalid as an anchor inside one, so the whole clickable rectangle now comes from a stretched link the pills sit above.
  • The roving tabindex claimed every button in the list, which would have turned one tab stop per card into five. The pills opt out with data-roving-skip and stay a pointer affordance; the keyboard reaches the same tags from the info panel.
  • NuqsAdapter moved up to cover main — the info panel is a nuqs consumer now, and its comment said Nav was the only one.

Checked

pnpm lint · pnpm lint:metadata (170 examples) · pnpm format:check · tsc --noEmit · vitest (55) — and by hand in the running site: filter from a card pill (23 for physics), a second tag on top (?tag=physics,game → 7), transmission going grey on the aquarium while those two are held, zero disabled pills on any card, filter from the info panel, toggle off, the active tags leading each strip, the stretched link carrying ?tag= into the example, and 17 pills at tabindex="-1" against exactly one tabbable link in the list.

195 tags for 170 examples, 145 of them on a single example, 29 examples
with none, and 13 whose line in `llms.txt` was the name alone. Three
registers were mixed into one field: the technique (`transmission`), the
API that implements it (`meshreflectormaterial`), and what the scene
depicts (`arkanoid`, `tag-heuer`, `codrops`). The last two are already
said by the source and by the title.

So: one axis, the technique. Synonyms and plurals merge on the form the
catalog already carries more often, ties to the singular. What is left is
117 tags, every one of them on at least one example -- no threshold, which
is what keeps `bvh` and `pcss` reachable and keeps every pill from ever
returning nothing.

The enum lives on `tags` in the schema, the same treatment `libraries`
gets, and the validator reads both lists out of that one file. An unknown
tag fails the lint rather than warning: a misspelt tag used to be a
cosmetic slip, and is now a filter that finds nothing. The editor offers
the list while it is typed, since every `pmndrs.json` points `$schema` at
it -- which is what makes a closed list bearable.

The 31 examples that would have been left with nothing were read, not
guessed from their titles: tagging an example from its own title says the
title twice. Six terms the catalog did not carry came out of that reading
-- fisheye, lod, views, hmr, debug, ssgi.

Clicking a tag is the only way into the filter; there is no picker. With
most tags on one example a menu of 117 is not something anyone reads,
whereas "show me the others like this one" is the actual gesture. Which
leaves the badge to say what the picker would have: pressed, it is why the
list is short, and clicking it again is how that is undone. It also has to
lead its card's strip, or a card can be filtered by a tag its four visible
pills do not show.

Two things had to move for a pill to be clickable at all. The card was an
anchor wrapping everything, and a button inside an anchor is as invalid as
an anchor inside one -- so the card keeps its whole clickable rectangle
through a stretched link the pills sit above. And the roving tabindex
claimed every button in the list, which would have made four tab stops per
card out of what is meant to be one; the pills opt out of it and stay a
pointer affordance, with the keyboard reaching the same tags from the info
panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Multi-selection, ANDed. Which on its own would be a trap: 117 tags make
6,786 pairs, 336 of which occur together at all and 263 of those on a
single example -- so a second click empties the list about nineteen times
in twenty, and emptying the list is the one thing the vocabulary was
rebuilt to avoid.

So the second click is not offered when it would. `TagFilterProvider`
indexes the catalog by tag once, `useTagFilter` intersects it with the
tags picked so far, and a badge that would leave nothing goes disabled.
The dead end stops being something you find by falling into it.

It weighs the tags alone, not `?q=` or `?library=`: a pill greyed out
because of what sits in the search box reads as broken rather than as
narrow.

The cards need none of this and get it for free -- a card is in the list
because it carries every active tag, so every pill on it has at least
that card behind it and can never be the one that empties it. It is the
info panel, whose example may not be in the list at all, where a tag can
be a dead end. `?tag=physics,game` narrows to 7, and `transmission` on
the aquarium goes grey while they are held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`:active` reaches every ancestor, so toggling a tag sank the whole
vignette — a gesture that goes nowhere near the example. The press is
the stretched link's now.

And the pill says it is pressable: the badge's own hover is written
`[a]:hover:`, which lands on anchors, and this one is a button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXMeT91HWLjW5Vtd1ySFW5
@abernier
abernier marked this pull request as ready for review August 16, 2026 10:02
@abernier
abernier merged commit be95c38 into main Aug 16, 2026
16 checks passed
kvvasuu added a commit to kvvasuu/examples that referenced this pull request Aug 17, 2026
PR pmndrs#221 closed tags to a fixed enum after these examples were tagged.
Add ascii, autofocus, brightness-contrast, color-average, glitch,
hue-saturation and tone-mapping as new technique tags, and merge
depth-of-field/ssao/selective-bloom/ramp onto existing synonyms
(dof, ambient-occlusion, selective, gradient+mask).
@kvvasuu kvvasuu mentioned this pull request Aug 17, 2026
krispya added a commit that referenced this pull request Aug 21, 2026
* Add Autofocus, Bloom and Brightness/Contrast examples, ported from react-postprocessing

Autofocus and Brightness/Contrast are faithful ports of their CodeSandbox
originals. Bloom's scene was reworked onto the shared Suzanne+Stage template
with a glass material and an orbiting light, since the original's licensed
(CC-BY-NC-SA) voxel-character model didn't look good and raised licensing
questions better avoided.

* Add Color Average example, an original scene design

No CodeSandbox demo exists for this effect in react-postprocessing's docs.
Scene design: a cluster of glossy, palette-colored spheres that drains to
grayscale as the ColorAverage effect's opacity ramps up -- gives the
grayscale conversion an obvious, satisfying before/after to look at.

* Add the e2e test script to the earlier examples' package.json

They were added before the e2e/Chromatic test task (from PR #173 and
friends) landed on main, so they were missing the "test" script every
other example now has.

* Add Depth of Field, Glitch and Hue/Saturation examples, ported from react-postprocessing

Faithful ports of the CodeSandbox originals, with fixes for two stale
prop ranges written against a much older postprocessing version:
DepthOfField's focusDistance is documented as normalized [0,1] (the
original used 0..4), and HueSaturation's saturation is a -1..1 factor,
not radians (same bug fixed earlier in Take Control).

Glitch's `mode` prop doesn't reactively update in the current
@react-three/postprocessing release (confirmed upstream, fixed in an
unreleased 4.0.0) -- swapped it for the effect's other working props
(active, strength, delay, duration, columns, ratio) instead.

Depth of Field's scene is a row of five Suzanne heads receding in depth
rather than the original's single object, so the focus falloff is
actually visible; the camera's near/far planes are tightened so the
normalized focusDistance/focusRange controls map to a meaningful slice
of the scene.

* Add Noise, Ramp and Tone Mapping examples, ported from react-postprocessing

Noise: grain overlay demo on a staged Suzanne.
Ramp: custom gradient-ramp effect masking ASCII/Bloom over a box grid; fixed
an invisible-Bloom bug (luminanceThreshold 0.5 -> 0.1 to actually cross the
diffuse boxes' luminance).
Tone Mapping: ToneMapping effect across all ToneMappingMode values on a
staged Suzanne; added a mode dropdown since middleGrey/whitePoint only
affect the Reinhard2 family, not the modern AGX default.

* Add Selective Bloom example, an original scene design

Objects bloom on hover; the torus knot can also be locked on via a
Leva checkbox and the sphere via click. Uses @react-three/postprocessing's
SelectiveBloom effect with a low luminanceThreshold since selection (not
material brightness) drives what glows.

* Add SSAO example, an original scene design

Dense pile of matte spheres and boxes shows contact-shadow occlusion from
@react-three/postprocessing's SSAO effect. Fixed via ref + direct property
mutation since the wrapper's useMemo only reconstructs on camera/normalPass
change, not on prop updates. Also fixed three tuning issues: the deprecated
`radius` setter silently clamps to [1e-6, 1] so the wrapper's own 1-40
default range was a no-op; `bias` and `luminanceInfluence` defaults were
tuned for a differently-lit scene and mostly cancelled the effect out on
this one. Toggling "enabled" mutes via blendMode opacity rather than
unmounting, since EffectComposer renders a black screen when
enableNormalPass is on but zero Effect children remain.

* Credit original authors in pmndrs.json for the ported and original-design examples

* Reconcile postprocessing example tags with the closed tag vocabulary

PR #221 closed tags to a fixed enum after these examples were tagged.
Add ascii, autofocus, brightness-contrast, color-average, glitch,
hue-saturation and tone-mapping as new technique tags, and merge
depth-of-field/ssao/selective-bloom/ramp onto existing synonyms
(dof, ambient-occlusion, selective, gradient+mask).

* Add new examples to the website

---------

Co-authored-by: Kris Baumgartner <kjbaumgartner@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant