Skip to content

Component Audit Rubric

cixzhang edited this page Aug 10, 2026 · 33 revisions

Component Audit Rubric — v1.2

Version 1.2 · last changed 2026-08-10 · Component Scores

Cite this version in every audit. The scorecard's Rubric: line and the ledger's rubricVersion field must both carry it. Scores taken under different versions are not comparable — the ratchet reports incomparable and asks for a re-audit rather than failing on a meaningless delta.

Versioning rule. Bump the minor when scoring changes (weights, floors, severities, what counts as evidence) — that invalidates comparison with earlier scores. Bump the patch when only wording, citations, or a check's detection recipe change — comparison still holds. Record the change in the history below, and date it.

Version Date What changed
1.2 2026-08-10 Graded BLOCK ceilings (1 ⇒ ≤3 · 2 ⇒ ≤2.5 · 3 ⇒ ≤2 · 4+ ⇒ ≤1); "no BLOCKs" removed from the 3/5 anchors so the ceilings actually bind; §5 split into §5a objective (6) and §5b rendered judgment (4), with unmeasured §5b not measured rather than 0; limited re-keyed to vacuous passes; owner tiebreak and root-cause scoring for deduplication; check role tokens and resolved scales, not literal pixel values.
1.1 2026-08-10 First graded floors; one-defect-one-score; token-layer contrast and promotion-lifecycle checks excluded from existing core components; X5/A5/A6 splits; sanctioned external-system Effect exception; §5 visual-evidence gate and state-visual conformance matrix.
1.0 2026-08-10 Initial rubric: 11 sections, four run modes, weighted 0–100 grade.

Purpose. One checklist, run in four modes, that produces a grade per component — so we can compare components, track drift over time, and answer "is this ready?" with a number and a reason.

