Agent docs

Which rules a primitive could retire

Every rule in this system was written because a human caught something on a screen.

Every rule in this system was written because a human caught something on a screen. That is a good origin for a rule and a bad place for it to stay. A rule costs attention on every build forever; a primitive that makes the mistake impossible costs one build, once.

Audience: an agent (or person) deciding whether a rule becomes a kit primitive or stays law.

So this doc sorts the law three ways.

BucketMeaningWhat to do with it
FREEa shipped kit primitive already makes the mistake impossible, if you use itsay so in the component doc, so using the primitive is visibly cheaper than remembering the rule
BUILDABLEa named primitive would make it impossible, and it does not exist yetkit roadmap, ranked by how often the rule was actually broken
JUDGMENTno primitive can decide this; it needs a person who knows the surfaceleave it as law, and stop feeling bad that it is law

The buckets are about mechanism, not importance. A JUDGMENT rule is not weaker law. It is law that has to stay law.

Scope and method#

Classified: all 27 bundle rows and 11 do-nots of AP-10, all 17 bundle rows plus row zero and 3 do-nots of AP-11. That is 59 rules.

The canon is covered transitively rather than separately, because every bundle row cites the canon section it enforces. Classifying row 7 of AP-10 classifies 06 §A9. Where a canon section has no recipe row pointing at it, it is not in this pass, and that gap is listed at the bottom.

There are three consumers of the kit, not two. Alongside walmart and speedway, apps/playground exercises the kit directly and is the only place several primitives have ever run. Adoption claims that say "neither app" are measuring product surfaces and should say so, because the playground repeatedly turns out to hold the working example. This pass corrected two of its own claims that way, both times in the direction of a primitive being further along than the product apps suggested.

Every FREE claim below names the primitive and the file it lives in, read this session. A rule is only FREE when the primitive removes the decision, not when it merely offers a convenient way to comply. That distinction is doing most of the work here: Button has a disabled prop, but a disabled prop is exactly how the silent-disabled defect gets built, so Button alone is not FREE for it. useDisabledReason is, because you cannot use it and still go mute.

FREE, today#

Eleven rules. Each names the primitive that retires it.

One qualifier applies to every entry below, and it is not a technicality. A primitive is only free to an app whose pin can reach it. The two consumers are pinned differently: walmart at ^0.0.24 (walmart-mvp/frontend/package.json:21), speedway at ^0.0.14 (speedway/package.json:36), against a kit at 0.0.25. Under npm semver a caret on a 0.0.x version is a patch pin, so ^0.0.14 resolves to exactly 0.0.14. Anything added to the kit after that is unreachable in a clean speedway install, whatever this doc says.

Speedway's dev tree hides this, because its node_modules/@versable-git/ui is a symlink to packages/ui, so it runs the local 0.0.25 and everything appears to work. A clean install disagrees. Before relying on a FREE entry in speedway, check when the primitive shipped.

Disabled actions explain themselves#

AP-10 row 26, canon 02 §A4. "A button that cannot act is hidden or explains itself, never silently disabled."

useDisabledReason at packages/ui/src/hooks/use-disabled-reason.ts takes a priority-ordered list of [condition, reason] and returns buttonProps, which spreads bricked plus tooltip onto the kit Button. The bricked flag keeps the control interactive precisely so the tooltip can fire, which is the part a hand-rolled disabled cannot do. There is no way to use this hook and produce a mute disabled button: the reason is a required element of every entry.

Fold into packages/ui/docs/button.md.

Mutation feedback survives the component that triggered it#

AP-10 row 25 and AP-11 row 16, canon 02 §A6 and 10 §A8. "Settles into a toast, owned by a component that survives the success."

pushAlert at packages/ui/src/toast/toast.tsx:144 is a module-level export, not a hook and not component state. The Toasts surface mounts once. A toast pushed from a modal therefore outlives that modal by construction, because the state was never inside it. The rule exists because someone once held toast state in the component that was about to unmount; that is unrepresentable here.

Fold into packages/ui/docs/toast.md.

Re-entrant async actions#

AP-11 row 9's first half, and the general case behind it. An action fired twice because the first run had not settled.

useAsyncAction at packages/ui/src/internal/use-async-action.ts defaults ignoreWhilePending to true and drops triggers while a run is in flight. It also captures the error and stays safe across unmount. Button, modal footers, chips and menu items all route through it, so anything built from those components inherits the guard without opting in.

