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.
| Bucket | Meaning | What to do with it |
|---|---|---|
| FREE | a shipped kit primitive already makes the mistake impossible, if you use it | say so in the component doc, so using the primitive is visibly cheaper than remembering the rule |
| BUILDABLE | a named primitive would make it impossible, and it does not exist yet | kit roadmap, ranked by how often the rule was actually broken |
| JUDGMENT | no primitive can decide this; it needs a person who knows the surface | leave 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.
Modal identity is store-driven#
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.
Navigation and shell, 08#
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.