Sources. Derived from and citing the wiki (API Conventions, Theming Infrastructure, Design Conventions, Accessibility Checklist, Component Lifecycle, Component Hardening Protocol, Night Watch Component Auditor), the repo (.github/copilot-instructions.md, .github/instructions/*, CLAUDE.md, CONTRIBUTING.md, internal/eslint-plugin-astryx, CI workflows), and 950 mined PR review comments. Where a check has a real enforcer, it is named. Where it doesn't, it says manual — no check here claims automation it does not have.


1. The four modes

Mode Trigger Scope Output
N — Nightly auditor Night Watch, 5 components/night Only auto + semi items. manual items are skipped, not failed. §5 runs its 🟢 rows only and is reported as an objective subscore — the nightly pass does not produce a §5 grade, because it captures no screenshots. Objective subscore + batched fix PR; judgment items go to the PR's Needs Review section
P — Promotion gate (labcore, or into a package) The promotion diff Everything, strictest. Rendered screenshot evidence required — every applicable state in light, dark, and ≥1 custom theme, plus 320px and coarse pointer. Full grade + go/no-go
O — On-demand audit "Grade my component" Everything, whole component (not a diff). Screenshots required for §5 (see its evidence gate). Full grade + scorecard + contact sheet
R — PR review Any PR touching a component Everything in the diff's blast radius. Component-wide items (§2 target inventory, §8 prop coverage, §4 full state sweep) drop to advisory unless the diff touches them. A visual change requires before/after screenshots. Review signal + delta grade

Mode column below: blank = applies in all four modes. Letters = the modes where the item is enforced (e.g. P = promotion only, PO = promotion and on-demand).

Quick checks — the entry gate for mode R (updates to an existing component)

Before any of the graded sections are scored on a PR, these must be green. They are cheap, mechanical, and non-negotiable: a red one is a BLOCK on its own and the review stops there rather than spending judgment on a PR that doesn't build. This mirrors the wiki's 8-item Quick self-review and adds the two the author must have run locally.

Quick check Command If red
Q1 Lint passes at strict tier — CI severity, not the local warn tier. pnpm lint:strict (or CI=true pnpm lint) BLOCK. A warn-tier-green PR is not lint-clean.
Q2 Tests pass — the full suite, run locally before push. CI is not the test runner. pnpm test BLOCK
Q3 Build passes. pnpm build BLOCK
Q4 Icons come from the registry (no new inline <svg> shadowing a registry name). grep <svg BLOCK — §2 T17
Q5 themeProps present, on the painting element, and carrying every style-driving prop. read + themingTargets.test.ts BLOCK — §2 T4–T7
Q6 No new style-only wrapper div/span. @astryx/no-style-only-wrapper (warn tier — read it anyway) BLOCK — §2 T14
Q7 No CSS shorthands where longhands are required (border, and the null-on-null override case). @astryx/no-border-shorthand, @astryx/no-stylex-null-override FIX — §2
Q8 ARIA still correct for whatever the diff touched. read + tests BLOCK — §1
Q9 Stories updated for anything the diff changed or added. read the story file FIX — §8 X10–X12
Q10 Popover/overlay background and layering still correct where the diff touches a layer component. read + screenshot BLOCK — §4 B14
Q11 Changeset present for a consumer-visible change. pnpm check:changesets BLOCK — §8 X20
Q12 Before/after screenshots attached for any visual change. Playwright + Storybook §5 capped at 3/5 without them

A new component (or a promotion) runs the full 27-item self-review instead of the quick listComponent Build Protocol Phase 10 — and then the whole rubric.


2. Scoring model

Verification class per item. auto = a rule, test, or script fails and names it. semi = a grep/script produces candidates, a human adjudicates. manual = judgment.

Severity per item, from .github/copilot-instructions.md ("score the failure, not its likelihood"):

Meaning
BLOCK What breaks if it ships is a bright-line failure. A rare trigger, an eslint-disable, or "the happy path works" do not downgrade it.
FIX Real quality debt; shippable with a stated reason.
NIT Polish and hygiene.

Section score, 0–5, anchored. Every section has explicit 5 / 3 / 1 anchors. Mechanical floors act as ceilings on the anchor read, graded by how much is wrong rather than whether anything is:

Open distinct defects in the section Section score cannot exceed
1 BLOCK 3
2 BLOCKs 2.5
3 BLOCKs 2
4+ BLOCKs 1
no BLOCK, 3–5 distinct FIXes 4
no BLOCK, 6+ distinct FIXes 3

Read the anchor first, then apply the ceiling. The anchors describe the property; the floors cap how high that read can land. A single BLOCK does not by itself mean 2 — v1.1 raised the ceiling to 3 but left "no BLOCKs" written into the 3/5 anchors, which made the change decorative in the three heaviest sections. Anchors no longer say "no BLOCKs"; if one still reads that way, the ceiling governs.

Calibration note. v1's floor was "any BLOCK ⇒ ≤2", and the first trial run graded five shipped core components at 41–63 — four Fs and a D, including components that are effect-free, fully ARIA-wired and heavily tested. v1.1's graded floors plus deduplication moved the same evidence to 48–74, a real spread. The 2.5 half-step above came from the SideNav re-score: a compound component with five sub-components gives every rule five chances to be violated, so the second BLOCK is far likelier than the first, and a flat 2 ⇒ ≤2 cliff pinned four sections carrying 46 weight at exactly 2 with no discrimination between two defects and five.

The grade measures distance from the bar, not craft. Two graders independently noted that a reader takes the letter as "how good is this component" while the number says "how far from clean is it." A mature, widely-composed component with four narrow defects can land below an immature one with none. So: always report the BLOCK count in the headline, and always report the sensitivity line"fix the N open BLOCKs and change nothing else → projected X". That projection is the actionable number; the letter alone is not.

One defect, one score. A single root cause frequently maps to items in three or four sections (an unguarded transition is A13 and T25 and D12; a dropped passthrough is T2 and P2 and R9). Score it once, in the section that owns the fix, and cross-reference it in the others without scoring it there. The scorecard reports both a finding count and a distinct defect count; the second is the honest one.

Which section owns it — tiebreak. Ownership is load-bearing (a defect moved from §1 to §9 is worth ~3 points), so don't argue it per audit: the owner is the section whose enforcer would catch it (the i18n lint rule ⇒ §9; @eslint-react/purity ⇒ §7; themingTargets.test.ts ⇒ §2). If no enforcer exists, the owner is the section containing the file you would edit to fix it.

Consequences are scored at the root. When defect B only exists because defect A is unfixed (a link/button element swap that disappears once busy-disables is fixed), score A and note B as downstream. Fixing the root must visibly move the score.

Deduplication does not inflate. A defect cross-referenced into a section still fails that section's anchor property — it simply doesn't count toward that section's floors. A section may not score 5 on "composes existing primitives" because the reimplementation it contains was scored elsewhere.

Shared-module findings. When the defect lives in a shared style module or shared primitive (Field/inputStyles.stylex.ts, a base hook) rather than in the component under audit, report it at advisory severity against this component — it does composite the result — and route the fix to the owning module per §11 L12. Do not let eight consuming components each take a BLOCK for one shared file.

Token-inherited failures. When a contrast or token-role failure originates in the token layer rather than the component (the component composites a token pair it cannot change), it is not scored against the component at all while the system-wide fix is open — report it as known systemic — tracked in #4652 (widespread WCAG AA contrast failures across the design system; program tracker #4475) and move on. A component may not be marked down for a defect that lives one layer below it and is already being worked. If the pair is genuinely component-specific — the component picked the wrong token, rather than the token being wrong — that is a normal finding against the component.

Promotion-lifecycle checks apply to promotion PRs only. §11's spec/arbitration/vibe/hardening evidence and the labcore gate items exist to grade a promotion diff. For a component that already lives in core, the lifecycle rider is not run — do not report a missing spec issue, arbitration record, vibe artifact, or self-review as a finding against a shipped component. Only L4 (review gate), L14 (changeset) and L15 (no public-repo leak) apply to an ordinary PR.

Anchors are property-based, not component-based. An earlier draft named live components as the bar ("Button is the reference", "Button.test.tsx is the shape", "Section, Tooltip, SideNav are a 3"). Calibration killed that: Button scored 2–3 in the very sections that named it as the 5. Naming a component in a normative anchor makes it ungradeable by construction and inflates it by reputation. Component names below appear only as non-normative "current best example" pointers, and they carry the date they were true.

Overall grade = Σ (section score ÷ 5 × weight), out of 100.

Section Weight
§1 Accessibility & operable paths 16
§2 Theming & token integrity 14
§3 Public API contract 14
§4 Behavior correctness & state coverage 12
§5a Design conventions — objective 6
§5b Design conventions — rendered judgment 4 (screenshot-gated; not measured ⇒ excluded + redistributed)
§6 Testing & verification evidence 8
§7 Code health (React & DOM) 8
§8 Docs, Storybook & docsite 8
§9 i18n & RTL 5
§10 Responsive & touch 5
§11 Lifecycle & process evidence ungraded rider — reported, never a block except its four hard items
Total 100
Grade Score Meaning
A 90–100 Reference quality. Use it as the example.
B 80–89 The core bar. Minimum for labcore promotion — matches the existing templates bar ("grade B or above").
C 70–79 Ships, with named debt. Any open BLOCK caps the overall grade at C regardless of arithmetic.
D 60–69 Needs a hardening pass before anyone builds on it.
F < 60 Not ready.

Gates layered on the grade

  • P (promotion): zero open BLOCKs and overall ≥ B and every Accessibility Checklist item verified in the promotion PR (hard requirement, packages/lab/README.md).
  • R (PR review): any BLOCK ⇒ lead with 🔴, recommend request-changes, cite the specific rule, point at the concrete fix.
  • N (nightly): publish the objective subscore as objective: x/y checks — do not synthesize a letter grade from an automated pass alone.

N/A handling. Mark an item N/A with a reason (a non-interactive display component has no keyboard items). N/A items leave the section and its weight redistributes proportionally. A component cannot N/A its way out of §1 or §2.

limited sections — keyed to vacuous passes, not to N/A. A section whose items mostly cannot fail for this component is not measuring it. Mark a section limited when fewer than one-third of its items produce a non-vacuous verdict, where a vacuous pass = the item's failure mode is structurally impossible here ("don't hold DOM in state" passes because there is no state; "no hardcoded strings" passes because there is no text). Score vacuous passes as N/A. Report a limited section's score for information and redistribute its weight.

This replaces the earlier N/A-count trigger, which fired on inapplicable items but not on items that pass by absence. The Badge re-score measured the leak: 19% of its grade was being awarded for absence, worth ~3.4 points on §7 alone. Re-keying to vacuous passes removes the need for a separate small-display-component weight profile.

Redistribution is proportional — and a section that was not measured never receives it. Freed weight is spread across the sections that actually applied, in proportion to their weights. A section scored 0 or reported not measured is excluded from receiving redistributed weight; otherwise marking sections limited amplifies an evidence penalty by growing the very section that cannot score.

§5 splits into objective and rendered-judgment halves. §5a (objective visual conventions — grid, radius, heights, type hierarchy, overlays, stacking, motion) is statically gradeable and the nightly auditor runs it. §5b (rendered judgment — monotonous spacing, contrast in context, state-visual conformance, proportion/density/composition, AI tells) is screenshot-gated: without visual evidence it is reported not measured, excluded from the total, and its weight redistributed to the sections that were measured. It is never scored 0. A zero asserts "the visual design is maximally bad" in place of "nobody looked" — the trial run showed that artifact moving the letter by a full band on three of five components. The gate keeps its teeth where it matters: mode P cannot pass with §5b unmeasured, and any audit that skips it must say so in the headline.


§1 — Accessibility & operable paths — weight 16

The wiki Accessibility Checklist is canonical; this expands it into gradeable checks. pr-a11y (axe) catches the static DOM subset only — keyboard, focus, and announcement behavior are on the reviewer.

# Check How Sev Mode Source
A1 Every interactive element has an accessible name, and the widget itself is labeled (visible label, aria-label, or aria-labelledby). auto (partial) — pr-a11y axe; required label prop BLOCK Accessibility Checklist §Name
A2 The correct WAI-ARIA APG pattern is implemented, and it is named — linked in the PR description (mode R/P) or named in the component's docs (mode O/N, where there is no PR to read). manual BLOCK Accessibility Checklist §Role
A3 Every state (selected, expanded, disabled, invalid, busy, current) is exposed via ARIA — never by color or shape alone. semi — grep aria- against the style-driving props BLOCK Accessibility Checklist §State
A4 Full APG keyboard support, including RTL arrow-key flipping via the shared focus hooks (useListFocus/useGridFocus/useTreeFocus, which take isRtl). manual + user-event tests BLOCK Accessibility Checklist §Keyboard
A5 Modal overlays trap focus while open (useFocusTrap) and restore on close. Focus is never dropped to <body> on a state change, and a focusable element is never removed from the DOM to express state — this half applies to every component, including non-modal layers. Non-modal layers (tooltip, hovercard, non-modal popover) are N/A for the trap: the APG forbids one. Say which half you scored. manual BLOCK Accessibility Checklist §Focus; WAI-ARIA APG
A6 (a) Never render a live region that is born with content — assistive tech announces only changes, so it will be silent. (b) Transition announcements go through useAnnounce rather than a hand-wired aria-live node. A declarative, persistent role="status" mirror of a state is a legitimate different mechanism and is permitted — useAnnounce is imperative fire-once; the two do different jobs. auto for the hand-wired case — the useAnnounce lint rule (a) BLOCK · (b) FIX Accessibility Checklist §Announcements
A7 Announcements fire on the real state transition in a callback — not from an Effect that can re-run and double-announce. manual BLOCK PR #4360; packages.instructions "Double-fire"
A8 Every interaction is reachable on touch: no control whose only path to discovery or activation is :hover. Gate "always show" on @media (any-pointer: coarse), not hover: none — hybrid laptops report hover: hover + pointer: fine. semi — grep hover: none in reveal logic BLOCK copilot-instructions, "broken accessible path — any modality"
A9 Hover/focus reveals use useContainerReveal (visible on :focus-within, always visible on coarse pointers, never display:none), not JS hover state. semi BLOCK if hand-rolled hooks/useContainerReveal.ts
A10 Interactive targets are ≥ 24×24 px (WCAG 2.5.8); a compact visual still offers ~44 px hit area on coarse pointers. Detectable shape to grep for first: an interactive element with padding: 0 (or none) wrapping a fixed-size icon or a fixed width/height under 24px — that pattern caught the worst defect in the calibration run from source alone. semi — the grep above, then measure in the browser BLOCK <24px; FIX for no coarse expansion Accessibility Checklist §Target size; Design Conventions
A11 isDisabled maps to native disabled; busy never disablesaria-busy plus visual treatment (disabling drops focus). semi — grep disabled={isLoading} BLOCK API Conventions §Disabled vs Busy
A12 Input ARIA wired: aria-required from isRequired, aria-invalid from status.type === 'error', aria-describedby from description, aria-busy when busy. semi — DOM assertions in tests BLOCK API Conventions §Accessibility; Night Watch Component Auditor §9
A13 Entry/exit animation goes through useEntryAnimation or is guarded by prefers-reduced-motion. semi — grep BLOCK for a new animation Accessibility Checklist §Reduced motion
A14 Painted state (selection fills, custom checkmarks, focus indicators) has system-color fallbacks so it survives forced-colors: active. manual BLOCK for a new component Accessibility Checklist §Forced colors
A15 The focus ring is visible in every theme and lives on the owner element, not inside a replaceable indicator. manual — render in ≥2 themes BLOCK PO Accessibility Checklist §Focus
A16 AT-facing strings (labels, announcements, hints) go through useTranslator() — same i18n rule as visible text. auto — @astryx/no-hardcoded-i18n-string BLOCK Accessibility Checklist §i18n
A17 Shared primitives are composed, not reimplemented: VisuallyHidden, useAnnounce, useFocusTrap, the focus hooks. A bespoke reimplementation is a review reject. manual BLOCK Accessibility Checklist §Use the shared primitives
A18 pr-a11y green without adding entries to .github/a11y-baseline.json; resolved entries deleted. semi — diff the baseline BLOCK when the PR adds entries CONTRIBUTING §Accessibility audits
A19 Every item of the wiki Accessibility Checklist is explicitly verified in the promotion PR body. manual BLOCK P packages/lab/README.md §Promotion gate

5/5 — the APG pattern is named and linked; keyboard complete in both directions including RTL; focus trapped and restored; state in ARIA, not just pixels; announcements via useAnnounce from callbacks; every action works with a coarse pointer; targets ≥24px with 44px coarse expansion; reduced-motion and forced-colors honored; axe green with a shrinking baseline; and the behavior is asserted in tests, not merely claimed. 3/5 — named, roled, keyboard-operable, axe green, with at most one blocking defect in an otherwise sound surface: focus restoration is untested, one state is visual-only on a non-critical path, or the coarse-pointer hit area was never measured. 1/5 — any of: an unnamed interactive element; a state visible only as color; a keyboard trap or pointer-only interaction; focus lost on open/close; a hand-wired aria-live; a hover-only path to a destructive action; a target under 24px; a new baseline entry added to silence axe.


§2 — Theming & token integrity — weight 14

Enforced by two Vitest guards — packages/core/src/theme/themingTargets.test.ts and derivedVarRegistry.test.ts — plus internal/eslint-plugin-astryx. Note: there is no lint-theme-targets rule on main, and the plugin is two-tier — pnpm lint warns, CI=true / lint:strict errors. Warn-tier green is not a defense.

# Check How Sev Mode Source
T1 Every color is a token or derived from tokens via color-mix()/light-dark()/calc() on vars. No raw hex/rgb/hsl anywhere — including as an argument to light-dark(), behind a const, or under an eslint-disable. "No suitable token exists" is not an exception. Carve-out: test fixtures and deliberate *.test-violations.* files are FIX, not BLOCK — but they must not ship in a published entry point. semi — @astryx/no-hardcoded-styles covers only literals inside stylex.create(); back it with a hex/rgb/hsl grep BLOCK (FIX for fixtures) copilot-instructions, always-blocking
T2 No themeable surface is removed: a token, a themeProps target, or a MediaTheme-flowed override replaced by a fixed value — including a value pinned on xstyle/style, which sits at the top of the cascade. Ask "is this still themeable?" before "is this lint-clean?". manual BLOCK copilot-instructions; Theming Infrastructure §Cascade Model
T2b Sanctioned non-token values. 0, 'none', 'transparent', 'inherit', 'currentColor'; layout values that are not semantically spacing (width: '100%', flex: 1); and large layout constraintsmaxHeight, maxWidth, minWidth — which are component-specific design decisions rather than theme values (a dropdown's maxHeight: '300px', an empty state's maxWidth: '360px'). Flag a constraint only if the value looks wrong, never because it is hardcoded. not a finding Night Watch Component Auditor §1 exceptions
T3 Same rule for spacing, radius, and shadow. Shadows use a whole shadowVars['--shadow-*'], never a template string with only the color tokenized. semi — lint covers stylex.create() literals; boxShadow is not covered — grep it BLOCK Night Watch Component Auditor §1 (#746, #670)
T4 Every rendered astryx-* class is documented in {Name}.doc.mjs theming.targets[] (subset policy: docs may list more than source renders). auto — themingTargets.test.ts BLOCK that test's header (#3741)
T5 Every prop key passed to themeProps() appears in that target's visualProps or states. auto — same test BLOCK themingTargets.test.ts
T6 Any prop that selects between StyleX style objects is in the themeProps() call on that element — find fooStyles[prop] inside stylex.props(), confirm prop in the sibling themeProps(). semi — grep; no lint rule, and historically the single most frequent finding BLOCK Night Watch Component Auditor §3 (#808)
T7 themeProps sits on the element that actually paints (background/radius/shadow/typography decide) — not the root by default, and never a positioning wrapper or backdrop. Converge on a single target. When painted properties are split across nodes (Tooltip paints background/radius/typography on one and padding/max-width on the next), the finding is "these should be one target", not "which one is right" — restructure so one element carries the paint and the target. Multiple targets are justified only when the sub-element is genuinely visually distinct with its own color, background, or border (a switch thumb, a radio dot, a progress fill) — never as an accident of layout. Fallback while converging: every painted property must at least be reachable from a documented target. A sub-element does not earn a target when it is structural only (layout wrappers, flex containers), when it is text inheriting from the parent (labels, descriptions), or when global tokens already control it with no per-component override need. manual BLOCK (target on a non-painting node) · FIX (paint split across nodes that should converge) Theming Infrastructure; Night Watch Component Auditor §3 (#749, #744)
T8 No raw CSS and no hand-rolled JS workaround for anything StyleX supports — verify against internal/stylex-capabilities/CAPABILITIES.md (mirrored as STYLEX-CAPS in CLAUDE.md) rather than asserting. manual, against the caps file BLOCK copilot-instructions
T9 Know the "compiles but wrong" caps: @layer and explicit @property are NO; stylex.when.ancestor('[data-attr]') is NO (pseudo-selectors only, must start with :). manual BLOCK — ships invalid CSS CLAUDE.md STYLEX-CAPS
T10 Use the CSS-native mechanism: @starting-style for entry (not useState+rAF), stylex.when.ancestor(':hover', marker) for parent-hover-child, :nth-child(even) for zebra, @container for container-responsive, stylex.firstThatWorks(), stylex.positionTry(). manual BLOCK CLAUDE.md STYLEX-CAPS
T11 Form controls use a component-scoped stylex.defineMarker()never stylex.defaultMarker(), which leaks hover/focus-within from outer containers (e.g. a Popover) into CheckboxInput/RadioList/Switch. semi — grep defaultMarker BLOCK for form controls CLAUDE.md STYLEX-CAPS
T12 No className/style beside a {...stylex.props()} spread — merge via mergeProps(). auto — @astryx/no-classname-clobber (error in both tiers) BLOCK rule source
T13 Logical properties and values only. The one sanctioned physical left is rtlStyles.centerInline(). auto — @astryx/no-physical-properties (error both tiers, autofixable) BLOCK rule source
T14 No div/span that exists only to style a single Astryx component — put the style on the child's xstyle. A wrapper is legitimate when it does something the child cannot (establishes a flex/grid container, pads around the border box, carries semantics/behavior/ref) — name which. auto-ish — @astryx/no-style-only-wrapper is warn in both tiers (~25 legacy sites mid-migration), so review owns new ones BLOCK for a new one copilot-instructions; bug #4752
T15 A state-driven transform lives on the theme-targeted element, not a parent wrapper — otherwise a theme can restyle the chevron but not its rotation. auto — @astryx/no-wrapper-transform (error both tiers) BLOCK rule source; #4838/#4846
T16 Overlay/layer components pass style props through the layer's render function (ContextRenderProps), never a wrapper <div> around content. manual BLOCK Night Watch Component Auditor §7
T17 Glyphs come from the icon registry via <Icon icon="name"> / useIcon(name) — no inline <svg> for an icon. No lint rule exists; detect with a <svg grep. Sanctioned non-glyph shapes on main: Avatar DefaultIcon, AvatarStatusDot, Thumbnail, CheckboxInput's checkmark. semi — grep, cross-check the IconName union BLOCK when it shadows a registry name; FIX otherwise Icon/globalIconRegistry.tsx; Night Watch Component Auditor §4
T18 Theme-scoped icons via defineTheme({icons}), not registerIcons() (which mutates a module-level global and warnOnces). semi — grep FIX globalIconRegistry.tsx
T19 A visual variation prop resolves through an extensible *VariantMap interface declared in the component's public subpath barrel (src/Button/index.ts), with the prop type derived from it (keyof ButtonVariantMap) — not a closed union and not a Record<KnownValue, Style>. Unknown theme values must fall through to base styles, and a type-level regression must type-check consumer JSX through the public subpath under moduleResolution: "bundler". There is no variants key on defineTheme; the map interface is the whole mechanism. semi — read the barrel; the type-level regression test BLOCK for a closed map on a themeable axis Theming Infrastructure §Custom variants (#4646, #759)
T20 Public props extend BaseProps, so every component takes xstyle/className/style; never re-declared by hand. auto — @astryx/require-base-props (error strict) BLOCK rule source
T21 Font sizes use semantic typeScaleVars (label / body / supporting / large), not raw textSizeVars. The migration is essentially done (90 files vs 5) — a new textSizeVars size is a regression. Pick the semantic token by what the text is, not by mechanically matching its raw size: --font-size-base--text-label-size for interactive controls (buttons, tabs, selectors, menu items, form labels, nav items) or --text-body-size for content (inputs, links, descriptions, table cells); --font-size-sm and --font-size-2xs--text-supporting-size (descriptions, breadcrumbs, captions, placeholders, badges, helper text); --font-size-lg--text-large-size (empty-state titles, nav headings). semi — grep FIX Night Watch Component Auditor §1 (#661)
T22 The right neutral gray for what the element is: --color-neutral self-contained element · --color-background-muted container with content · --color-track slim rails · --color-overlay-hover / --color-overlay-pressed interaction states only · --color-background-body page-level only (AppShell). semi — grep the token names FIX (BLOCK if an opaque fill replaces a relied-on translucent tint) Theming Infrastructure; Night Watch Component Auditor §1
T23 Component CSS vars follow --[_]<component>[-<part>]-<property>. Private (derivable from a CSS property) = --_ prefix + private: true + a derivedVarRegistry.ts entry; public = neither. Reach for a var only when the value feeds a calc() or is read by multiple elements — themeProps targeting is the default. auto — derivedVarRegistry.test.ts FIX (BLOCK if a theme can no longer reach the value) Night Watch Component Auditor §2
T24 Container padding uses --container-padding-inline / -block-start / -block-end with a 0px fallback; there is no isotropic --container-padding. Theme packages never touch --layout-padding-* / --container-padding-*. semi — grep, including under packages/themes/ FIX (BLOCK if a removed var name is used — it silently resolves to 0) Container Padding System; Night Watch Component Auditor §1 (#1235)
T25 Motion uses durationVars/easeVars; only transform/opacity animate; reduced-motion honored. Real tokens on main: --duration-{fast,medium,slow} (+-min/-max) and a single --ease-standard. semi — grep literal ms / cubic-bezier( FIX for a raw value; BLOCK for animating layout properties or omitting reduced-motion theme/tokens.stylex.ts; Design Conventions
T26 State and size ride as themeProps data on one target — no -selected/-disabled sub-target, no hand-authored data-* on a styled element. semi — grep FIX (BLOCK if it shadows an existing target) Theming Infrastructure
T27 One name and shape per visual concept across siblings (an option row is {component}-option everywhere); prefer inheritance over child targets; never add a target to expose or hide internal structure. Target count: 0–1 typical, 3–5 compound, >5 ⇒ decompose. Scope the comparison: the components named in this component's componentsUsed plus its shared style module — not the whole library. manual FIX (BLOCK when it forks a shipped target name) Theming Infrastructure principles 3–7
T28 No themeProps on a composition wrapper that wraps other themed Astryx components — it creates specificity conflicts. Skip and flag. manual FIX Night Watch Component Auditor §3 (#749, #672)
T29 Reuse the primitive so theming cascades: Button (ghost) for close buttons; Divider for standalone separators (repeated siblings use borderBlockEnd + :last-child suppression); useLinkComponent() for navigation; VisuallyHidden for SR-only text. The test for "should this be the primitive?" is whether a theme author would expect restyling that primitive to affect this element — nav items, tab items, and toolbar buttons have distinct theming needs and should not become ghost Buttons just because they look alike. semi — @astryx/no-hardcoded-anchor covers <a> BLOCK for raw HTML where a primitive exists copilot-instructions; Night Watch Component Auditor §4
T30 'use client' is the first statement in any file using a React client API. auto — node scripts/check-use-client.mjs (not wired into any npm script or workflow — run it explicitly) BLOCK RSC Compatibility
T31 Renders correctly in light, dark, at least one non-default theme (packages/themes/*), and inside MediaTheme — structural overrides survive, only color flips. Screenshots attached. manual BLOCK if a surface becomes unreachable by onDark/onLight PO Theming Infrastructure §On-Media
T32 A token or shared-style change is judged by where it composites — contrast, emphasis, layering, elevation order, light and dark, every surface it appears on. The classic trap: a color relied on to be translucent made opaque. manual BLOCK when a composite regression is real packages.instructions §Judgment
T33 Docs, targets, and vars stay in sync in the same diff (theming.targets[]/vars[], SYNC: comments, derivedVarRegistry.ts). A removed target key needs a [breaking] changeset and an astryx upgrade codemod — runtime themes are not validated, so consumers otherwise get no error. auto — pnpm check:sync, pnpm check:changesets, the two theming guards BLOCK copilot-instructions; PR #4712 §Migration
T34 (Pending #4712 — draft, not on main; inactive until it lands.) Indicators (checkbox box, radio circle, selection mark) follow the indicator convention: the owner keeps the input, role, accessible name, focus ring, and keyboard behavior; the indicator is aria-hidden, owns no semantics, draws no focus ring, renders children when present, and tokenizes every color/radius/border-width. Marks render unconditionally with a state prop. Replacement is by name via defineTheme({indicators}). manual BLOCK once #4712 lands PR #4712

5/5 — every visual value is a token or token-derived, including shadows; semantic tokens chosen by role; themeProps on the painting element carrying every style-driving prop, with targets[]/vars[] updated in the same diff so both Vitest guards pass unmodified; new targets justified by a named consumer need and converging with siblings; no new DOM node — style rides on xstyle, state transforms on the targeted element; CSS-native mechanisms verified against CAPABILITIES.md; extensible variant maps with a type-level regression test; verified in light, dark, a custom theme and MediaTheme with screenshots. A theme author could restyle it without ever opening the source. 3/5 — at most one blocking defect in an otherwise sound theming surface, or one or two cosmetic FIXes (a raw textSizeVars size, an arguably-wrong neutral gray, a sub-target that should have been inheritance, a raw duration, an un-tokenized layout constraint). Guards and strict lint pass; every surface a theme could previously reach it still can. Light/dark checked; custom theme and on-media assumed rather than seen. 1/5 — a raw hex anywhere; a target or token replaced by a fixed value, or pinned via xstyle/style; an undocumented rendered astryx-* class (themingTargets.test.ts red); a style-driving prop missing from themeProps; a new style-only or transform-carrying wrapper; a hand-rolled workaround for a supported StyleX feature; @layer/@property in output; an unguarded :hover; a new inline <svg> shadowing a registry icon; a missing 'use client'; a removed target key with no changeset or codemod.


§3 — Public API contract — weight 14

"Public API shape is hard to walk back — treat it as blocking, not a nit" (copilot-instructions). The enumerated conventions the wiki names, carried by value rather than by link:

The conventions themselves are not restated hereAPI Conventions owns them, and an audit finding must cite its section. The axes the items below grade: component and file naming and the file header; hook, context and type naming; boolean is/has and callback on{Verb}{Scope?}; onOpenChange for layer components only; enum casing and the shared vocabulary; start/end for direction; html-prefixed native collisions; required vs optional; slots as passthrough and behaviors as hooks; BaseProps plus ...rest capture and per-category precedence; controlled inputs and default-prefixed uncontrolled state; and the async transition pair (clickAction + useTransition, changeAction + useOptimistic), where busy never disables.

# Check How Sev Mode Source
P1 Every exported *Props interface extends BaseProps (or an Omit/Pick of it), never a hand-rolled HTMLAttributes. auto — @astryx/require-base-props; exemptions in internal/eslint-plugin-astryx/shared.js BLOCK API Conventions §Prop Surface
P2 The component destructures ...rest and spreads it on the primary rendered element — the one carrying themeProps/the interactive role, not a wrapper. Extending BaseProps only makes pass-throughs type-check; it does not wire them to the DOM. semi — AST-detectable, no rule today BLOCK — "the most invisible bug in the system" API Conventions §Capture and Forward rest (#3738, #3852)
P3 Prop collisions resolved per category, deliberately: className/style/xstyle combined via mergeProps(); role/owned aria-*/computed id/type set after {...rest}; handlers the component implements composed via composeEventHandlers() with the consumer's destructured out of rest; everything else forwarded. semi — @astryx/no-classname-clobber covers only the className/style case BLOCK API Conventions §Precedence (#3863)
P4 Boolean props use is/has (and defaultIs/defaultHas for uncontrolled defaults) — never disabled, loading, open, defaultOpen, initialIsOpen. auto — @astryx/boolean-prop-naming. Blind spots: only *Props-suffixed interfaces, skips unions containing boolean, still permits the deprecated initialIs/initialHas BLOCK API Conventions §Booleans
P5 The primary value-change callback is onChange; scoped variants (on{Verb}{Scope}Change) only where there are multiple independent changing values. Never onValueChange/onSelectionChange for the primary. semi — greppable; "is this the primary value?" is judgment BLOCK API Conventions §Primary Change Callback
P6 Layer components (Dialog, Popover, DropdownMenu, MobileNav, HoverCard, Tooltip) expose exactly onOpenChange?: (isOpen: boolean) => void; non-layer components expose no isOpen/isShown/isVisible/onOpenChange at all. semi BLOCK API Conventions §Visibility Callbacks
P7 Directional props use start/end, never left/right (startIcon, endContent, paddingEnd). auto — grep /(left|right)[A-Z]/ over props members BLOCK — RTL correctness API Conventions §Directional Props
P8 No prop suppresses another prop's output; each prop is one orthogonal axis. Only documented exception: physical constraints (isTruncated, maxLines). manual BLOCK API Conventions §Principles
P9 Input components implement the standard field surface — required label, value, onChange/onChangeAction; and where applicable isLabelHidden, description, isOptional, isRequired, isDisabled, status: {type, message?}, size, disabledMessage. semi BLOCK (missing label is an a11y bright line) API Conventions §Input Component Props
P10 New or changed API converges with prior art rather than inventing a parallel name — check siblings for the same axis under a different name (size vs scale, isLoading vs busy, tone/variant/color, density vs compact) and reuse the established name, type, default, and vocabulary. manual (grep-assisted) BLOCK copilot-instructions; packages.instructions "converge, don't diverge"
P11 A removed/renamed/retyped public prop, export, variant value, or default ships a [breaking] changeset and a codemod under astryx upgrade. semi — pnpm check:changesets + a codemod directory check BLOCK packages.instructions Step 0
P12 No new required field on an existing public props type, shared type, or context — the tell is unrelated tests/examples edited to satisfy it. New fields are optional with an internal default. semi — visible in the diff BLOCK — silent breaking change packages.instructions §Judgment
P13 Slots are pure passthrough: the parent renders slotName={<Component …/>} and never hoists the child's state props (isMobileNavOpen, onMobileNavOpenChange) onto itself. semi BLOCK API Conventions §Slot Props
P14 No React child introspection — no Children.map/forEach/toArray/count/only, cloneElement, child.props, child.type. Use data-driven props, compound components + context, or render props. auto — @astryx/no-react-introspection (allowlist: OverflowList, MetadataList, Carousel) BLOCK rule source
P15 ref is declared on the props interface (React 19 prop style) and forwarded to the root DOM element; displayName is set. auto — @astryx/require-ref-prop, @eslint-react/no-missing-component-display-name, @eslint-react/no-forward-ref FIX (BLOCK for promotion) API Conventions §Refs
P16 Exported type names follow <Component>Props / Variant / Status; contexts <Component>Context; context hooks use<Component><Thing>; hooks use<Name> — all unprefixed. auto — regex over exported names FIX API Conventions §Type Naming; Night Watch Component Auditor §6
P17 index.ts exports the component plus its props/variant/status types; reachable from the package src/index.ts; any public hook is also exported from src/hooks/index.ts and src/index.ts; the generated "exports" map is current and was never hand-edited. auto — pnpm sync:exports:check, node scripts/verify-exports.mjs FIX (BLOCK for promotion) API Conventions §Export Conventions; Night Watch Component Auditor §10
P18 Variant/size types are derived, never hand-maintained string unions. Derive from the *VariantMap interface (keyof ButtonVariantMap) on any axis a theme may extend — that is T19's requirement and it wins. keyof typeof styles is correct only for a closed axis that is deliberately not themeable (size, where the heights are system tokens). auto — AST FIX API Conventions §Variant and Size Types; T19
P19 No any in the public type surface; discriminated unions and generics preferred; unknown + narrowing where genuinely open. auto — @typescript-eslint/no-explicit-any FIX CONTRIBUTING §Code Style
P20 Documented prop types inline their literal values (gap: 0 | 0.5 | 1 …, not gap: SpacingStep). auto — packages/core/src/docPropLiterals.test.ts FIX that test (#1645)
P21 Every prop in {Name}.doc.mjs exists in source, and every prop referenced in doc prose exists on the component it names. Doc prose is LLM training signal — a phantom prop steers codegen to hallucinate. auto — docPropReferences.test.ts (#3360) + typecheck:docs FIX Component Authoring Guide
P22 Uncontrolled support uses defaultValue/defaultIs*/defaultHas* — never initial*; a controlled/uncontrolled pair is value + onChange (or isX + onXChange) with the controlled prop winning. Note there is no useControllableState hook on main — each component hand-rolls this. auto (prefix) + semi (pairing) FIX API Conventions §Uncontrolled Defaults
P23 A boolean | config prop follows all four rules — true = defaults on, object = enabled-with-config (no {enabled: true}), false/omitted = off, config destructured in the body — and is only used when it replaces 3+ props. semi FIX API Conventions §Boolean-or-Config (SideNav.collapsible/resizable, AppShell.mobileNav)
P24 onChange signature matches the input type — text (value, e), boolean (checked, e), parsed (value: T | undefined) with no event, selection (value) — and any async twin is {verb}ActionchangeAction, not onChangeAction — with the identical signature. semi FIX API Conventions §onChange Signature
P25 Async-capable components use the right transition pattern: buttons clickAction + useTransition; inputs changeAction + useOptimistic actually rendering the optimistic value; isLoading is the external flag. All 14 async-capable core components ship changeAction; there are no on*Action props on main. semi FIX (the busy-disables half is BLOCK — see A11) API Conventions §Async Actions
P26 Behavior is offered as a hook or a boolean | config prop on the target component, not as a wrapper component; where both exist, the component is a thin shell over the hook. manual FIX API Conventions §Behaviors: Hooks Over Wrappers
P27 The three styling escape hatches are inherited from BaseProps/Pick<BaseProps,…>, never re-declared per component. semi — AST FIX Night Watch Component Auditor §7
P28 A composed component destructures every prop it sets on the inner component, aliases with the Prop suffix (onClickProp, xstyleProp), and merges rather than drops (className concatenated, xstyle appended, handlers composed, style forwarded). semi FIX API Conventions §Composed Component Prop Forwarding
P29 data-testid and other neutral pass-throughs land on the primary interactive element, not a wrapper div. semi — falls out of P2 FIX API Conventions §Test ID Convention
P30 label is required on interactive components and optional on display components where the content is the label; children required where the component has no meaningful output without it; presentational props optional with documented defaults. semi FIX API Conventions §Required vs Optional; Required Props Pattern
P31 Every default is declared once, visibly, and the same default holds in source, .doc.mjs, and JSDoc. semi FIX API Conventions §JSDoc; Component Specification Protocol ("override rate 0% = right default")
P32 Enum values are camelCase and reuse the system vocabulary; a visual enum drives styles through the extensible variant map, not a hardcoded Record. semi NIT (casing) / FIX (hardcoded map) API Conventions §Enums (#759)
P33 Props colliding with native HTML attributes are html-prefixed (htmlName, htmlFor); a prop reusing an HTML name with a different type Omits it from BaseProps. auto — small grep set NIT API Conventions §HTML Attribute Collisions
P34 Every exported prop and component has JSDoc saying what it does, with @default on optional props and @example in a bare ``` fence — never language-tagged (a tagged fence silently breaks Storybook autodocs). auto — @astryx/docblock-example-format NIT (the tagged fence is FIX) API Conventions §JSDoc; CLAUDE.md
P35 Deprecated props/values carry @deprecated naming the replacement, stay functional for at least one release, and are dropped from docs. semi — docPropLiterals.test.ts handles the docs half NIT to add; BLOCK to remove (see P11) docPropLiterals.test.ts; codemod transforms
P36 The file header (@file, @input, @output, @position, SYNC:) is present and its SYNC: targets exist. auto — pnpm check:sync NIT API Conventions §File Header
P37 A component that accepts hasAutoFocus and sets autoFocus={hasAutoFocus} on a native element must also set data-autofocus={hasAutoFocus || undefined} on that same element — React's autoFocus fires before a <dialog> is visible, so Dialog re-focuses via the data attribute after showModal(). Without it the prop silently does nothing inside a dialog. semi — find autoFocus={hasAutoFocus} with no sibling data-autofocus FIX (BLOCK inside a layer component, where the prop is inert) Night Watch Component Auditor §8 (#1044)
P38 Composition contract: no escape-hatch prop without a demonstrated use case (trigger="hover" on Popover when HoverCard exists — a second way to do one thing is permanent surface, so name the callsite that needs it), and the prop interface must not expose raw-element-only APIs where a primitive exists, which pins the consumer to the raw element and makes the later migration breaking. manual FIX (BLOCK when it forecloses moving to the primitive) API Conventions §Composition vs Config; Night Watch Component Auditor §4

Contributor hard stop (R mode). If a PR adds a new prop or other public API surface and the author is not in .github/ENGOWNERS/DESIGNOWNERS, do not approve — flag "⚠️ Needs human/maintainer judgment on the API surface." Naming disputes route to API Arbitration (build both, vibe-test, override rate decides), never to in-thread debate.

5/5 — the API is indistinguishable in shape from its best sibling: every convention above holds, ...rest is captured and forwarded to the primary element with per-category precedence handled deliberately, controlled/uncontrolled is one clear owner, types are derived not hand-maintained, docs and defaults agree with source, and any new axis reuses an established name and vocabulary. 3/5 — the shape is right, with at most one blocking convention violation, but the surface is untidy: a hand-maintained variant union, a missing type export, a default that drifted between source and doc, one prop whose name is defensible but not sibling-aligned. 1/5 — a public convention violation: a boolean without is/has, onValueChange as the primary change callback, dropped ...rest or a clobbered consumer handler, xstyle/className overwritten instead of merged, a new required field on an existing type, or a breaking rename with no changeset and no codemod.


§4 — Behavior correctness & state coverage — weight 12

Hardening Layer 2: issues with a clear right answer. Plus the adversarial pass — try to break it through the public API. If the component has the prop, the state must work; if the API allows it, it must not produce a broken experience.

# Check How Sev Mode Source
B1 Rest renders correctly with only required props. semi — story BLOCK Component Hardening Protocol Layer 2
B2 Hover gives visual feedback, guarded by @media (hover: hover). semi — grep + render BLOCK (unguarded :hover = sticky hover on touch) Component Hardening Protocol Layer 2; PR #299
B3 Focus ring visible via keyboard (:focus-visible), in every theme. manual BLOCK Component Hardening Protocol Layer 2
B4 Active/pressed feedback on click and tap. manual FIX Component Hardening Protocol Layer 2
B5 Disabled is visually muted, non-interactive, and correctly ARIA'd; a disabledMessage explains why where the pattern applies. manual BLOCK if it renders identically to default (the API promises the state) Component Hardening Protocol Layer 2; Scope Test
B6 Loading (if isLoading exists): spinner or skeleton, interaction blocked, dimensions stable. manual FIX (BLOCK if it disables and drops focus) Component Hardening Protocol Layer 2
B7 Status (if status exists): error/warning/success use the right tokens, pair color with an icon, and wire aria-invalid. manual BLOCK for color-alone Component Hardening Protocol Layer 2; Design Conventions
B8 Selected/current is distinct from rest and matches the family's selection style (filled / surface / edge / border / depressed). manual FIX Design Conventions §Selection
B9 Empty renders gracefully — no blank hole, no layout collapse. manual FIX Component Hardening Protocol Layer 2
B10 Overflow — long text truncates or wraps with no layout breakage; verify with a ~1.4× text-expansion pseudo-locale or a long-string story. manual FIX Component Hardening Protocol Layer 2; i18n expansion
B11 Family consistency — padding, sizes, and token usage match sibling components in the same family (a md Button and a default-density input land on the same height). manual FIX NPO Component Hardening Protocol §Scope Test
B12 Adversarial pass: deliberately try to construct a broken experience through the public API — contradictory props, controlled prop with no handler, absurd counts, deeply nested composition, rapid clicks and focus/blur cycling, a single item in a list/tab/breadcrumb, one very long child among short ones. Resolve what you find. Aggregation: one finding per affected state or code path, not per symptom — four cosmetic glitches in the same overflow path are one FIX. manual BLOCK for anything that breaks; FIX for anything ugly POR Source doc §10; Component Hardening Protocol Layer 2 Edge Cases
B13 Composition contexts behave: inside Dialog, Table, Card, AppShell, and within narrow/wide/constrained parents. manual — stories FIX PO Component Hardening Protocol Layer 2/L3
B14 Popovers/menus/tooltips escape their containers — no overflow: hidden/clip ancestor clipping them; z-index follows the semantic layer order, not magic numbers. semi — Night Watch Component Auditor §11 checks BLOCK — unreachable content Night Watch Component Auditor §11; Design Conventions §Elevation
B15 No latent regression: a passthrough silently dropped, a feature that breaks when composed with another, or a behavior change in an existing path. manual BLOCK copilot-instructions
B16 A bug fix ships a regression test that was red before, green after, with that evidence in the PR description. manual BLOCK R packages.instructions

5/5 — every state the API promises renders correctly and distinctly in light and dark, edge cases are demonstrated in stories rather than argued, the adversarial pass found nothing (or found and fixed it in the same PR), and composition inside Dialog/Table/Card is shown working. 3/5 — the common states are right; one state (usually loading, empty, or overflow) is unverified or slightly off; no adversarial pass was run. 1/5 — a state the API promises looks identical to default; a control clipped or unreachable; layout collapses on empty or long content; a latent passthrough drop.


§5 — Design conventions — §5a objective (weight 6) + §5b rendered judgment (weight 4)

§5a — objective (weight 6): D1, D2, D4, D5, D6, D8, D9, D10, D11, D12, D14. Statically gradeable from source; the nightly auditor runs these. §5b — rendered judgment (weight 4): D3, D7, D13, D15, D16 plus the state-visual conformance matrix. Screenshot-gated. With no visual evidence, §5b is reported not measured — excluded from the total with its weight redistributed, never scored 0 — and mode P cannot pass without it.

Check tokens, not numbers. Spacing, radius, size and type scales are all theme dialsdefineTheme derives them from a base and a multiplier, and a theme may legitimately ship a different resolved scale. Every check below asks "is this value a role token, and is the relationship right in the resolved scale?", never "is this value 8px?". A rendered number differing from the neutral default is not a finding; a raw literal, a token used for the wrong role, or a broken relationship is. The only numbers stated as absolutes here are external standards a theme may not tune away: WCAG contrast ratios, the 24×24 target-size minimum, and the 12px body-text legibility floor.

From Design Conventions. That page marks each convention 🟢 objective (auditable) / 🟡 assisted / 🔴 judgment. The nightly auditor runs only the 🟢 rows; 🟡/🔴 belong to Component Hardening Protocol Layer 3 and to modes P/O/R with a human.

⚠️ Visual evidence is mandatory for this section

§5 is graded from rendered pixels, never from source. A grade for this section is not valid without screenshots attached to the audit.

What must be captured. One screenshot per applicable state — rest, hover, focus-visible, active/pressed, disabled, loading, each status value, selected/current, empty, and overflow (long text) — in both light and dark, plus at least one non-default theme (packages/themes/*) for modes P and O. Every variant and size the component ships gets its own rest capture. If a state exists in the API, it needs an image; "N/A" must name why the component has no such state.

How to capture. Drive real Chrome/Chromium with Playwright against the local Storybook (or the Vercel preview) and screenshot the actual component. Rebuild @astryxdesign/core before capturing — Storybook resolves it to dist/, so a stale build measures stale code. getComputedStyle readings (exact hex, px, line-height, radius) are supporting evidence to be pasted alongside the image — never a substitute for it.

Then look at the images. The 🟡/🔴 rows below (D3, D13, D15, D16) are judged by eye from the screenshot. Do not infer appearance from CSS source or reason about what the code "should" render.

Scoring consequence. No screenshots ⇒ §5b is not measured: excluded from the total, weight redistributed, and named in the headline. It is never scored 0 — a zero asserts the design is maximally bad in place of saying nobody looked. §5a is unaffected and still scored from source. Partial coverage (some states missing) caps §5b at 3/5. A visual change with no before/after pair caps §5b at 3/5 in mode R. Mode P cannot pass with §5b unmeasured.

Deliverable format. A labelled contact sheet — <Component>__<variant>__<state>__<theme>.png — attached to the audit issue, the PR comment, or the calibration tab, with the diff called out between before/after pairs.

Required: the state-visual conformance matrix

Capturing the states is only half the check. Every captured state must be matched to one of the approved state representations in Design Conventions, and the screenshot judged against that representation's reference image and token signature. Reuse before inventing is the rule; an unnamed, novel treatment is a finding, not a style choice.

Match the described mechanism, not the pixels. Read each representation's description and check that the component implements that mechanism — the reference image is an illustration of the mechanism on one component, not a target to match 1:1. Hover is the clearest case: "--color-overlay-hover tint layered over the base" means the hover color is the component's own base color with an alpha overlay composited on top, so a hovered ghost button, a hovered menu row, and the reference secondary button are all correct while looking nothing alike. What you are verifying is "is this the overlay mechanism over this component's base?" — not "is this the same shade as the screenshot in the wiki?" The same holds for focus (an accent outline at the documented offset, whatever the control's shape), selection (which of the five selection styles the family uses), and status (muted vs inverted prominence tier). Judge the mechanism and the token role; use the reference image to recognize the pattern, and getComputedStyle to confirm the composite actually resolved from the right token.

Emit this table alongside the contact sheet — one row per captured state:

State captured Screenshot Approved representation it claims Token signature present? Matches the reference? Verdict
e.g. hover Button__secondary__hover__light.png Hovered (Overlay style) --color-overlay-hover tint over base ✓ yes pass

The approved representations live in Design Conventions §Consistent State Representations — that page owns the catalogue, its reference images, and the token description for each. Do not restate it here; cite the row you matched. What this rubric adds is the verification: for each captured state, name the row, then confirm from the screenshot (with getComputedStyle as corroboration) that the row's token signature actually resolved —

Row you matched Signature that must be visibly present
Hovered — Overlay style an alpha tint over the component's own base, not an opaque fill
Hovered / Focused — Ring style (fields) an inset ring, not elevation
Pressed scale(0.98) and the pressed overlay — not a color swap
Focused — Outline style an offset outline, visible in every theme
Selected (any of the five styles) the matching ARIA state (aria-checked / -selected / -current / -pressed) backs the visual
Dragging — Ghost preview 50% opacity only while actively dragging; no raised-card shadow
Drop target — Insertion line surrounding items stationary until release
Disabled native disabled, not a visual alone
Loading / Processing aria-busy, never disabled; dimensions stay stable
Status — Muted or Inverted an icon is present — never color alone

Agent states (thinking, reasoning, streaming, tool-executing, awaiting input, syncing, inspecting, rendering) have no approved visual yet. They get no automatic failure: flag the state as an open design question, say which User/System representation it borrows from, and route it to the spec loop.

How to score the matrix. Every captured state matched to a named representation whose token signature is visibly present, and consistent with the component's family ⇒ D13 passes. A state that implements an approved representation badly (opaque hover fill instead of an alpha tint, a raised shadow on a drag ghost, dimensions jumping on load) is a BLOCK via the relevant D-row. A state that invents a new representation where an approved one fits is a FIX — and if it is genuinely a new archetype, it routes to the spec loop, not to the PR. An agent state gets no automatic failure: flag it as an open design question and say which User/System representation it borrows from.

# Check How Sev Mode Source
D1 On-scale spacing — every padding/margin/gap comes from spacingVars, on the spacing scale (including its documented half-steps for optical work). Never a raw value. The scale's step is a theme dial; check membership of the resolved scale, not a pixel count. auto/semi — Night Watch Component Auditor §11 FIX Design Conventions §Spacing
D2 No inverted nesting — a child's gap is never wider than its parent's; gaps step up monotonically with grouping order (label→input < fields < groups < sections). semi FIX Design Conventions §Spacing
D3 Not monotonous — spacing is varied and intentional; ≤3 unique values across a component flattens relationships. Count unique spacing values per rendered element tree, not per file — a five-file compound component is one count. manual (🟡) NIT POR Design Conventions §Spacing
D4 Concentric radius — every radius is a role token (--radius-inner / -element / -container / -page / -full), never a literal; and every nested pair satisfies r_inner ≈ r_outer − gap in the resolved scale of the theme under test. defineTheme derives the scale as base × step × multiplier and themes override it, so a rendered value differing from the default scale is not a finding — a raw radius, a role token used for the wrong role, or a non-concentric nested pair is. Sharpness/roundness stays one theme dial, not per-element overrides. semi FIX Design Conventions §Radius; theme/defineTheme.ts
D5 On-scale heights — fixed-height controls take their height from --size-element-{sm,md,lg}, never a literal; variable-height elements use the density (padding) system. Size and density are tuned together so a fixed-height control and a variable-height one share a baseline in the same row. Compare against the resolved token values for the theme under test. semi FIX Design Conventions §Size · Density
D6 Type hierarchy — sizes come from typeScaleVars role tokens, never raw values. The relationships are what to check, and they hold whatever the theme's base and ratio: adjacent steps separate by at least the scale's ratio (~1.25× — steps that blur together fail), multi-line body has line-height ≥1.3 (target 1.5), measure stays ≤75ch, letter-spacing ≤0.05em on body, no all-caps or justified body, and rendered body text never falls below the legibility floor of 12px. The px floor is a legibility limit, not a token — the one number here a theme may not tune away. semi — Night Watch Component Auditor §11 FIX Design Conventions §Typography
D7 Contrast — every fg/bg pair passes WCAG AA (4.5:1 text, 3:1 large/UI) in both light and dark. A failure caused by the token pair itself is not scored against the component while the system-wide fix is open — report known systemic — #4652. Only a component's own wrong-token choice is a finding here. semi — measure; contrast.ts covers generated tokens only, not component pairs BLOCK (component's own token choice) · not scored (token-layer, tracked in #4652) Design Conventions §Color
D8 No pure black/white — no untinted #000/#fff; use tinted neutrals. Same carve-out: where the absolute is the token (--color-on-accent is #FFFFFF today), it is a token-layer concern, not the component's. auto/semi BLOCK (in component source) · not scored (token-layer) Design Conventions §Color
D9 Alpha overlays — interaction/selection tints are alpha overlay tokens, not opaque fills. semi FIX (BLOCK when compositing breaks) Design Conventions §Color
D10 Never color alone — status pairs a semantic token with an icon. semi (🟡) BLOCK (a11y bright line) Design Conventions §Color; copilot-instructions
D11 Elevation matches stacking — shadow tier matches the semantic order (base < dropdown < sticky < overlay/modal < toast < tooltip); no magic z-index; no hairline border + wide diffuse shadow together; no colored glow. semi FIX (BLOCK when something renders underneath a higher layer) Design Conventions §Elevation
D12 Motion — duration matches the change's weight (micro ~fast, entry/exit ~medium), --ease-standard, only transform/opacity animate, reduced-motion collapses to 0s; no bounce/elastic easing; no decorative motion. semi BLOCK for layout animation or missing reduced-motion; FIX for the rest Design Conventions §Motion
D13 Reuse an approved state representation before inventing one — pick the selection style that fits the component and stay consistent within its family. Minimize visuals per state. manual (🔴) FIX POR Design Conventions §State Representations
D14 Reorder contract (ordered collections only) — dedicated handle; pointer preview moves freely while the collection stays stable; tokenized insertion cue; one mutation on drop; keyboard reordering exposes the same cue and announcements; reduced motion skips settling; 50% opacity only while actively dragging; no raised-card shadow, no magic z-index. semi FIX Design Conventions §Reorder
D15 Proportion, density, and composition read as intentional — the squint test passes, states feel visually distinct, density fits the use case. Judged from a screenshot, not from source. manual (🔴) FIX POR Component Hardening Protocol Layer 3 review form
D16 No AI tells — side-tab accent stripes on rounded cards, colored glow shadows, gradient text, everything-is-primary emphasis. manual (🔴) FIX POR Design Conventions

§5a 5/5 — every objective row passes from source: on-grid spacing, concentric radius, on-scale heights, role-token typography with real hierarchy, alpha overlays, semantic stacking, cheap and reduced-motion-safe animation. §5a 3/5 — objectively clean apart from one blocking row or a few FIXes (an off-grid value, a non-concentric pair, an unguarded transition). §5a 1/5 — off-grid values, non-concentric nesting, an opaque hover fill, a magic z-index, or layout-property animation.

§5b 5/5screenshots attached for every applicable state in light and dark (plus a custom theme for P/O), every state matched to a named approved representation, and they hold up: you can squint at it and the groupings are obvious; every value traces to a token on the grid; nested corners are parallel; heading/body/supporting are unmistakably different; AA passes in both modes; the shadow tier matches the stacking order; motion is quiet, cheap, and reduced-motion-safe; the state representation is the family's, not a new invention. §5b 3/5 — evidence is partial (some states uncaptured) or the judgment layer is soft: monotonous spacing, a slightly cramped density, or a selection treatment that doesn't match its family's style. §5b 1/5 — a state implements an approved representation badly (opaque hover fill where an alpha tint belongs, a raised shadow on a drag ghost, dimensions jumping on load), status by color alone, or a visible AI tell. §5b not measured — no screenshots. Excluded from the total, weight redistributed, named in the headline. Never 0, and never reported as a pass.


§6 — Testing & verification evidence — weight 8

Harness ground truth: Vitest 4 + @testing-library/react 16 + user-event. There is no jest-axe/vitest-axe — unit-level a11y is role/name/ARIA assertions; axe runs out-of-process via @axe-core/playwright in the pr-a11y job. No VRT exists (one snapshot file repo-wide; pnpm test:screenshots advertised in CONTRIBUTING does not exist). Coverage has no thresholds — it is a report, not a gate.

# Check How Sev Mode Source
V1 A colocated <Name>.test.tsx exists next to the component. auto — hasTests() in .github/scripts/analyze-pr.js BLOCK CONTRIBUTING
V2 pnpm test and pnpm build are green locally before push — CI is not the test runner. auto BLOCK Component Lifecycle §PR Refinement Loop
V3 Tests assert the public contract — state transitions, controlled and uncontrolled paths, callback arguments, documented edge cases. manual BLOCK packages.instructions §Tests
V4 No test slop: no internal-state or DOM-structure assertions, no bare snapshot dumps, no "renders without crashing" as the only assertion, no it.each explosions, no computed-style/pixel assertions. manual FIX (BLOCK if the PR is the test PR) packages.instructions §Test slop
V5 ARIA contract tested: roles, aria-* wiring, accessible names. semi — grep getByRole/toHaveAccessibleName/aria- BLOCK Component Build Protocol Phase 7
V6 Keyboard interaction tested with user-event for the full APG pattern — Enter/Space, arrows, Escape, Tab order, RTL flipping. semi — grep userEvent, then read BLOCK for interactive components Accessibility Checklist §Keyboard
V7 Focus management tested: trap while open, restore on close, focus never dropped on state change. manual BLOCK for overlays Accessibility Checklist §Focus
V8 Announcements tested via the live region (role="status"), not text queries — the setup deliberately ignores [data-astryx-live-region] in text queries. semi FIX internal/test-utils/src/setup.ts
V9 axe passes (pr-a11y, scoped to changed components) and the baseline only shrinks. auto + semi BLOCK ci.yml pr-a11y
V10 Storybook stories exist for the states the a11y and RTL audits sweep — the audits only see what a story renders. auto — story presence BLOCK accessibility-audit.js
V11 pnpm rtl:audit -- --filter <Name> produces no surprises. Note pr-rtl is continue-on-error: true — a soft gate today. auto but soft FIX (BLOCK for promotion) ci.yml pr-rtl
V12 Server-safe utilities have an actual SSR/RSC render test where they claim RSC callability. manual — only 2 files repo-wide use react-dom/server today FIX RSC Compatibility (#3466)
V13 Export/package surface verified: sync:exports:check, verify-exports.mjs, types exported. auto BLOCK ci.yml
V14 Story play: functions exercise the interactive flows (104/165 top-level story files have them). semi NIT Hardening Audit Guide §Automation targets
V15 Visual evidence: before/after screenshots for a visual change, in the relevant states, variants, and themes. State the VRT gap honestly — there is no visual regression suite. manual FIX POR Hardening Audit Guide §Automation targets
V16 Coverage read as a signal, not a gate — name the untested branch; do not quote a threshold that does not exist. semi — pnpm test:coverage NIT vitest.config.ts (no thresholds)

5/5 — tests read as a contract spec: controlled and uncontrolled, every callback's arguments, documented edge cases, full APG keyboard coverage via user-event, focus trap/restore assertions, role/name/ARIA state assertions; pr-a11y green with a shrinking baseline; RTL audit clean; check-use-client clean; SSR-callable utilities actually server-rendered in a test; before/after screenshots on any visual change. (Non-normative: Button.test.tsx, 39 cases, was the closest example as of 2026-08-10.) 3/5 — solid render and prop tests plus a couple of interaction tests; ARIA asserted, keyboard partial, focus behavior untested; axe green partly because the stories are too thin to reach the interesting states. Green CI, real gaps. 1/5 — "renders without crashing" plus a snapshot; assertions pinned to DOM structure or computed styles; zero keyboard or ARIA coverage on an interactive widget; a new baseline entry silencing a fresh violation; a behavior fix with no regression test.


§7 — Code health (React & DOM) — weight 8

Mined from ~950 PR review comments plus packages.instructions.md. The house position, stated plainly: "All useEffects are smells" (PR #298).

Sanctioned exception: synchronizing a real external system

Added in v1.1, from the Tooltip calibration finding.

An Effect is the right tool for exactly one job: synchronizing with a system React does not own. useLayer.show()/hide() wrap the native Popover API (showPopover()/hidePopover(), with a Safari <17 / Firefox <125 fallback) — browser top-layer state that has no declarative React expression. Pushing a controlled isOpen prop into it must happen in an Effect; there is no handler to put it in, because the change originates from the consumer's render, not from a user gesture.

An Effect is sanctioned when all seven hold:

  1. The external system is real — a browser API with state React does not own (top layer / Popover API, dialog.showModal(), observers, media queries, document listeners, a third-party imperative widget). Another React component's state is not an external system.
  2. One direction only — props → system. The Effect does not mirror the system back into React state as its purpose.
  3. Idempotent and self-guarding — a second call is a no-op, so a re-render or a StrictMode double-invoke is harmless. useLayer.show() guards on isOpenRef.current; hide() likewise.
  4. No focus movement from inside the Effect.
  5. No announcement and no analytics from inside the Effect (that is A7/C3, and it stays BLOCK).
  6. Any consumer-visible callback it triggers provably fires once — the guard in (3) must be what makes that true, not luck.
  7. It lives in the primitive that owns the system, not re-implemented in every consumer.

The bright line is focus. The rule C2 comes from (#4769) was a focus-management bug: controlled state plus an Effect dropped focus. A component that moves or traps focus — modal Dialog, focus-trapping Popover, Selector, Menu — gets no exception; the harm the rule prevents is live there. A non-focus-stealing layer (Tooltip, HoverCard — aria-describedby only, useLayer does no focus management at all) cannot reproduce that harm, and the exception applies.

Worked example — Tooltip, from the calibration run. useTooltip.tsx:452-463 (controlled isOpenlayer.show()/hide()) satisfies all seven: real external system, one-way, self-guarding, no focus, no announce. Not a finding. Popover.tsx:499-509 is the better form of the same thing — useIsomorphicLayoutEffect plus an explicit !popover.isOpen guard, so there is no flash. useTooltip.tsx:444-449 (mount-time isDefaultOpenlayer.show()) fails (7) only: useLayer has no isDefaultOpen option, so four consumers each re-implement it. That is a FIX routed to useLayer, not a BLOCK on Tooltip — it fires onOpenChange once because show() self-guards, so the double-fire risk is real in shape but absent in fact.

What stays BLOCK regardless: an Effect that announces (A7), moves or restores focus, fires analytics, syncs one piece of React state to another, or mirrors props into state. And "the external system is real" is judged, not asserted — say which API, and why it has no declarative form.

# Check How Sev Mode Source
C1 Every new or changed useEffect is justified in the PR description — name the external system it synchronizes. If it responds to a user action, it belongs in the handler. manual BLOCK if the Effect is doing event-handler work; FIX otherwise packages.instructions; PR #298
C2 Do not add controlled isOpen-style props that need Effects to stay in sync — expose imperative controls via a handle ref, driven from callbacks. Exempt when the Effect meets all seven conditions of the sanctioned exception above (real external system, one-way, self-guarding, no focus, no announce, single-fire, owned by the primitive). Pre-existing instances that do not meet it are FIX and route to a migration issue; a new non-exempt instance is BLOCK. manual BLOCK for a new non-exempt instance; FIX for pre-existing; not a finding when exempt PR #4769; sanctioned exception above
C3 Never announce, focus, or fire analytics from an Effect that can re-run on re-render. Firing a consumer-visible state callback (onOpenChange) from a mount Effect is a FIX, not a BLOCK, when the underlying imperative call is self-guarding so it provably fires once — route it to the primitive that should own the initial state (e.g. a useLayer({isDefaultOpen}) option). Announcements and focus have no such carve-out. semi BLOCK for announce/focus/analytics; FIX for a guarded single-fire consumer callback PR #4360; Tooltip calibration finding
C4 Use a callback ref instead of a mount Effect for "measure/register the node once it exists" — it avoids a second render pass. manual FIX (BLOCK in a core primitive) PRs #4000, #4285
C5 No useState for anything derivable during render, from a ref, or from the interaction handler itself. semi — @eslint-react/no-unused-state, set-state-in-render, react-compiler FIX packages.instructions; PR #3998
C6 Never hold DOM elements (or whole collections) in React state — store the minimal key set, or apply the style imperatively via a ref map. manual BLOCK at list scale PR #1064
C7 Use the shared pooled observer — observeResize/unobserveResize from utils/sharedResizeObserver — never new ResizeObserver per instance (browsers batch per observer instance). semi — @eslint-react/web-api-no-leaked-resize-observer catches the leak, not the waste BLOCK in a per-row/per-cell path; FIX elsewhere utils/sharedResizeObserver.ts
C8 Prefer CSS over an observer: @container, :nth-child, @starting-style, stylex.when.ancestor. semi BLOCK — hand-rolled workaround for a supported CSS feature CLAUDE.md STYLEX-CAPS
C9 Encapsulate an optional behavior in its own hook and attach listeners only when enabled — no unconditional setup for an off-by-default feature. manual FIX PR #658
C10 Model drag/resize/pointer interactions as callbacks, not Effect chains. manual FIX PR #1877
C11 Never read a ref during render. semi — @eslint-react/purity, react-compiler BLOCK PR #1877
C12 Compose the existing hook or primitive instead of re-implementing it (useMenuHover, useClickableContainer, useOverflow, useAnnounce, useFocusTrap, the focus hooks, useTypeahead). manual BLOCK for a11y primitives; FIX otherwise packages.instructions; PRs #3851, #2985
C13 Keep a hook's memoized return reference-stable; expose a getter for expensive on-demand data rather than depending on a churning collection. manual FIX (BLOCK if it destabilizes a core context value) packages.instructions; PR #3256
C14 Don't re-derive what the host, the DOM, or an existing source already carries. manual FIX packages.instructions
C15 Don't hand-author a large lookup map when a source of truth already exists. Before writing a literal Record<…> of more than a handful of entries — icon name → glyph, status → color, locale → format, key → label, component → token — establish where that data already lives (the icon registry, the token/variant maps, defineTheme, the locale catalog, an existing registry or generated file) and derive from it. A hand-authored map is a second source of truth that drifts silently, defeats theme extension, and is exactly the shape an LLM reaches for first. If no source exists, say so in the PR and put the map behind one exported constant with a SYNC: comment naming what it must track. semi — flag any object literal over ~8 entries mapping a known domain; check for an existing registry of the same keys FIX (BLOCK when it shadows the icon registry, a token map, or a themeable variant axis — those are §2 T17/T19) API Conventions §Use the system; Night Watch Component Auditor §4
C16 Initialize state to its correct value; never "fix it up" in a follow-up Effect. semi — @eslint-react/set-state-in-effect FIX (BLOCK if it produces a visible flash) PR #2136
C17 Never express state by unmounting a focusable element — toggle visibility so focus and the a11y tree survive. manual BLOCK packages.instructions; copilot-instructions
C18 Presentational components stay hook-free. auto — @astryx/presentational-component (error in both tiers) BLOCK rule source
C19 Clean up every listener, timer, interval, observer, and fetch. auto — @eslint-react/web-api-no-leaked-* BLOCK eslint.config.js
C20 No magic numbers in layout/interaction math — derive from a token or name the constant with its provenance. semi FIX PR #1876
C21 Comment why, not what — no narration comments, commented-out code, or changelog-in-code. manual NIT copilot-instructions

5/5 — zero Effects, or every Effect synchronizes a genuinely external system and says so; interaction logic lives in handlers and callback refs; state is only what cannot be derived; sizing uses the shared observer pool with matched unobserve; behavior is composed from existing hooks and data is derived from the existing registry rather than re-typed as a literal map; nothing read from refs in render; react-compiler and purity clean at lint:strict. 3/5 — one or two defensible-but-undocumented Effects, a redundant useState, an own-instance ResizeObserver outside a hot path (correctly cleaned up), or one modest hand-authored map that duplicates data an existing source already holds. No correctness impact. 1/5 — Effect chains syncing props→state; an Effect that announces or focuses; controlled-open props kept in sync by Effects; elements or collections held in state; per-row observers; a hand-rolled a11y primitive; a large literal map shadowing the icon registry, a token map, or a themeable variant axis; refs read in render.


§8 — Docs, Storybook & docsite — weight 8

Ground truth: stories are not colocated — zero *.stories.tsx files exist under packages/. All 180 live under apps/storybook/stories/: 165 as <Name>.stories.tsx at the top level, the rest in charts/. (CONTRIBUTING §5 and packages.instructions.md still describe a colocated file; that guidance is stale.) Blocks live at packages/cli/assets/templates/blocks/components/<Name>/. Prop→story coverage across main today is 859/1071 = 80%.

# Check How Sev Mode Source
X1 A <Name>.doc.mjs exists, exports a docs object typed ComponentDoc, and type-checks. auto — pnpm -F @astryxdesign/core typecheck:docs BLOCK Docsite Architecture
X2 usage.description is present and non-empty — it drives the docsite Overview and the CLI --brief. auto — grep + data-extraction.test.ts BLOCK Docsite Architecture; #4133
X3 usage.bestPractices contains both do's (guidance: true) and don'ts (guidance: false), ≥2 of each. Button.doc.mjs (5 do / 3 don't) is the bar. semi — count per doc FIX Button.doc.mjs
X4 usage.anatomy lists every named part with required flags. semi FIX Docsite Architecture
X5 X5a — phantom prop (BLOCK): no props[] entry or doc prose may name a prop the component does not have. Doc prose is LLM training signal; a phantom prop steers codegen to hallucinate. Enforced by docPropReferences.test.ts. X5b — missing entry (FIX): every user-facing prop in the TS interface should have a props[] entry. This direction has no enforcer and drifts freely. Each prop entry carries type, description, and default (or required: true). X5a auto · X5b semi X5a BLOCK · X5b FIX Night Watch Night Watch Doc Reviewer §2; docPropReferences.test.ts
X6 Exactly one hero block: a .doc.mjs under packages/cli/assets/templates/blocks/components/<Name>/ with isShowcase: true plus a matching <Name>Showcase.tsx default export. auto — Night Watch Doc Reviewer §6; data-extraction.test.ts BLOCK Night Watch Doc Reviewer §6
X7 ≥1 non-showcase example block (isShowcase: false, exampleFor: '<Name>') with .tsx.doc.mjs pairing and a non-empty description; metadata valid (type: 'block', aspectRatio > 0, componentsUsed). auto — Night Watch Doc Reviewer §3/§9/§10 BLOCK (pairing) / FIX (metadata) Night Watch Doc Reviewer
X8 The showcase/examples actually render — registries regenerate and the docsite data tests pass. auto — pnpm -F @astryxdesign/docsite generate && test (CI docsite-test) BLOCK ci.yml
X9 A story file exists at apps/storybook/stories/<Name>.stories.tsx. auto — hasStories() in analyze-pr.js BLOCK analyze-pr.js
X10 Every documented prop is exercised by at least one story. Parse rule (pin it, don't re-litigate it per audit): a prop counts as exercised if it appears as an args key, an argTypes key (including control: false), or a JSX attribute in the component's story file. It does not count if it only appears in prose or a type import. Target: 100% for a new or promoted component; no regression for an existing one. Repo baseline today is 80% under the loose match. auto — ship the coverage script; do not hand-count FIX (BLOCK for a new component or promotion) Component Build Protocol Phase 6
X11 Stories demonstrate differences, not enumeration: variants side by side, composition (inside Dialog/Table/Card/AppShell), and edge cases (long text, empty, dense, narrow container). manual FIX Component Build Protocol Phase 6; Component Hardening Protocol Layer 2
X12 Every applicable state has a story — rest/hover/focus/active/disabled/loading/status/selected/empty/overflow. semi — story-name grep, then read FIX Component Hardening Protocol Layer 2
X13 tags: ['autodocs'] is set (143/165 top-level story files have it). auto — grep NIT CONTRIBUTING §5
X14 JSDoc @example exists on every exported component/hook and uses a bare fence — no tsx tag, no blank lines, no // comments, no lone > inside the fence. A tagged fence silently breaks autodocs. auto — Night Watch Doc Reviewer §1 BLOCK Night Watch Doc Reviewer §1; CLAUDE.md
X15 File header JSDoc present (@file/@input/@output/@position) and SYNC: reminders honored. semi FIX CLAUDE.md
X16 Accessibility guidance lives in the docs, not only in code: name/keyboard/labeling advice as bestPractices entries, and the implemented APG pattern linked in the PR description. manual FIX Accessibility Checklist
X17 theming.targets[] matches every themeProps() call; theming.cssProperties[] covers every component var. auto — Night Watch Doc Reviewer §5/§5b + themingTargets.test.ts FIX Night Watch Doc Reviewer §5
X18 Common props documented with the canonical wording — notably xstyle must say "stylex.create() value — not an inline style object". semi — Night Watch Doc Reviewer §4 FIX Night Watch Doc Reviewer §4
X19 Playground works: playground.defaults where the component renders blank without them; slotElements on element-accepting ReactNode props (icon, endContent, actions). semi — Night Watch Doc Reviewer §8b NIT Night Watch Doc Reviewer §8b (#2005)
X20 A consumer-visible change carries a changeset with a [category] first line and a @handle contributor line. auto — pnpm check:changesets BLOCK CONTRIBUTING; copilot-instructions

5/5.doc.mjs with description, ≥2 do's and ≥2 don'ts, and anatomy; 100% prop→doc parity, every prop typed and defaulted; one hero block plus ≥2 examples, all pairing-clean and rendering; a story file touching ≥90% of documented props and showing variants, composition, and every applicable state; clean bare-fence @examples; theming in sync; a11y guidance in bestPractices with the APG pattern linked. (Non-normative: Button was the closest example as of 2026-08-10.) 3/5 — docs and a story exist and CI is green, with real holes: prop→story coverage 60–85%, a few props missing defaults, one thin example, stories that enumerate variants without composition or edge cases, no a11y guidance. (Non-normative: components in this band as of 2026-08-10 included Section, Tooltip and SideNav.) 1/5 — a props-only .doc.mjs with no usage (the docsite Overview renders empty), or no .doc.mjs at all; no block directory, so no hero and no examples; no story, or one default story; @example missing or language-tagged so autodocs shows nothing. (CircularProgress in lab; Code, Heading, HStack, VStack ship with no .doc.mjs.)


§9 — i18n & RTL — weight 5

The lint rule is @astryx/no-hardcoded-i18n-string (sibling: @astryx/i18n-key-format). It catches: text-sink JSX attributes (label/placeholder/title/tooltip/text/summary/message/description/hint and any *Label/*Text/*Placeholder/… suffix), the user-visible aria-* subset, object properties of those names in .tsx, destructure defaults, and hardcoded first arguments to announce(...). It ignores tests, stories, .doc.mjs, and JSDoc examples. Temporary allowedCalleeStrings: 'Copied', 'Selection cleared', 'All selected'.

# Check How Sev Mode Source
I1 Every user-visible string goes through useTranslator() / the catalog — no literal in any text-sink prop, object field, or destructure default. auto — @astryx/no-hardcoded-i18n-string (error in CI) BLOCK copilot-instructions
I2 AT-facing strings are translated too — aria-label, announcements, hints, aria-roledescription, hidden helper text. auto (listed aria set) + manual BLOCK Accessibility Checklist §i18n
I3 Live announcements route through useAnnounce with a t(...) argument — never announce('literal'). auto (callees: ['announce']) BLOCK rule header
I4 New catalog keys are @astryx.<component>.<leaf>, ≥2 segments, every segment camelCase, @ prefix required. auto — @astryx/i18n-key-format BLOCK — a mis-shaped key silently misses the resolver rule source
I5 Every new key is added to packages/core/locales/en.json (250 keys today) with a description — it is the Crowdin source. An unlisted key renders as the raw key string. semi — no CI test asserts source-key ⊆ catalog BLOCK crowdin.yml; i18n/resolve.ts
I6 Interpolation uses ICU placeholders — never string concatenation or template-joined fragments. manual BLOCK — unlocalizable word order resolve.ts
I7 A shipped catalog key is never removed without a codemod and a [breaking] changeset — keys are public surface for overrides. manual BLOCK resolve.ts lookup order
I8 CSS logical properties only (marginInlineStart, insetInlineStart, borderStartEndRadius). auto — @astryx/no-physical-properties (error both tiers, autofixable) BLOCK rule source
I9 Logical values too — textAlign: 'start'/'end', float/clear: 'inline-start'/'inline-end'. auto — same rule BLOCK PR #2438
I10 Directional icons (chevron/arrow/caret) mirror via rtlStyles.mirror applied with xstyle on the Icon itself — or swap the glyph name, never both (a double flip nets to nothing). semi — rtl:audit D1 icon-mirror over every core-* story BLOCK rtl-audit README §A.1
I11 Never pair a logical anchor with an unflipped physical translate — the element lands on the wrong side in RTL. Lint cannot catch this; it is the interaction of two individually-legal declarations. semi — rtl:audit D5 positional-mirror (6/6 true positives, 0 false positives at full-library scale) BLOCK rtl-audit README §A.2
I12 Center an absolutely-positioned auto-width element with rtlStyles.centerInline(), not a hand-rolled left: 50%. auto — the rule emits a distinct message pointing at the helper BLOCK utils/rtlStyles.ts
I13 Horizontal arrow-key navigation flips under RTL, via the shared focus hooks reading direction lazily on keydown (isRtlElement), not at render. manual BLOCK Accessibility Checklist §Keyboard
I14 Direction resolution order: CSS logical → rtlStyles.mirror → lazy DOM read (isRtlElement) → useDirection() last (render-time use risks a hydration mismatch). manual FIX i18n/useDirection.ts
I15 Prev/next control order swaps and directional scroll/drag math inverts under RTL; add a targets.json entry for any new directional behavior. semi — rtl:audit D2/D3 BLOCK rtl-audit README §B
I16 Don't leak the layer's internal RTL positioning into a call site — express "don't flip this" as a named hook option. manual FIX PR #3486
I17 Run pnpm rtl:audit -- --filter <Name> and paste the scorecard on any PR adding directional UI or a new component. semi — CI runs it scoped; weekly full sweep FIX (BLOCK for promotion) CONTRIBUTING §RTL audits
I18 Text expansion doesn't break layout — no fixed widths on translatable text; verify with ~1.4× pseudo-locale or a long-string story. manual FIX Component Hardening Protocol Layer 2 §Overflow

Severity note. §9 carries a lot of blocking weight for its 5 points. Blocking is reserved for defects: an untranslated string, a mis-shaped key, a physical property, a chevron that doesn't mirror, a wrong-side element. Missing evidence — no rtl:audit scorecard pasted (I17), no pseudo-locale check (I18) — is FIX, not BLOCK, outside a promotion.

5/5 — every visible and AT-facing string is a @astryx.* key present in en.json with a description; ICU for interpolation; zero physical properties or values; rtl:audit D1+D5 clean with a curated target added for new directional behavior; direction never read during render; arrow-key flipping from the shared hooks. 3/5 — all visible text localized and logical CSS throughout, but one AT-facing label or announce() literal remains, or useDirection() is used at render in one low-risk spot, or D2/D3 behavior was verified by hand without adding a curated target. 1/5 — English literals in label/aria-label/announcements or a suppression on the i18n rule; physical marginLeft/left/textAlign: 'left' in shipped styles; a chevron that neither mirrors nor swaps (or does both); a logical-anchor + physical-translate element landing on the wrong side.


§10 — Responsive & touch — weight 5

Touch reachability is graded in §1 (A8–A10) because it is an accessibility bright line. This section grades adaptation quality.

# Check How Sev Mode Source
R1 Reflows at 320 px CSS width with no horizontal scroll and no clipped control (WCAG 1.4.10); a narrow-viewport story exists. manual — no automated reflow check exists BLOCK if a control becomes unreachable; FIX for cosmetic overflow Component Hardening Protocol Layer 2; Hardening Audit Guide §3
R2 Adapt to the container, not the viewport — @container, or Grid with minChildWidth; never a ResizeObserver doing a container query's job, never hardcoded column counts. semi FIX (BLOCK when an observer replaces a container query) CLAUDE.md STYLEX-CAPS; PR #1345
R3 Reuse the system breakpoints (AppShell sm 640 / md 768 / lg 1024) with one source of truth shared by the JS check and the CSS @media — don't invent a one-off value. manual FIX AppShell.tsx; PR #2938
R4 Prefer a CSS-only responsive swap; use useMediaQuery only when the value is needed in JS, and pass serverDefault to avoid an SSR flash. manual FIX (BLOCK for visible mobile jank on first paint) hooks/useMediaQuery.ts; PRs #3433, #2136
R5 Overlays and dialogs scroll at mobile/tablet sizes — a fullscreen dialog that cannot scroll is a dead end. manual BLOCK PR #2579
R6 Expand the hit target with useClickableContainer + interactiveRef, not by duplicating href/onClick onto the row; keep the interaction target and the positioning anchor separate where they differ. manual FIX (BLOCK if it produces nested interactives) PRs #4364, #3517
R7 Prefer overflow: clip over hidden where you don't want a scroll container — hidden is still a scroll target. semi NIT (FIX when it steals a touch gesture) PR #4197
R8 Don't render one responsive mode's structure twice — factor the shared header/content into one component composed by both. manual NIT PR #2615
R9 Scope consumer style passthrough to one stable responsive surface when the component renders structurally different elements per view. manual FIX — API ambiguity PR #4528
R10 Don't show a mobile affordance for an empty responsive slot (a nav toggle with nothing behind it). manual FIX PR #1377
R11 Ship stories for narrow container, long text, and empty state — the a11y and RTL jobs can only see what a story renders. semi FIX (BLOCK for promotion) Component Hardening Protocol Layer 2 §Stories

5/5 — clean at 320 px with no horizontal scroll; adaptation via @container/Grid minWidth; system breakpoints reused with one source of truth; overlays scroll at mobile sizes; narrow/long-text/empty stories exist; screenshots at 320 px and under coarse-pointer emulation attached. 3/5 — reflows correctly, but uses useMediaQuery where CSS would do, or introduces a one-off breakpoint value; no narrow-viewport screenshot. 1/5 — horizontal scroll or a clipped/unreachable control at 320 px; a ResizeObserver doing a container query's job; hardcoded column counts; an unscrollable fullscreen dialog.


§11 — Lifecycle & process evidence — promotion PRs only, ungraded rider

Applicability. This section grades a promotion diff (labcore, or into a package) and nothing else. For a component that already lives in core, skip it entirely — do not report a missing spec issue, arbitration record, vibe artifact, hardening issue, or self-review as a finding against a shipped component. Those artifacts are promotion-time evidence, and demanding them retroactively grades history rather than the component.

The only items that apply to an ordinary PR are L4 (review gate cleared), L14 (changeset) and L15 (no public-repo leak). In modes N and O on an existing core component, §11 is not run and the scorecard's evidence line reads n/a — existing core component.

Within a promotion PR: highlight when evidence is missing, don't block on it. Reported as evidence: n/m artifacts found, never folded into the letter grade — except the hard items marked BLOCK below.

# Check How Sev Mode Source
L1 packages/lab/package.json still has "private": true and "astryx": {"canaryOnly": true} after the diff. auto — git show <head>:packages/lab/package.json BLOCK P Component Lifecycle §Publishing guarantee
L2 The promotion is a real move — delete from packages/lab/src/<X>/, add to packages/core/src/<X>/ — not a flag flip, and not a net-new core component that skipped lab. auto — gh pr diff --name-only BLOCK P Component Lifecycle §lab → core
L3 Every Accessibility Checklist item is verified in the promotion PR body, with the APG pattern linked. manual BLOCK P packages/lab/README.md
L4 CI fully green and the review-required status cleared by an entitled CODEOWNER — not bypassed. auto — gh pr checks; commit status BLOCK PR REVIEW_GATE.md
L5 A linked tracking issue exists and it ran the Specification Protocol — evidence, not a feature request. semi — gh issue search highlight packages.instructions
L6 Spec evidence visible: internal usage research + ≥3 external systems (Radix/shadcn/Ant/MUI/Chakra), the six use-case classes enumerated, API with per-decision rationale, surface-area audit (no export duplicating an existing component). manual highlight Component Specification Protocol Phases 2–6
L7 API Arbitration ran for every contested shape or name, with recorded options, naive prompts, override rate, hallucinations, escape hatches, and the winner + why. semi highlight Component Specification Protocol Phase 8; API Arbitration
L8 Vibe-test evidence exists for the API and is methodologically sound (isolated sub-agent per prompt × option, naive prompts that never name components, a separate judge for comparisons). Expect this to be missing — there is no per-component vibe artifact anywhere; Vibe-Test-Scores.md is a system-level ledger and API concerns go to rolling issue #3164. manual highlight Component Lifecycle lab/core table; Vibe Evaluation
L9 Hardening ran: a hardening-labeled issue exists and either closed via a merged audit PR, or is open with the L3 review form archived in it — not auto-closed without work. semi highlight Component Hardening Protocol; Night Watch Component Auditor Step 2
L10 The Layer-1 audit dimensions pass (they are §§2–3 and §5 of this rubric). semi see those sections Component Hardening Protocol Layer 1
L11 The 27-item self-review (or the 8-item Quick version for a refactor) was posted on the PR as a comment. No PR template exists to prompt it — expect it missing. semi highlight Component Build Protocol Phase 10
L12 Findings raised during the pass are routed, not silently absorbed: new prop/variant/sub-component → spec issue; naming dispute → vibe-test queue; family or token inconsistency → fix here. manual highlight Component Lifecycle §When Findings Route Back
L13 Post-move surface is complete in core: <Name>.tsx, <Name>.test.tsx, <Name>.doc.mjs, a story in apps/storybook/stories/, index.ts, blocks (hero + ≥1 example). auto — §6/§8 checks + verify-exports.mjs BLOCK P packages.instructions
L14 Changeset present with [category] + @handle; a promotion changing public API is [breaking] → minor pre-1.0, with a codemod for any removed/renamed export. auto — check:changesets, codemod-verify.yml BLOCK CONTRIBUTING
L15 No public-repo leak in any committed text — T/D/S/P-numbers, infra names, unixnames, @meta.com emails, or assistant fingerprints in code, comments, PR title/body, or changeset. auto — grep the diff and PR body BLOCK copilot-instructions
L16 For a template/block reveal, the diff removing hidden: true (or a hiddenComponents entry) is intentional and the template grades B or above on the design-judge axes. semi highlight Component Lifecycle §hidden → visible

How to report a missing artifact: ⚠️ no spec issue linked / no arbitration record found / no vibe-test evidence — with the exact command that came up empty. Prominent, never a merge block.


3. The scorecard

Every graded run emits this. Paste it into the audit issue or the PR comment — and record it in Component Scores.

Recording the result is part of the audit

An audit that isn't recorded didn't happen. Every graded run — nightly, promotion, or on-demand — writes its scorecard to the Component Scores ledger. The ledger lives in the wiki, so recording is a wiki push: no repo PR, no review latency.

node scripts/score-ledger.mjs --record <Component> --from <scorecard.json>

What to record: the overall score and grade; every section score with its state (scored / limited / not_measured / na); the open BLOCK list as rule id + one line; distinct_defects / fixes / nits; lastAudited; the commit audited; the mode; and the rubricVersion you graded under (see the version block at the top of this page).

The BLOCK list is the tracking. Do not open a GitHub issue per finding — the ledger row carries them, attached to the component rather than to a queue nobody reads. Cross-cutting or system-level findings still get an issue, and the row links it.

Only record what you measured. An unverified section is not_measured — never a guess, and never a zero. Mode N captures no screenshots, so design_rendered is always not_measured there.

The recorded score is a gate. The ratchet fails any later PR that lowers a component's score or introduces a new BLOCK. Score generously and you build a gate nobody can pass; score harshly and you block the next honest contributor. Record what you found.

Component: <Name>            Package: core | lab | <pkg>
Mode: N | P | O | R          Rubric: v1.2      Date: YYYY-MM-DD
Commit/PR: <sha or #NNNN>

Quick checks (mode R): Q1 lint:strict [ ]  Q2 test [ ]  Q3 build [ ]  Q4–Q12 [ ]
Visual evidence: <n> screenshots at <path/link> — states: <list>  themes: <list>
                 (empty ⇒ §5b is `not measured`: excluded, weight redistributed,
                  named in the headline — never scored 0)

Section                                  W    Score  Weighted  Notes
§1  Accessibility & operable paths       16    _/5     __.__   
§2  Theming & token integrity            14    _/5     __.__   
§3  Public API contract                  14    _/5     __.__   
§4  Behavior correctness & states        12    _/5     __.__   
§5a Design conventions — objective        6    _/5     __.__   
§5b Design conventions — rendered         4    _/5     __.__   ← screenshots, or `not measured`
§6  Testing & verification                8    _/5     __.__   
§7  Code health (React & DOM)             8    _/5     __.__   
§8  Docs, Storybook & docsite             8    _/5     __.__   
§9  i18n & RTL                            5    _/5     __.__   
§10 Responsive & touch                    5    _/5     __.__   
                                        ---           ------
TOTAL                                   100            __.__   →  GRADE: _

BLOCKers open: <n>   (any BLOCK caps the overall grade at C)
Distinct defects: <n>   (raw findings: <n> — the distinct count is the honest one)
FIXes open: <n>      NITs open: <n>
SENSITIVITY: fix the <n> open BLOCKs, change nothing else  →  projected <score> / <grade>
`limited` sections (fewer than 1/3 non-vacuous verdicts): <list> — weight redistributed
Evidence rider (§11): <n>/<m> artifacts found — <list what's missing>
N/A sections: <list with reason>
Unverified: <items no tooling run could confirm, and which way they'd move the grade>

Top 3 things to fix:
1. <BLOCK — rule cited — concrete fix>
2. ...
3. ...

Per-mode run sheet

N — nightly P — promotion O — on-demand R — PR review
Quick checks (Q1–Q12) Q1–Q3 on the audit PR all + full 27-item self-review Q1–Q3 all — entry gate; a red one stops the review
Items run auto + semi only all all all, scoped to the diff
Visual evidence (§5b) none — §5a only; §5b not measured required — every applicable state × light, dark, ≥1 custom theme, + 320 px and coarse pointer. Mode P cannot pass without it. required; if skipped, §5b is not measured and the headline says so required for any visual change: before/after pairs. Without them §5b caps at 3/5
Output objective subscore + batched fix PR full grade + go/no-go + contact sheet full grade + scorecard + contact sheet signal + delta grade
Gate none (drift report) 0 BLOCKs and ≥ B none any BLOCK ⇒ request changes
§11 evidence skip (existing core component) full skip for an existing core component; full for a promotion L4/L14/L15 only

Commands for a full pass

pnpm lint:strict && pnpm test && pnpm build   # Q1–Q3, the entry gate
pnpm check:sync && pnpm check:changesets
node scripts/check-use-client.mjs          # not wired into any script — run it
node scripts/sync-exports.js --check && node scripts/verify-exports.mjs
pnpm -F @astryxdesign/core typecheck:docs
pnpm -F @astryxdesign/docsite generate && pnpm -F @astryxdesign/docsite test
pnpm storybook:build && pnpm a11y:audit -- --components <Name>
pnpm rtl:audit -- --filter <Name>

# §5 visual evidence — required, not optional
pnpm -F @astryxdesign/build build && pnpm -F @astryxdesign/core build   # Storybook reads dist/
pnpm storybook                                                          # :6006
npx playwright install chromium
#   then drive real Chromium: for each applicable state (rest, hover, focus-visible,
#   active, disabled, loading, each status, selected, empty, overflow) × light + dark
#   (+ a custom theme for P/O), screenshot to
#   <Component>__<variant>__<state>__<theme>.png — and then LOOK at them.
#   Use page.hover() / keyboard Tab / page.mouse.down() to drive real states;
#   never fake a state by injecting a class.

# then the greps lint doesn't cover: hex/rgb/hsl, boxShadow, <svg,
# textSizeVars, defaultMarker, style={{, ':hover' without a guard,
# and fooStyles[prop] vs the sibling themeProps() call

4. Pending activations

Checks that are written but not yet live. Flip them when the trigger lands; until then they are inactive and must not be cited.

Check Currently Activates when
T34 — indicator convention inactive (PR #4712 is a draft; packages/core/src/Indicator/ is not on main) #4712 merges
I15 / V11 — RTL findings FIX (the pr-rtl job is continue-on-error: true) continue-on-error is dropped and pr-rtl becomes a required check
X5b — missing doc prop entry FIX (no enforcer exists for the source→doc direction) a source→doc checker lands
D7 / D8 — token-layer contrast not scored against components (known systemic — #4652) #4652 closes, or a semantic-pair contrast gate lands in theme/contrast.ts
A10 / R1 — target size, 320px reflow semi / manual a Playwright pass measuring interactive bounding boxes at 320px and under pointer: coarse is added to the rtl-audit harness
T14 — style-only wrapper review-owned (rule is warn in both tiers, off in test files) the ~25 in-flight core migrations land and the rule flips to error

Clone this wiki locally