Note the boundary carefully. useAsyncAction makes double-firing impossible. It does NOT make row 9's second half free: reporting the outcome is still the caller's job, and a confirm modal that closes silently on success is still buildable. That half is JUDGMENT below.

Fold into packages/ui/docs/button.md and packages/ui/docs/modal.md.

Confirmation is a component, not a browser primitive#

AP-11 row 8, canon 10 §A5. "Use the componentized Confirm; raw window.confirm is drift."

ConfirmModal at packages/ui/src/modal/confirm-modal.tsx exists and is complete. The rule is FREE in the weak sense that the replacement exists and is a drop-in. It is not FREE in the strong sense, because nothing stops a developer typing window.confirm. Recorded here as FREE-with-a-lint-gap: the primitive is done, the enforcement is not. The gap is wider than this doc first claimed: none of the three repos has a lint layer at all (verified 2026-08-13: no eslint config and no lint script in speedway, walmart's frontend, or this repo), so "one config line" presumed infrastructure that does not exist. The rule is filed here, paste-ready for whichever repo first adopts ESLint:

"no-restricted-syntax": ["error",
{ "selector": "CallExpression[callee.name='confirm']",
"message": "Raw confirm() is drift; use the kit's ConfirmModal (canon 10 §A5)." },
{ "selector": "MemberExpression[object.name='window'][property.name='confirm']",
"message": "window.confirm is drift; use the kit's ConfirmModal (canon 10 §A5)." }
]

Until a lint layer exists the ban is advisory, checkable by hand with rg -n '\bconfirm\(' app/ src/. Adopting ESLint (or wiring a check script) is an owner decision, queued on the board.

Speedway has four raw call sites to migrate, not three: app/routes/workspaces/jobs/job.tsx:292 and :326, app/routes/workspaces/jobs/setup.tsx:943, and app/routes/admin/loadsheets.tsx:247. The fourth is easy to miss and worth knowing about when you write the lint rule: it calls the global confirm( bare rather than window.confirm, so a rule matching window.confirm passes it. Match \bconfirm\(. Walmart has none.

Fold into packages/ui/docs/modal.md.

AP-11 row 3, canon 10 §A3. "Store-driven, string-identified, rendered locally at the callsite."

packages/ui/src/modal/modal-store.ts holds selectedModalId, selectedModalKey and selectedModalArgs as atoms, and useModal is the only sanctioned way in. It also ships useModalMountWarning, a runtime check that fires when a modal's declared id and mount state disagree. That warning is the unusual case of the kit already having built the enforcement for a rule, not just the mechanism.

Fold into packages/ui/docs/modal.md.

Full-bleed tables inside a card#

AP-10 row 6, canon 05 §A4. "Exactly one owner per framed container, table goes full-bleed inside it."

DataTable takes a bleed prop that does this. Walmart uses it in five files, ActiveParts.tsx:231, Jobs.tsx:952, UnlistedParts.tsx:333, ErrorManagement.tsx:1001, and SpecViewer.tsx:153,274,295. Speedway uses it nowhere; every hit for the string there is prose.

This one is FREE with a documentation gap rather than an adoption gap, and the gap is a good example of why this sweep is worth doing. Canon 05 §A4 describes only the hand-rolled idiom, scrollClassName="-mx-6 ... border-t border-base-200" with edge padding carried by the first and last cells, and calls that the house idiom. It never mentions bleed. So a reader who lands canon-first hand-rolls the negative margins, and a reader who lands component-first uses the prop. Both are following the docs.

Fold into packages/ui/docs/table.md, and 05 §A4 should name the prop.

Column construction has a factory#

AP-10 row 10, canon 06 §A4. Columns built via col.*.

col at packages/ui/src/table/column-factories.tsx:13 is real and exported. Treat this as FREE with the adoption caveat AP-10 already records: exactly one file in either app uses it, walmart-mvp/frontend/src/pages/Jobs.tsx at :536 and :548. The primitive retires the rule for anyone who adopts it. Almost nobody has.

Fold into packages/ui/docs/table.md.

A navigating cell says so#

The do-not "do not make a clickable cell look like text". Shipped 2026-08-14 as col.link (packages/ui/src/table/column-factories.tsx), closing BUILDABLE item 5. Its get returns { href, name, label? } and name is required at the type level, feeding the hover tooltip that names the destination, so the affordance cannot be forgotten. Primary ink and the hover underline come baked in, and clicks stop at the cell so a clickable row does not also fire. renderLink in the overrides routes through the app router's Link. The table gallery's custom-cells card exercises it, which is also the col factory's first playground example.

Folded into packages/ui/docs/table.md.

Skeleton headers cannot drift from the table#

AP-10 rows 21 and 22, and the do-not "do not hand-write skeleton headers". Moved here from BUILDABLE item 1 by the 2026-08-13 playground pass, which read the table layer itself instead of stopping at packages/ui/src/spinner/.

Table has carried a column-fed skeleton mode since the data layer first landed (commit 104a567, before 0.0.1 was ever published): skeleton plus skeletonRows (packages/ui/src/table/table.types.ts:96-98) render placeholder rows by mapping the SAME columns array the loaded table renders (table.tsx:275-287), under the real header row. The skeleton cannot disagree with the page, which is the whole rule. DataTable forwards the mode through its ...tableProps spread (data-table.tsx:222).

Because it predates 0.0.1, this is the rare FREE entry with no pin qualifier: walmart's 0.0.24 and speedway's 0.0.14 both already have it.

The adoption caveat is softer than this entry first said, twice over. First, the playground table gallery does demo the bare mode (components/table/page.tsx:191-196); the first version claimed no gallery did, off a grep whose filter missed a prop sitting alone at end of line. Second, walmart's TableSkeleton (walmart-mvp/frontend/src/lib/tableSkeleton.tsx:10, four pages plus routeSkeletons.tsx) is not a reinvention of the mechanism: it is a thin preset OVER the mode, a skeleton DataTable inside a Card with a toolbar strip (tableSkeleton.tsx:22). So the mode has production consumers through that preset; what no product used was the bare prop. Speedway does hand-roll div.skeleton blocks. As of 2026-08-14 the preset itself ships in the kit as TableSkeleton plus ToolbarSkeleton (packages/ui/src/table/table-skeleton.tsx, lifted from walmart's with identical names), so walmart's copy becomes a one-line import swap on its next deliberate kit upgrade; its 0.0.24 pin stays by owner ruling.

Already documented at packages/ui/docs/table.md:45; AP-10 rows 21/22 should now cite the prop.

Empty, filtered, loading and failed cannot collapse#

AP-10 row 23, its do-not "do not render a caught fetch failure as the empty state", and the first clause of 04 §A11. Shipped 2026-08-14 as ListState (packages/ui/src/page-states/list-state.tsx), closing BUILDABLE item 3.

It takes { loading, error, totalCount, filteredCount } and selects the branch in a fixed order: error beats loading beats true-empty beats filtered-empty beats the children. The order is the contract; a caught failure cannot render as emptiness and a pending load cannot render as "nothing here yet". Each branch's content stays per-surface through slot props (errorState accepts a function receiving the error, so the server's own description survives per 04 §A11), with the PageInfo family as defaults. Gallery: components/page-states.

The usual weak-sense qualifier applies: nothing stops a hand-rolled ternary, and the pin qualifier applies in full since it ships after 0.0.25.

Fold into a future packages/ui/docs/page-states.md.

Atomic filter writes to the URL#

AP-10 row 15, canon 07 §A3, and the do-not "do not batch nothing".

@versable-git/qsync (packages/qsync, built 2026-08-13) implements the seam's TableSyncAdapter against the URL with a per-tick batching queue, and owns replace: true plus preventScrollReset: true so call sites cannot forget them. The probe that authorized the build sharpened the rule itself: BOTH setSearchParams forms lose one of two same-tick writes on react-router 7, the functional form included, observed on 7.18.2 (packages/qsync/src/probe.react-router.test.tsx documents it against the live router; the products run 7.15.1 and 7.18.1). Batching is not hardening; it is the only correct shape, and the adapter is the only place it exists.

Two qualifiers. The pin qualifier above applies twice over: the package is private and unpublished, so product apps cannot reach it until the owner's publish ruling lands (doc 43 documents the same state for the toolkit). And the rule is FREE only through the seam: apps/playground consumes it live, while a hand-rolled useSearchParams site keeps the rule as law.

Folded into packages/ui/docs/table.md.

BUILDABLE, ranked by how often the rule was actually broken#

These are the ones worth building, each derived from a real incident rather than from a wish.

1. A kit TableSkeleton fed by the live column array#

Discovered already shipped, and moved to FREE above ("Skeleton headers cannot drift from the table"): Table has had a column-fed skeleton mode since before 0.0.1, and this doc's earlier claim that the kit was not column-aware came from reading only packages/ui/src/spinner/. The 2026-08-13 playground pass caught it. The page-level preset (walmart's TableSkeleton, a Card plus toolbar strip over the mode) ships in the kit since 2026-08-14; walmart's copy becomes an import swap on its next deliberate kit upgrade. Items 3-5 keep their ranks.

