-
Notifications
You must be signed in to change notification settings - Fork 1k
Component Audit Rubric
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.
| 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 (lab → core, 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).
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 list — Component Build Protocol Phase 10 — and then the whole rubric.
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
corecomponents 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 flat2 ⇒ ≤2cliff 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 lab→core 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 lab → core 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.
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 disables — aria-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.
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 | |
| 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. |
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. |
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. |
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.
"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:
Naming — component anatomy
<Namespace><Variant><Type><Postfixes>, unprefixed; file names mirror exports; the 7-file directory; the@file/@input/@output/@position+SYNC:header; hooksuse<Name>; contexts<Component>Context; types<Component>Props/Variant/Status. Props — booleansis/has; callbackson{Verb}{Scope?};onOpenChangefor layer components only; the primary change callback isonChange; enum values camelCase;size/padding/margin=sm|md|lg; directional propsstart/end;html-prefix for native collisions. Required vs optional — behavioral and structural required, presentational optional. Composition vs config — slots are passthrough; no escape hatch without a demonstrated use case; behaviors are hooks, not wrapper components. Use the system — no raw HTML where a primitive exists; icons from the registry; match sibling prior art. Prop surface — everything extendsBaseProps; capture and forward...rest; per-category precedence. State — inputs are controlled; uncontrolled defaults usedefault+ preservedIs/Has;boolean | configrules. Async —clickAction+useTransition,changeAction+useOptimistic(noonprefix), 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; 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 | |
| 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}Action — changeAction, 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 |
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 "
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.
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.
§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.
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.
§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
statusvalue, 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/corebefore capturing — Storybook resolves it todist/, so a stale build measures stale code.getComputedStylereadings (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.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-hovertint 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, andgetComputedStyleto 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.pngHovered (Overlay style) --color-overlay-hovertint over base ✓yes pass The approved representations (reference images live in the wiki at
raw.githubusercontent.com/wiki/facebook/astryx/<name>.png):
State Approved representation Token signature to verify in the screenshot Reference Rest base tokens, no interaction — dc-button--secondaryHovered — Overlay style --color-overlay-hovertint layered over the basean alpha tint, not an opaque fill dc-button--secondary__hoverHovered — Ring style (fields) --color-border-emphasizedborder +--shadow-inset-hoverringinset ring, not elevation dc-textinput--hoverPressed scale(0.98)+--color-overlay-pressedtransform + overlay, not a color swap dc-button--secondary__activeFocused — Outline style 2px --color-accentoutline at 3px offsetoffset outline, visible in every theme dc-button--secondary__focusFocused — Ring style (fields) --color-accentborder + inset ringdc-textinput--with-value__focusSelected — Filled (toggles) --color-accentfills the controlaria-checked/valuebacks itdc-checkboxinput--checked,dc-radiolist--selected,dc-switch--onSelected — Surface (segmented) active segment lifts on --color-background-surface+--shadow-lowdc-segmentedcontrol--defaultSelected — Edge (tabs/steps/entries) --color-accentedge bar or underlinearia-selected/aria-currentbacks itdc-tablist--default,dc-outline--basic,dc-stepper--basicSelected — Border (cards) --color-accentborder + inset ringdc-selectablecard--single-selectSelected — Depressed (list/nav rows, toggle buttons) --color-neutral/--color-accent-mutedfillaria-current/-selected/-pressedbacks itdc-sidenav--selected,dc-treelist--selected,dc-togglebutton--selectedDragging — Ghost preview source stays in place; source and duplicate preview at 50% opacity; preview follows the pointer on both axes; no raised-card shadow 50% opacity allowed only while actively dragging dc-reorder--drag-ghostDrop target — Insertion line 2px --color-accentline; surrounding items stationary until releasedc-reorder--drop-targetDisabled --color-text-disabled, muted, non-interactive; adisabledMessageexplains whynative disabled, not just a visualdc-button--disabled,dc-selector--disabled-with-messageLoading — Skeleton style skeleton placeholder stands in; dimensions stay stable aria-busydc-skeleton--defaultProcessing — Spinner style in-place content hidden ( color: transparent) with a spinner over it, or a spinner in the field's end slot; dimensions stay stablearia-busy, neverdisableddc-button--loading,dc-textinput--loadingStatus — Muted style (low prominence, persistent) --color-{status}-mutedbackground + colored text/border + iconicon present — never color alone dc-banner--warning,dc-button--destructiveStatus — Inverted style (high prominence) solid --color-{status}fill with--color-on-{status}textdc-badge--inverted,toast-errorTemporal — Overlay style (brief, transient) --color-background-invertedoverlay that appears and dismissestoast-info,dc-tooltipAgent states (thinking, reasoning, streaming, tool-executing, awaiting input, syncing, inspecting, rendering) undefined — no approved visual yet — propose via the spec loop; keep it close to the User/System language 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-grid spacing — every padding/margin/gap on the 4px scale (2px half-steps for optical work), from spacing tokens. | 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 — r_inner ≈ r_outer − gap on every nested pair; radius from the role scale (--radius-inner 4 / -element 8 / -container 12 / -page 28 / -full). |
semi | FIX | Design Conventions §Radius | |
| D5 | On-scale heights — fixed-height controls are 28 / 32 / 36 px; size and density tuned together so mixed rows share one baseline. | semi | FIX | Design Conventions §Size · Density | |
| D6 | Type hierarchy — role tokens over raw sizes; adjacent size steps ≥1.25×; no body text below 12px; line-height ≥1.3 on multi-line body (target 1.5); measure ≤75ch; letter-spacing ≤0.05em on body; no all-caps or justified body. | 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/5 — screenshots 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.
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.
Mined from ~950 PR review comments plus packages.instructions.md. The house position, stated plainly: "All useEffects are smells" (PR #298).
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 controlledisOpenprop 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:
- 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.- One direction only — props → system. The Effect does not mirror the system back into React state as its purpose.
- 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 onisOpenRef.current;hide()likewise.- No focus movement from inside the Effect.
- No announcement and no analytics from inside the Effect (that is A7/C3, and it stays BLOCK).
- Any consumer-visible callback it triggers provably fires once — the guard in (3) must be what makes that true, not luck.
- 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-describedbyonly,useLayerdoes no focus management at all) cannot reproduce that harm, and the exception applies.Worked example — Tooltip, from the calibration run.
useTooltip.tsx:452-463(controlledisOpen→layer.show()/hide()) satisfies all seven: real external system, one-way, self-guarding, no focus, no announce. Not a finding.Popover.tsx:499-509is the better form of the same thing —useIsomorphicLayoutEffectplus an explicit!popover.isOpenguard, so there is no flash.useTooltip.tsx:444-449(mount-timeisDefaultOpen→layer.show()) fails (7) only:useLayerhas noisDefaultOpenoption, so four consumers each re-implement it. That is a FIX routed touseLayer, not a BLOCK on Tooltip — it firesonOpenChangeonce becauseshow()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.
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.)
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:auditscorecard 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.
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.
Applicability. This section grades a promotion diff (
lab→core, or into a package) and nothing else. For a component that already lives incore, 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.
Every graded run emits this. Paste it into the audit issue or the PR comment.
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() callChecks 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
|