2. A real sync adapter, so filter writes are atomic#

Shipped 2026-08-13 as @versable-git/qsync and moved to FREE above; items 3-5 keep their ranks. The memory adapter that proved the seam end to end lives on as the fake backend in the package's core tests.

3. An empty-state component that takes the three-way branch as input#

Shipped 2026-08-14 as ListState and moved to FREE above ("Empty, filtered, loading and failed cannot collapse"); items 4-5 keep their ranks. The original entry below stands as the build's rationale.

Retires AP-10 row 23, and the do-not "do not render a caught fetch failure as the empty state".

The presentational layer is further along than this doc first recorded. PageInfo at packages/ui/src/page-info/ is the base, and the kit ships a named preset over it for each state: EmptyState (packages/ui/src/empty-state/empty-state.tsx), PageLoading and PageError (packages/ui/src/page-states/page-states.tsx). Table even defaults its empty slot to a bare EmptyState (table.types.ts:101), and packages/ui/docs/table.md:186 is explicit that a shipped list must not rely on that fallback. So all three states exist as components. Choosing between truly empty, filtered to nothing, and still loading is still entirely the caller's, and collapsing three branches into one generic message is a one-line mistake.

A primitive taking { loading, totalCount, filteredCount, error } and selecting the branch would make the collapse impossible. The build has shrunk to the selector alone: the three messages stay per-surface, the state components already exist, and only the branching moves into the kit. This is a small component with an outsized effect, because the rule is broken by omission rather than by commission, and omissions do not show up in review.

4. A toolbar the apps actually share#

Retires AP-10 rows 13, 14, 17, 18, 20, and the do-not about chips inline beside their facet.

The kit ships a complete FilterBar at packages/ui/src/table/filter-bar.tsx, and this doc's earlier "zero shipped call sites" measured only direct imports. Its first-party call site is DataTable itself: the default toolbar renders <FilterBar/> whenever the model has filters (data-table.tsx:174-176, showFilterBar defaulting true), and it runs live in three playground surfaces, components/data-table, demo/modules/[slug] and demo/review, each configuring features.filters. No product exercises it: every walmart and speedway features block configures only sort, search and pagination (verified 2026-08-13, SpecViewer.tsx:157,298, Jobs.tsx:621, FilePreview.tsx:274), and the page-level lists override the default toolbar with their own, speedway's ~/components/FilterBar across five files including JobsTable.tsx and review.tsx, walmart's FacetToolbar, via the model={model} pattern packages/ui/docs/table.md documents.

So three toolbars exist, two in production and one proven only in demos, and five bundle rows are law only because no single component owns them. The kit side also has more machinery than one component: DataTable.Toolbar ships a compound (Root, Spacer, Slot, Search, SelectionInfo, Reset, data-table.tsx:20-85) that a convergence should reuse rather than reinvent. The chips-placement fix at commit 5fa032c had to be applied twice, once to the shared toolbar and once to a hand-built row, which is precisely the cost of the fork.

This is the largest item here and the least safe to rush. Ranked fourth because converging three implementations is a migration, not a build, and it should follow the migration guide rather than precede it.

The kit half shipped 2026-08-14 as FacetBar (packages/ui/src/table/facet-bar.tsx), the union of both product toolbars by owner ruling: speedway's model-bound search, checkbox dropdowns, count line and refresh, plus walmart's single-facet Selects, helper tooltips, FilterableCell and removable chips, with chips as a per-app knob. Both apps stay unchanged until the next improvement set, so rows 13, 14, 17, 18 and 20 remain law in the products. What changed is that the convergence target now exists, gallery-proven on the data-table page.

5. A cell that declares its own navigation#

Shipped 2026-08-14 as col.link and moved to FREE above ("A navigating cell says so"). The original rationale: a cell that navigates needs an underline on hover and a tooltip naming the destination, every call site did that by hand or forgot, and the factory variant makes forgetting impossible.

JUDGMENT, and staying that way#

These need a person. Listing them is useful because it stops the next reader searching for a primitive that cannot exist.

Row-click meaning, and one state per row. AP-10 row 8 and its do-not, the most-repeated table defect in the corpus. The kit tried. DataTable has a clickSelect prop whose own comment says: pass false on a table whose selection lives outside the model, because "the kit can't see that, and two selection states on one table diverge". The kit is explicit that it cannot observe state it does not own. A primitive cannot fix a rule about a second state living somewhere it cannot see.

Row zero: peek, modal, or page. AP-11's first row, canon 10 §A1. It turns on whether surrounding context must stay visible, which is a fact about the task, not about the code.

Page archetype, viewport bounding, composition order. AP-10 rows 1, 2, 3. Row 2 is interesting: the canon already says the correct behaviour is to be explicit, "if you do not name it you did not do it". A rule whose content is "state your intent" cannot be absorbed by a primitive, because the primitive would have to guess the intent.

Column widths, type scale of sibling controls, tint priority. AP-10 rows 9, 11, and the do-nots about sizing by habit and mismatched control scale. These are perceptual. A linter can catch a hardcoded width; it cannot know the count column did not need it.

Sibling mirroring. AP-10 row 20. "A sibling list's toolbar is the spec for this one." This is a rule about consistency with a thing outside the current file, and it stays judgment until item 4 above collapses the toolbars, at which point it partly evaporates.

Nav badge staleness. The do-not about invalidating on the write rather than the navigation. This is a data-layer rule that happens to show up visually; it belongs to the query cache, not the UI kit.

Tooltip stacking. AP-11 row 17, a live defect in both apps. A component that carries its own tooltip must not be wrapped in another. Arguably buildable via a context flag that a nested tooltip could detect, but the failure is rare enough and the mechanism invasive enough that it stays here for now. Revisit if it recurs.

Everything about honesty of copy. The count line reading "N of M" only while filtered, empty-state tone carrying meaning, a caught failure not posing as emptiness. A primitive can route these; it cannot write them.

Three contradictions found while sweeping#

Not the point of this pass, but recording them beats losing them.

The debounce number is stated three ways. Resolved 2026-08-13 (72507e2): the kit default moved to 300ms and AP-10 row 19 now cites the primitive instead of restating a literal. As found, the recipe said 300, useDebounce defaulted to 200, the playground demo passed 300 (apps/playground/src/app/components/hooks/page.tsx:48), and no product code arbitrated because neither app calls the hook. Kept as a record of the shape: restating a number in prose is how it drifted.

Row 19's search escalation and row 13's search control both assume FilterBar. Given FilterBar has no product callers, both rows describe a component proven only in playground demos. AP-10 is honest about this in its divergence notes, but the bundle rows themselves read as settled.

ConfirmModal is FREE while window.confirm remains legal. Recorded above. The gap is a lint rule, not a component.

The second pass: canon sections without recipe rows#

Swept 2026-08-13, the six sections the first pass named as its gap. The prediction that accessibility would be richest in FREE rules held. Most of the rest is law that has to stay law, which is a finding, not a disappointment.

Accessibility, 02 §A9#

Three rules live in that one paragraph.

A control with no text derives its accessible name: FREE. Button derives one in the order explicit aria-label, then the string tooltip, then the icon key spaced into words (packages/ui/src/button/button.tsx:132-207, shipped in 0.0.24 per the canon's own commit trail). You cannot render an icon-only kit Button without a name. The pin qualifier bites hardest here: walmart's 0.0.24 reaches it, speedway's 0.0.14 does not, and speedway is the app the canon was derived from.

A clickable thing is a real control: JUDGMENT, until lint day. The kit cannot see a hand-rolled <div onClick>, and wrapping one in kit Tooltip wires no aria (canon 02 §A9; live instances in both apps, FacetToolbar.tsx:46-47, Jobs.tsx:131-132). Lintable off the shelf (jsx-a11y), which files it next to the raw-confirm rule: paste-ready the day any repo adopts a lint layer.

One kit defect on record: Input's caption is not its name. The top caption renders outside the <label> that wraps the field (its own early-return block, packages/ui/src/input/input.tsx:176-181), so neither a screen reader nor a getByLabel query can connect caption to control. Canon 02 §A9 tracks it for the next kit version. The one place this sweep found the primitive itself breaking the rule it exists to retire.

Failure states, 04 §A11#

Three failure modes, and the buckets split them cleanly. The worst, a caught fetch error rendering as the empty state, is carried by ListState (FREE above): its error input exists precisely to make that collapse impossible. The other two, surfacing the transport's raw string and a handler flattening the exception's own words into generic copy, are JUDGMENT: rules about copy and about what a try/except may discard, invisible to any component.

Loading and states, the rest of 04#

Skeletons preserve the box: FREE. Every kit component's skeleton prop occupies exactly the space the real content will (Card keeps its frame, PageTitle draws bars only for the slots in use, canon 04 §A2). Using a component's own skeleton mode removes the collapse-and-jump mistake; hand-rolled placeholder divs keep it available, so the entry has the usual adoption shape.

Stagger, bounded: FREE. skeletonStaggerStyle (packages/ui/src/spinner/skeleton.tsx, also skeleton-group.tsx) offsets entrance delays by fractional modulus, and reduced-motion disables skeleton animation wholesale. No decision is left with the caller.

The rest of 04 §A is JUDGMENT: the skeleton-vs-spinner choice (A1), never blanking what is already known (A3), granularity matching load shape (A4), pending-navigation layering (A6), acting empties (A8), tone carrying the meaning of an absence (A9), and the routed boot skeleton (A10, whose column-fed half now rides the skeleton FREE entry above).

Foundations, 01#

The tokens are the primitive, and they retire mistakes only for code that stays inside them. The load-bearing rules, aliases point into the semantic layer only (A1), new tokens arrive with their measurement (A3), do not mint new steps (A4), mono only for identifiers (A5), are all JUDGMENT: each governs what a developer does OUTSIDE the primitive, where the kit cannot look. Two are lintable in principle (palette-step literals in component code; unsanctioned radius or shadow values) and join the lint-day file with raw-confirm.

Status language, 03#

The mechanism layer is complete: StatusKind and SemanticColor as closed vocabularies, and col.status (packages/ui/src/table/column-factories.tsx:61) feeding a StatusPill from a row mapper. Every rule about USING them is JUDGMENT: species choice (A1), ranked collapse into one badge (A3), pending mutations living on buttons not badges (A4), one shared map per vocabulary and boundary translation (A5), banner tone and ordering (A7). Nothing here is buildable; the components exist, and what the rules govern is meaning.

Shell law is architecture, not primitives: chrome declared into rather than drawn (A1), one meta map per page identity (A2), dual staff gating (A8). The kit's Sidebar takes an explicit active boolean, so the prefix-matching ban (A3) is unexpressible through the kit path and survives only for NavLink-layer code the kit never sees. The section's own portable checklist is its enforcement surface; everything in it stays JUDGMENT.

Motion, 11#

The vocabulary is complete (fadeInUp, stagger-fadeInUp, dotPulse, rise, bar-grow, card-arrive, IconCycle); every rule about WHEN is JUDGMENT: motion as change signal not decoration (A1), ambient cues slowed (A4), no second spinner beside a pulsing dot (A3), every new keyframe joining the reduced-motion block (A8). The one structural guarantee, placeholders do not animate in, is carried by the components' skeleton modes and rides the box-preserving FREE entry.

The tally after both passes#

The second pass adds three FREE rules (derived accessible names on Button 0.0.24+, box-preserving skeletons, bounded stagger), puts one kit defect on record (Input's caption association), files two more lint-day rules beside raw-confirm (clickable-must-be-a-control; palette-step literals), and confirms everything else in the six sections as law that has to stay law.

What this pass did not cover#

The canon-section gap the first pass named here is now closed by the second pass above.

The 73-entry correction ledger at docs/evidence/20260812-ui-knowledge-plan/L0-ledger-*.md was used as corroboration for the rankings above rather than classified entry by entry. Every do-not in both recipes derives from it, so the ledger is covered through them, but a direct pass might surface incidents that never became a rule.

apps/playground got its proper read on 2026-08-13, and the prediction below held: BUILDABLE item 1 turned out to be already shipped in the kit's own table layer, the FilterBar zero-call-sites claim fell to a first-party call site inside DataTable, and item 3's landscape gained the EmptyState/PageLoading/PageError family. One gallery gap surfaced on the way: no playground page exercised col.*, closed 2026-08-14 when the custom-cells card adopted col.link. (A second claimed gap, that nothing exercised Table's skeleton mode, was itself a bad grep, corrected the same day: the table gallery demos it at components/table/page.tsx:191. As originally written, this paragraph predicted a proper read would move more items between buckets; it moved one and corrected two.)

apps/_templates/next was not read at all in this pass and is named here because it matters to a different queue item. An ideal-template guide that does not start from the template already in the repo is describing a second template.

@versable-git/ui · reference, canon, and method, read in place