-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Component Audit Rubric
Version 1.15.3; see Versions for what that means and what changed.
This page is a checklist for judging the quality of an Astryx component, and a way to turn that judgement into a score you can track. You almost never run all of it. Find your situation below and go where it sends you.
| You are... | What to do | Where |
|---|---|---|
| Reviewing a pull request | Judge the change, not the component. How much to check depends on whether it is a bug fix, a new feature, or a new component — see the next section. | Reviewing a change |
| Grading a whole component — someone asked "how good is this?", or you are hardening it | Work the whole checklist, capture screenshots, and record the result. | Grading a whole component |
Moving a component from lab into core |
The strictest pass. This is the gate the checklist exists for. | Promoting to core |
| The nightly automated pass | Full component audit and objective fixes. A draft observational worksheet may accompany the PR; auto-merge remains inactive until current repository authority and schema validation activate it. | The nightly pass |
| Just looking up a rule | The eleven sections below hold the checks, each with an id you can cite in a review. | The checks |
How the score works, in one paragraph. Each section is scored out of 5 against written descriptions of what 5, 3 and 1 look like, then weighted into a total out of 100 and a letter A–F. Findings are blocking (ship this and something is broken), should-fix (real debt, shippable with a reason), or nit. Any open blocking finding caps the grade at C, however good the rest is. The number measures distance from the bar, not craft — so always read the blocking count next to it, and say what fixing them would get you. Details in Scoring.
Where scores live. In component-scores.json in this wiki — the ledger is a file you edit and push, not a service. Recording your result is part of doing an audit — see Recording an audit. No pull request is gated on a score.
This checklist is how we review. Current in-repo authority records govern policy; linked wiki pages are practical explanations and worksheets. Every check below cites its source so a finding can be traced to the governing record or supporting guidance.
| Guidance or record | Role |
|---|---|
| API Conventions | Prop and component naming, required vs optional, composition vs config, the prop surface contract |
| Theming Infrastructure | Tokens, theme targets, the cascade, custom variants, on-media theming |
| Design Conventions | Spacing, radius, size, type, colour, elevation, motion, and the approved state visuals |
| Accessibility Checklist | Practical consumer/contributor worksheet for applying current accessibility requirements |
| AST-009 assistive-technology verification | When real AT/browser evidence is required, what it proves, and when pending receipts block stable release |
| Component Lifecycle | Specify → build → harden, and the gates between them |
| Component Specification Protocol · API Arbitration | How a new component and its API get decided |
| Component Hardening Protocol | The Layer 3 human design review, and its form |
| Component Authoring Guide | File structure, StyleX patterns, token usage |
| Night Watch Component Auditor | Who runs the nightly pass, and how |
| Contributing with AI Assistants | Working on this repo with an agent |
When one of those pages and this one disagree, the applicable current repository record wins on product behavior and this page wins on audit procedure, scoring, and ledger mechanics. Fix the contradiction at the canonical repository owner, then update this guide.
For Night Watch, promotion, and on-demand grading, start with the component's
colocated repository spec. If it is missing or incomplete, continue from applicable
current authority and checkable evidence. You may optionally prepare verified
shipped behavior as a draft observational worksheet for owner review using the
repository's
component-specification guide.
The proposed closed evidence matrix makes every public concept, reachable state,
and evidence gap visible for review; it is not a validated gate while
spec:AST-029 is draft.
Implementation defines component-local observation when local docs or tests drift. Applicable current shared records and objective standards override conflicting implementation; those conflicts are findings to fix, not behavior to canonize. Unresolved public API, compatibility, ownership, or subjective design questions remain manual. The worksheet is review evidence, not current product authority or auto-merge permission.
Never run the whole checklist on a pull request. A review that costs an hour on a two-line fix gets skipped, and one that lists thirty findings the author did not cause buries the one that matters. Set the depth by what the change is:
Review the PR on its own merits. A contributor is responsible for what their diff introduces — never for what they inherited by touching a file.
- No ledger entry for this component? Judge the PR purely on its merits. Do not run a full audit, do not produce a grade, and do not block on anything the diff did not cause. An unaudited component is a gap in our coverage, not a defect in their contribution.
- A ledger entry exists? It is a baseline, not a bar. The ratchet asks one question: did this diff make it worse? A recorded BLOCK with an open issue is known debt and blocks nothing. Only a new BLOCK, or a decrease in score, fails the check.
- A stale baseline never blocks a good PR. Scores trail reality until the next audit; since the ratchet fails only on regressions, a low or outdated recorded score can only ever help a contributor.
- Found pre-existing problems while reviewing? Good — file them per the audit-issue protocol, or leave them for the nightly pass. Do not attach them to the contributor's PR, and do not ask them to fix the component to get their change in. Say plainly that the finding is pre-existing and not theirs to fix.
- The only things a PR must carry regardless are the ones it is itself responsible for: the quick checks (lint, test, build), a changeset for a consumer-visible change, and screenshots for a visual change.
The point of the grade is to ratchet the system upward over time, not to tax whoever happens to touch a file next.
Never run all ~200 checks on a PR. Depth comes from the repo's existing triage in
.github/instructions/packages.instructions.mdStep 0 — category ×ばつ risk → path — and the specific checks come from what the diff actually touches. State the triage line at the top of the review, as that page already requires:
Triage: bug fix · non-breaking · low blast radius → fast path · checks: §1 A4/A5, §7 C1The bar, by what the change is. Depth is set by the kind of change, and each kind has its own bar rather than a fraction of the same one:
Change The bar Bug fix Evidence that it was broken before and is fixed now — a test that was red and is now green, or a before/after screenshot for a visual bug. State that evidence in the PR description. Then a code-smell pass (§7, plus whatever the trigger table below fires). If the evidence is real and the fix is clean, the PR is fine — approve it. Do not audit the component around it. New feature (new prop, variant, behaviour on an existing component) Run every automatable check — all autoandsemiitems across the rubric; they are cheap and they are the ones that catch drift. Then a judgment pass on the audit items the change actually touches (trigger table below). Not the whole rubric; not one section either.New component → coreFull audit — every section, with visual evidence. A new component in coreis a permanent public commitment, and this is the case the whole checklist exists for. Record the result in the ledger as the component's first score.New component → labDeliberately lax. Run the automatable checks and the triggered items; flag the rest as "worth an audit pass before promotion" rather than blocking. Lab is explicitly the place where a component is allowed to be unfinished — it is canary-only and promises no stability. Blocking lab on the core bar defeats the point of having lab. The full audit is the gate out of lab, not the entry fee into it. Everything that goes to
coregets the full audit eventually — either as a new component here, or at promotion. Lab just moves when.Which checks, from what the diff touches. Include a group only when its trigger fires:
If the diff touches... Run stylex.create, a*.stylex.ts, or any colour/spacing/radius/shadow value§2 T1–T3, T12–T15 · §5a D1/D4/D5 themeProps, a*.doc.mjsthemingblock, or a style-driving prop§2 T4–T7, T19, T26 · X17 aria-*,role,tabIndex, focus, keyboard handling, or a live region§1 A1–A7, A11–A12 a :hoverrule, a reveal, or a hit target§1 A8–A10 · §5a D12 a *Propsinterface, an exported type, or...rest/mergeProps§3 P1–P7, P15–P18 useEffect,useState, a ref, or an observer§7 C1–C8, C11 a user-visible or AT-facing string §9 I1–I5 a physical CSS property, a directional icon, or RTL-sensitive layout §9 I8–I13 .doc.mjsprops, stories, or a block§8 X5, X9–X12 rendered output in any way (the visual change test) §5b — before/after screenshots, required a published export, or anything consumer-visible §3 P11–P12 · X20 changeset Two rules that override the triage:
- A bright-line failure blocks on any path. If a fast-path PR contains a hardcoded colour, a broken accessible path, or a dropped passthrough, it is still a BLOCK — the triage sets how much you look, never what counts once you see it.
- Everything else above still holds: judge the diff, not the component; no ledger entry means judge on merits; pre-existing findings are never attached to the contributor's PR.
Why not run the whole thing. A rubric that costs an hour on a two-line fix gets skipped, and a review that lists thirty findings the author didn't cause buries the one that matters. The full checklist earns its cost in modes N, O and P — where nobody is waiting on it.
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. (Presence only — category correctness is P11, released surface only.) | pnpm check:changesets |
BLOCK — §8 X20 |
| Q12 | The review records an evidence-based visual obligation: manual frames for changed/uncovered visible states; exact-head visual gate only when named stories cover both visible endpoints; or N/A with non-rendering path proof. Every manual frame has the canonical sensor receipt; before/after match except Build; mutated arms bank an exact diff; expectations are authored before observation rather than copied from it. | visual-gate coverage + Playwright/captureWithSensors() when required |
§5b not measured when the obligation/receipt/arm isolation is unsupported |
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.
Someone asked how good a component is, or you are hardening it. Work every section below, on the whole component rather than a diff.
What this pass owes you:
-
Screenshots. The design section is graded from rendered pixels, not from reading CSS — drive a real browser against Storybook and capture every state the component has, in light and dark. Skipping it is allowed; pretending you did it is not, so an uncaptured section is reported as
not measuredrather than scored. - A score per section, read against its 5/3/1 descriptions, then the weighted total and letter.
- A recorded result. An audit nobody recorded didn't happen — see Recording an audit.
- An issue per blocking finding, linked from the ledger row, so the work is tracked where the next person will look.
Do not hold a shipped component to the promotion paperwork — no spec issue, no arbitration record, no vibe artifact is required of something that already lives in core. That is promotion evidence, and demanding it retroactively grades history instead of the component.
A component moving from lab into core becomes a public, stable commitment, so this is the strictest pass — everything in Grading a whole component, plus:
- Screenshots are mandatory, not optional: every state, light and dark, at least one custom theme, plus a narrow viewport and a touch pointer.
- Every accessibility item verified in the promotion pull request — that is a hard requirement, not a judgement call.
- The lifecycle evidence applies here and only here: the spec issue, the API arbitration record, the hardening pass. Missing evidence is called out loudly but does not block on its own.
- The bar is a B or better, with nothing blocking open.
Lab itself is deliberately loose — it is canary-only and promises no stability, so a new component landing in lab gets the automated checks and whatever the change touches, and everything else is noted as worth an audit before promotion. The full pass is the gate out of lab, not the entry fee into it.
An automated audit takes one component a night from every component-bearing
package registered in scripts/component-packages.cjs. It may prepare a missing
contract as a draft observational worksheet, gives the component the full treatment
with screenshots, fixes objective gaps with a settled answer, then runs a second
audit to prove the fixes worked.
The same pull request may contain that draft worksheet plus missing behavior tests,
stable visual-regression snapshots, consumer-doc drift fixes, and implementation
bugs. This flow is manual-review-only today. Draft spec:AST-029 proposes a
fail-closed auto-merge gate, but it cannot activate until that record becomes
current together with a versioned component template/schema that validates its
evidence matrix. Public API, compatibility, ownership, and subjective design
decisions always remain manual.
It picks from the ledger—never-audited first, then oldest-audited, then lowest-scoring. It records the post-fix score, and the before/after pair goes in the pull request. This does not change grading or promotion modes: those retain their per-BLOCK issue and recording rules.
Anything needing a decision rather than a correction — a naming dispute, a new prop, a visual judgement call — it does not touch; those go to the PR's Needs Review section. Findings it could not fix stay in the ledger row against the component. It files no issues, except for a genuinely system-level problem that no single component's PR could fix.
The ledger is its only record of progress: what has been audited, when, under which version, and what is still open. Full mechanics in Night Watch Component Auditor.
Eleven sections. Each check has an id (A8, T6, P2...) — cite the id when you report a finding, so the reader can look up exactly what you applied.
| Section | What it covers | Weight |
|---|---|---|
| §1 Accessibility | Can everyone actually operate it — mouse, keyboard, screen reader, touch | 16 |
| §2 Theming | Can a theme restyle it; are all values tokens | 14 |
| §3 Public API | Prop names, shapes, and the contract consumers depend on | 14 |
| §4 Behavior | Does every state the API promises actually work | 12 |
| §5 Design | Spacing, radius, type, colour, motion — half from source, half from pixels | 6 + 4 |
| §6 Testing | Do the tests assert the contract, and did the audits run | 8 |
| §7 Code health | Effects, state, observers, reuse over reinvention | 8 |
| §8 Docs | Can someone learn it without reading the source | 8 |
| §9 i18n & RTL | Translated strings, and correctness right-to-left | 5 |
| §10 Responsive & touch | Narrow screens and coarse pointers | 5 |
| §11 Lifecycle | Promotion paperwork — promotions only, never graded | — |
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, and a real path reaches it. A rare trigger, an eslint-disable, or "the happy path works" do not downgrade it — but see A finding needs a reachable victim: no reachable path, no BLOCK. |
| 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.
Migration rules bind released surface only. Q11, T33, P11, X20 and I7 all require a [breaking] changeset and an astryx upgrade codemod when a public surface is removed, renamed or retyped. Every one of them exists to protect installed consumers. Where there are none, the rule does not apply — there is no surface to break, nobody to migrate, and nothing for a codemod to rewrite.
Establish that first, before citing any of them. A surface is released when it is present in the latest published version of its package:
npm view @astryxdesign/core@latest exports --json | grep -i '<Subpath>' # is it published? ls .changeset/ | xargs grep -l '<the feature>' # or still pending?
An entry still sitting in .changeset/ has never shipped. A whole component can be unreleased: Indicator merged 2026年08月11日, core@0.3.0 had gone out on 2026年08月05日 with no ./Indicator subpath, so every export in that layer was unreleased for its first week in main — and an audit recorded a P11 BLOCK against it anyway, for omitting props from a type no consumer could install (#4937, withdrawn). Both the auditor and the fix author cited the rule correctly against its text; neither asked whether the export had ever shipped.
When a migration rule does apply, it grades the diff — record it on the PR, not durably on the component. These are the only rules in this rubric whose subject is a changeset, and a changeset is consumed and deleted at release. A BLOCK derived from one therefore evaporates on its own, with nobody fixing anything, and the next audit sees an unexplained score jump. In modes N and O (grading a component), report a migration failure as a FIX with the PR named, and raise the BLOCK on the pull request in mode R, where it can actually be acted on before merge. L14 already works this way — scoped to a PR, ungraded — and it is the model for the other five.
A finding needs a reachable victim. "Score the failure, not its likelihood" is right, and it is about users: a state only 2% of people hit is still a real state, and a rare trigger never downgrades a defect. It does not license grading a defect that nothing can currently reach. Before recording one, name who suffers and how they get there.
The test is whether a path exists, not whether it is common:
| Verdict | |
|---|---|
| A user reaches it in an uncommon state (slow network, screen reader, 320px, RTL) | Score it. Rarity is irrelevant — the state is real and someone is in it. |
| A consumer reaches it by writing ordinary code (a spread, a documented prop) | Score it, even with no call site today — the API invites it. |
| Reaching it needs code the type rejects, that no call site in the repo contains, and that no doc suggests | NIT at most, and usually nothing. Say what would make it reachable. |
The distinction matters because the second and third rows look identical in a scorecard — both are "a consumer could...". The difference is whether the API permits it. tabIndex on an indicator is the worked example: axe genuinely reports aria-hidden-focus for a focusable node in a hidden subtree, measured 0 → 1 violation, which reads like a solid finding. But no call site in core passes it, IndicatorProps omits it so a literal is a compile error, and no doc mentions it — the only route left is spreading a hostile object at a component whose type forbids it. That is a NIT, and it was recorded as a FIX, which produced a PR carrying a new internal module to strip a prop nobody sets (#4947, slimmed to the one-line type omission).
Corollary: prefer the type over a runtime guard, and say so in the finding. When a defect is already unrepresentable in the API, the remaining runtime hole is usually not worth a mechanism. Adding one buys a rare case and costs a module, an indirection, and a second thing to keep in sync — and it is inconsistent unless every sibling prop gets the same treatment. A finding that can be closed by a type change should say that, rather than leaving the fix shape to whoever picks it up.
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: a promotion cannot pass with §5b unmeasured, and any audit that skips it must say so in the headline.
Current in-repo accessibility records govern policy. This rubric turns those requirements into gradeable checks and uses the wiki Accessibility Checklist as a practical review worksheet. pr-a11y (axe) catches the static DOM subset only. Keyboard, focus, and announcement behavior still need the ordinary reviewer/test evidence below. When the claimed outcome depends on AT-specific focus exposure or order, spoken/braille output, virtual-cursor entry, mode change, or a known AT/browser divergence, AST-009 assistive-technology verification adds the required real-AT evidence and stable-release handling.
| # | 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 when reviewing or promoting, or named in the component's docs when grading or on a nightly pass, 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 auto-detect direction from the container. |
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 meet WCAG 2.5.8 (AA): a ×ばつ24 CSS-px square must fit inside the target — but under 24px is not an automatic BLOCK; rule out the five exceptions first. Spacing: an undersized target passes if a 24px-diameter circle centered on its bounding box clears every neighboring target and every other undersized target's circle — calibration: two ×ばつ20 targets with a 4px gap pass (centers 24px apart, circles tangent); the same targets touching fail; equal targets of side s need 24 − s px of clearance. Inline: the target is in a sentence or sized by surrounding line-height (prose links). Equivalent: the same function is reachable through a ≥24px control on the same page. User-agent: native rendering the author has not resized (native date picker, scrollbars, number spin buttons). Essential: presentation is required or legally mandated (dense data-viz points, map pins). The rule does not apply to a target while it is obscured by interaction-triggered content (open dropdown list, modal, cookie banner); a spatially-selected control (Slider thumb, color-picker gradient, cursor-positioning area) counts as one target, not many undersized ones. Pointer modality does not raise the AA minimum: coarse pointers follow the same 2.5.8 rule, not the ×ばつ44 requirement from 2.5.5 (AAA). 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 — grep the shape, rule out the five exceptions (measure neighbor spacing when undersized), then measure the bounding box in-browser at default zoom; if pointer-dependent styles exist, test each affected pointer mode | BLOCK <24px with no applicable exception | Accessibility Checklist §Target size | |
| A11 |
isDisabled maps to native disabled except the documented focusable disabledMessage path. Busy is a different state: editing controls remain focusable, and action controls expose aria-busy, remain focusable, and block duplicate activation through aria-disabled plus guarded pointer and keyboard handlers — not native disabled. Only an explicit interruptible API such as isInterruptible may keep activation available. Loading must not inherit the ordinary disabled opacity when that would obscure a meaningful spinner or progress indicator. |
semi — inspect the rendered disabled/ARIA state and activation guards; test explicit disabled is native, default loading retains focus but cannot activate, interruptible loading can activate, and the meaningful loading indicator retains its required contrast | BLOCK | Accessibility Checklist §State and §Contrast and themes; 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 | |
| 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 |
| A20 | No disabled element paints a hover state. :hover keeps matching a disabled control — browsers suppress its events, not its styling — so a hover treatment written for the enabled element is shown under the pointer anyway, promising a click the control will not honour. Self-hover selectors carry the guard :hover:where(:not(:disabled,[aria-disabled="true"])); a suppression style applied when disabled does NOT count, because StyleX overrides the default condition only and the :hover class survives the merge. The cursor is A21. |
auto — @astryx/no-hover-on-disabled (source) + .github/scripts/disabled-hover-audit.js (Chromium, every story, zero tolerance) |
FIX | PR #5247 | |
| A21 | A disabled element answers the pointer with default — never an interactive cursor. The cursor is the only affordance a pointer user gets before they commit, so pointer on a disabled control promises a click it will not honour. Every cursor a component writes carries ':is(:disabled,[aria-disabled="true"])': 'default' — including a flat cursor inside a disabled style, because StyleX merges one PROPERTY at a time and a later declaration replaces the earlier one's conditions along with its value (that is how a guarded base loses to a disabled style that sets cursor at all). default rather than a distinct disabled cursor: an element sealed behind pointer-events: none is never hit-tested and shows whatever its ancestor shows, so a stronger cursor is only paintable on part of the library — and such an element is N/A, not a pass: the control never gets to answer, so say so and score the rest. |
auto — @astryx/disabled-cursor (source) + .github/scripts/disabled-cursor-audit.js (Chromium hit-test, every story, zero tolerance) |
FIX | PRs #5323, #5335 |
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; loading actions retain focus and block duplicate activation unless explicitly interruptible while busy editing controls remain focusable; every action works with a coarse pointer; targets meet WCAG 2.5.8; reduced-motion and forced-colors honored; a disabled control paints nothing under the pointer and answers it with default; 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 | Component styling consumes semantic color tokens, optionally derived with color-mix()/calc() on vars. Never write light-dark() in component source, even when both branches are tokens — light/dark resolution belongs to the theme/token layer. No raw hex/rgb/hsl/oklch anywhere in shipped component styling, including behind a const, inside another CSS function, or under an eslint-disable. "No suitable token exists" is not an exception. See the exceptions note below before flagging theme/demo code.
|
auto — @astryx/no-light-dark-outside-theme and @astryx/no-raw-color (errors in core; the light-dark rule warns in lab) |
BLOCK in component styling; clean in the documented theme/demo/test contexts |
@astryx/no-light-dark-outside-theme; copilot-instructions |
|
| T2 | No themeable surface is removed: a token, a themeProps target, or a MediaTheme-flowed override replaced by a fixed value — including a value pinned on xstyle/style, which sits at the top of the cascade. Ask "is this still themeable?" before "is this lint-clean?". |
manual | BLOCK | copilot-instructions; Theming Infrastructure §Cascade Model | |
| T2b |
Sanctioned non-token values. 0, 'none', 'transparent', 'inherit', 'currentColor'; layout values that are not semantically spacing (width: '100%', flex: 1); and large layout constraints — maxHeight, maxWidth, minWidth — which are component-specific design decisions rather than theme values (a dropdown's maxHeight: '300px', an empty state's maxWidth: '360px'). Flag a constraint only if the value looks wrong, never because it is hardcoded. |
— | not a finding | Theming Infrastructure; the T1 exceptions note below | |
| 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 | Theming Infrastructure (#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 | Theming Infrastructure (#808) | |
| T7 |
themeProps sits on the element that actually paints — not the root by default, and never positioning, measurement, overflow, event, animation, portal, or provider scaffolding. Enumerate a small visual anatomy before adding targets: one root surface when it paints, plus only genuinely distinct named parts. State rides on those targets, not new targets. Important visual properties and internal padding must remain reachable from that anatomy; do not hide them on an unthemeable wrapper. Margins, positioning, display, clipping, stacking, and sibling relationships stay component/layout-owned. When paint is split across nodes that should be one surface, restructure instead of exposing the wrapper. |
manual | BLOCK (target on non-painting/layout machinery) · FIX (incomplete or unnecessarily split visual surface) | Theming Infrastructure principles 1, 4–8 (#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. Judge the main render path: a secondary path that composes into a host owning the styling is out of scope — see the note below. |
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 on the main render path | 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 | API Conventions §Composed Component Prop Forwarding | |
| 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 |
|
| T18 | Theme-scoped icons via defineTheme({icons}), not registerIcons() (which mutates a module-level global and warnOnces). |
semi — grep | FIX | globalIconRegistry.tsx |
|
| T19 | A visual variation prop resolves through an extensible *VariantMap interface declared in the component's public subpath barrel (src/Button/index.ts), with the prop type derived from it (keyof ButtonVariantMap) — not a closed union and not a Record<KnownValue, Style>. Unknown theme values must fall through to base styles, and a type-level regression must type-check consumer JSX through the public subpath under moduleResolution: "bundler". There is no variants key on defineTheme; the map interface is the whole mechanism. |
semi — read the barrel; the type-level regression test | BLOCK for a closed map on a themeable axis | Theming Infrastructure §Custom variants (#4646, #759) | |
| T20 | Public props extend BaseProps, so every component takes xstyle/className/style; never re-declared by hand. |
auto — @astryx/require-base-props (error strict) |
BLOCK | rule source | |
| T21 | Font sizes use semantic typeScaleVars (label / body / supporting / large), not raw textSizeVars. The migration is essentially done (90 files vs 5) — a new textSizeVars size is a regression. Pick the semantic token by what the text is, not by mechanically matching its raw size: --font-size-base → --text-label-size for interactive controls (buttons, tabs, selectors, menu items, form labels, nav items) or --text-body-size for content (inputs, links, descriptions, table cells); --font-size-sm and --font-size-2xs → --text-supporting-size (descriptions, breadcrumbs, captions, placeholders, badges, helper text); --font-size-lg → --text-large-size (empty-state titles, nav headings). |
semi — grep | FIX | Design Conventions §Typography (#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 | |
| T23 | Component CSS vars follow --[_]<component>[-<part>]-<property>. Private derivations use the --_ prefix, private: true, and derivedVarRegistry.ts. A public var must name a stable component-owned visual on a themeable painting element, keep its meaning across DOM refactors, and be documented and tested through defineTheme; add one only when a semantic token, ordinary property, inheritance, or a variant/state cannot expose the value reliably. |
semi — derivedVarRegistry.test.ts automates the private half; public-var necessity is manual |
FIX (BLOCK if a theme loses an existing reachable visual) | Theming Infrastructure §Component CSS variables | |
| T24 | Container padding uses the four per-edge variables — --container-padding-inline-start, --container-padding-inline-end, --container-padding-block-start, --container-padding-block-end — with a 0px fallback. There is no isotropic or axis-only --container-padding* variable. Theme packages never touch internal --layout-padding-* / --container-padding-* names. |
semi — grep, including under packages/themes/
|
FIX (BLOCK if a removed var name is used — it silently resolves to 0) | Container Padding System (#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 | A composition layer may carry its own target — alert-dialog over dialog, more-menu over dropdown-menu (#672) — when it names a distinct product concept a theme should be able to style apart from the thing it composes, and it rides the element that paints (principle 1). What T28 forbids is a target on an outer wrapper node that contains several independently themed components, where a rule written for the wrapper cascades into the inner targets and fights them (#749, Tokenizer). Co-locating the layer's target on the same painting node as the inner component's target is the sanctioned shape, not a conflict: both selectors carry the same specificity, so the theme's own source order settles them. |
manual | FIX for a target on a multi-component wrapper node | Theming Infrastructure (#749, #672) | |
| T29 | Reuse an existing component when the sub-part promises that component's behavioral and visual contract, not merely the same HTML tag: Button for button-like actions, Divider for standalone separators, useLinkComponent() for navigation, VisuallyHidden for SR-only text. Compare behavior, appearance, accessibility, theming, and composition. A native control is legitimate when it intentionally implements a different contract (tab, option, drag handle, invisible interaction layer); name that contract and still meet its semantics. |
semi — @astryx/no-hardcoded-anchor covers <a>; native controls are search leads, then manual judgment |
BLOCK for a private clone of an existing contract | API Conventions §Use the System; Architecture Cheat Sheet §Global guardrails | |
| 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. Released surface only: a target introduced and removed before any release breaks nobody (see §Scoring). |
auto — pnpm check:sync, pnpm check:changesets, the two theming guards |
BLOCK | copilot-instructions; PR #4712 §Migration | |
| T34 | Indicators (checkbox box, radio circle, selection mark) follow the indicator convention: the owner keeps the input, role, accessible name, and keyboard behavior; the indicator is aria-hidden, owns no semantics, renders children when present, and tokenizes every color, radius, and border-width (a circle is --radius-full, not 50% — equivalent only while the box is square). The host renders the indicator unconditionally and passes state — an indicator may legitimately draw nothing in a given state (the default selection mark draws nothing when unchosen, so a listbox shows no empty box per row), and only an unconditional host lets a replacement that does draw in that state — a radio's empty circle — appear at all. The focus ring is the owner's to guarantee: a themed replacement can silently omit one, and the control's real input is opacity: 0, so the owner paints the ring onto the indicator's own element (useIndicatorFocusRing) where outline picks up its border-radius. Replacement is by name via defineTheme({indicators}). |
manual | BLOCK | #4712, merged 2026年08月11日 |
T1 exceptions — where a colour literal is legitimate. The rule protects themeability of shipped component styling. A literal that cannot affect a consumer's theme is not the defect the rule was written for; flagging it is a false positive, and one that trains people to ignore the rule.
Clean — not a finding:
- Theming demonstrations. A story, sandbox page, docsite example or test that shows what a custom theme looks like must use literal colours — a theme author writes literals, and that is precisely the thing being demonstrated.
defineTheme({tokens: {'--color-accent': '#7c3aed'}})in a theming story is the feature working, not a violation. Same forMediaThemedemos and swizzle examples.- Theme packages (
packages/themes/*) — defining token values is their entire job.- The token layer itself (
theme/tokens.stylex.ts), which defines the primitives everything else references.- On-media absolutes used as documented (
--color-on-dark/--color-on-light).FIX, not BLOCK: test fixtures and deliberate
*.test-violations.*files — but they must not ship in a published entry point.Still BLOCK, wherever it appears: a literal in a component's own styling; a literal presented as the recommended way to style a component (an example that reaches for
xstyle={{color: '#7c3aed'}}instead of a token teaches the wrong thing — that is a docs-quality finding under §8, and a BLOCK if it is the primary example); and any literal that a theme would be expected to reach.The test to apply: would a theme author expect to be able to change this colour? If yes, it must be a token. If the literal is the theme author's input, it is correct as written.
T14 is scoped to the main render path. The rule protects the surfaces a consumer actually composes with — the default rendering, where an extra node takes the component out of its parent's flex/grid relationship (bug #4752). A component may also have secondary render paths that exist to be composed into a host: SideNav's
drawer-contentmode renders its items inside a drawer thatMobileNavowns and styles. Judging those paths against T14 is a category error — the host owns the layout and the styling there, and the path exists precisely to hand its content over.On a secondary path, a wrapper is allowed when it carries something the fragment cannot — consumer
xstyle/className/style/...rest, aref, or semantics. Say which, and say why the host cannot carry it instead.What still applies everywhere: the wrapper may not silently change the host's layout contract (if the host lays its children out directly, a wrapper that breaks that relationship is a bug wherever it appears), and it must not duplicate a theme target the host already carries — a second name for a concept the host already exposes. A distinct target for the layer's own concept is allowed; T28 says where the line is.
This scoping is specific to rules about which element owns the styling. It does not generalise. Correctness rules — a11y, i18n, tokens, passthrough — hold on every path a consumer can reach, and so does D12: a layout animation costs the same reflow wherever it runs.
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:
The conventions themselves are not restated here — API Conventions owns them, and an audit finding must cite its section. The axes the items below grade: component and file naming and the file header; hook, context and type naming; boolean is/has and callback on{Verb}{Scope?}; onOpenChange for layer components only; enum casing and the shared vocabulary; start/end for direction; html-prefixed native collisions; required vs optional; slots as passthrough and behaviors as hooks; BaseProps plus ...rest capture and per-category precedence; controlled inputs and default-prefixed uncontrolled state; and the async transition pair (clickAction + useTransition, changeAction + useOptimistic), where busy controls retain focus and busy actions guard duplicate activation unless explicitly interruptible.
| # | 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. Released surface only — confirm the export is in the latest published version before citing this; see Migration rules bind released surface only. Graded on a PR (mode R); on a component (modes N/O) it is a FIX naming the PR. |
semi — pnpm check:changesets + a codemod directory check + the published-exports check |
BLOCK (R) · FIX (N/O) | packages.instructions Step 0; #4937 withdrawn | |
| 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 | |
| P17 |
index.ts exports the component plus its props/variant/status types; reachable from the package src/index.ts; any public hook is also exported from src/hooks/index.ts and src/index.ts; the generated "exports" map is current and was never hand-edited. |
auto — pnpm sync:exports:check, node scripts/verify-exports.mjs
|
FIX (BLOCK for promotion) | API Conventions §Export Conventions | |
| 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 | API Conventions §Prop Surface | |
| 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 | A prop that reuses a native HTML attribute name with a different meaning keeps the semantic name and Omits the attribute from what it extends. The html prefix is only for props that are the native attribute, passed straight through (htmlName, htmlFor) — do not recommend renaming a semantic prop to html*. Read BaseProps before flagging: it already omits title, contentEditable, autoFocus, defaultValue and a long tail of others from React.HTMLAttributes, so a component's own title prop is not a collision. |
auto — small grep set, cross-checked against BaseProps.ts
|
NIT | API Conventions §HTML Attribute Collisions | |
| P34 | Every exported prop and component has JSDoc saying what it does, with @default on optional props and @example in a bare ``` fence — never language-tagged (a tagged fence silently breaks Storybook autodocs). |
auto — @astryx/docblock-example-format
|
NIT (the tagged fence is FIX) | API Conventions §JSDoc; CLAUDE.md
|
|
| P35 | Deprecated props/values carry @deprecated naming the replacement, stay functional for at least one release, and are dropped from docs. |
semi — docPropLiterals.test.ts handles the docs half |
NIT to add; BLOCK to remove (see P11) |
docPropLiterals.test.ts; codemod transforms |
|
| P36 | The file header (@file, @input, @output, @position, SYNC:) is present and its SYNC: targets exist. |
auto — pnpm check:sync
|
NIT | API Conventions §File Header | |
| P37 | A component that accepts hasAutoFocus and sets autoFocus={hasAutoFocus} on a native element must also set data-autofocus={hasAutoFocus || undefined} on that same element — React's autoFocus fires before a <dialog> is visible, so Dialog re-focuses via the data attribute after showModal(). Without it the prop silently does nothing inside a dialog. |
semi — find autoFocus={hasAutoFocus} with no sibling data-autofocus
|
FIX (BLOCK inside a layer component, where the prop is inert) | API Conventions §Prop Surface (#1044) | |
| P38 | Composition contract: no escape-hatch prop without a demonstrated use case (trigger="hover" on Popover when HoverCard exists — a second way to do one thing is permanent surface, so name the callsite that needs it), and the prop interface must not expose raw-element-only APIs where a primitive exists, which pins the consumer to the raw element and makes the later migration breaking. |
manual | FIX (BLOCK when it forecloses moving to the primitive) | API Conventions §Composition vs Config | |
| P39 | A production data-* attribute has a named consumer outside the component and a reason props, context, refs, classes/pseudo-classes, or ARIA cannot serve it. Never use attributes plus closest()/querySelector()/dataset for component-to-component ownership, state, identity, or configuration; test selectors alone do not justify new production surface. themeProps reflection and documented CSS, browser, analytics, or instrumentation contracts are legitimate. |
semi — grep data-, dataset, getAttribute, closest, and querySelector, then identify producer and consumer; no lint rule |
BLOCK for component communication or reconstructed React ownership; FIX for undocumented/unused surface | Architecture Cheat Sheet §Global guardrails | |
| P40 | Implementation-component substitution is not a default extension surface. Use the Icon and Indicator maps for those system-owned visuals, a named icon/content slot or item renderer for consumer-owned content, and LinkProvider for the router adapter it owns. A generic components={{...}}, asChild, or whole-surface renderer needs explicit architecture approval and must preserve the behavioral owner. Data/AST renderers such as Markdown and narrow renderItem content callbacks are content seams, not component swaps. |
manual — inspect new component-typed props and renderer/registry APIs; no lint rule | FIX; BLOCK when the swap bypasses semantics, focus, or another shared owner | Architecture Cheat Sheet §Semantic icons / indicator maps | |
| P41 | A size-bearing component resolves explicit prop → inherited SizeContext → component default through useSize; a grouping/container owner provides its resolved size to eligible descendants through SizeProvider. Reuse `sm |
md | lg` and the shared size tokens unless the component documents a genuinely different axis. | semi — grep size defaults and useSize/SizeProvider, then run SizeContext precedence tests |
FIX; BLOCK when an explicit public prop is ignored |
| P42 | A status-bearing API joins the shared protocol: general feedback uses info | success | warning | error; input validation uses success | warning | error; busy/loading stays a separate operational state. Reuse Banner/Field anatomy and the matching semantic surface/text/border/icon token family. Score color-only, ARIA, busy-focus, and token defects at A3/A11/A12/D10/T1; use P42 only for a private vocabulary or failure to join the shared status API, preserving one-defect-one-score. |
manual (grep-assisted) | BLOCK for a parallel semantic vocabulary; FIX for anatomy/token-family drift already semantically exposed | API Conventions §Status / Validation; Architecture Cheat Sheet §Status / feedback |
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 | — | |
| B2 |
Hover gives visual feedback, guarded by @media (hover: hover). |
semi — grep + render | BLOCK (unguarded :hover = sticky hover on touch) |
PR #299 | |
| B3 |
Focus ring visible via keyboard (:focus-visible), in every theme. |
manual | BLOCK | — | |
| B4 | Active/pressed feedback on click and tap. | manual | FIX | — | |
| 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 Lifecycle §When Findings Route Back | |
| B6 |
Loading (if isLoading exists): spinner or skeleton, interaction blocked, dimensions stable. |
manual | FIX (BLOCK if it disables and drops focus) | — | |
| 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 | 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 §Consistent State Representations | |
| B9 | Empty renders gracefully — no blank hole, no layout collapse. | manual | FIX | — | |
| B10 | Overflow — long text truncates or wraps with no layout breakage; verify with a ×ばつ text-expansion pseudo-locale or a long-string story. | manual | FIX | i18n text 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 Lifecycle |
| 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 Audit Rubric §4 |
| B13 | Composition contexts behave: inside Dialog, Table, Card, AppShell, and within narrow/wide/constrained parents. | manual — stories | FIX | PO | — |
| 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 — measure the stacking order against Design Conventions §Predictable stacking (Elevation) | BLOCK — unreachable content | Design Conventions §Predictable stacking (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 a promotion cannot pass without it.
Check tokens, not numbers. Spacing, radius, size and type scales are all theme dials —
defineThemederives them from a base and a multiplier, and a theme may legitimately ship a different resolved scale. Every check below asks "is this value a role token, and is the relationship right in the resolved scale?", never "is this value 8px?". A rendered number differing from the neutral default is not a finding; a raw literal, a token used for the wrong role, or a broken relationship is. The only numbers stated as absolutes here are external standards a theme may not tune away: WCAG contrast ratios, the ×ばつ24 target-size minimum, and the 12px body-text legibility floor.
From Design Conventions. That page marks each convention 🟢 objective (auditable) / 🟡 assisted / 🔴 judgment. The nightly auditor runs the 🟢 rows automatically and captures the full screenshot set for §5b. It evaluates the 🟡/🔴 rows as review recommendations, never as settled product authority: any such finding stays in Needs Review, is not auto-fixed, and keeps the PR manual.
§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. Every frame carries the canonical screenshot sensor receipt proving the build/worktree SHA, exact story id, rendered theme and color mode, computed direction, viewport/DPR/media, semantic state, visible subject geometry, settled fonts/animation state, and absence of page/Storybook errors. Use
captureWithSensors()from the review probe kit. Before/after receipts must match on every sensor except Build; absent or failed receipts make §5bnot measured.getComputedStylereadings (exact hex, px, line-height, radius) are supporting evidence 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 when reviewing. A promotion 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 archetype first, then the mechanism — not the pixels. A representation belongs to the kind of thing it was written for:
scale(0.98)on press is a button depressing, and applying it to a nav row or a table row is a category error. Before scoring a state against a representation, ask whether the component is that kind of control; if it is not, the representation does not apply and its absence is not a finding.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 live in Design Conventions §Consistent State Representations — that page owns the catalogue, its reference images, and the token description for each. Do not restate it here; cite the row you matched. What this rubric adds is the verification: for each captured state, name the row, then confirm from the screenshot (with
getComputedStyleas corroboration) that the row's token signature actually resolved —
Row you matched Signature that must be visibly present Hovered — Overlay style an alpha tint over the component's own base, not an opaque fill Hovered / Focused — Ring style (fields) an inset ring, not elevation Pressed the pressed overlay, not a color swap. scale(0.98)is the button treatment — a self-contained control that depresses. Do not require it of other archetypes: a nav row, list item, table row or menu item takes the overlay alone, and shrinking a full-width row on press is wrong, not missing. Match the archetype, then the representation.Focused — Outline style an offset outline, visible in every theme Selected (any of the five styles) the matching ARIA state ( aria-checked/-selected/-current/-pressed) backs the visualDragging — Ghost preview 50% opacity only while actively dragging; no raised-card shadow Drop target — Insertion line surrounding items stationary until release Disabled native disabled, not a visual aloneLoading / Processing aria-busy; focus is retained; actions usearia-disabledplus activation guards unless explicitly interruptible; dimensions stay stableStatus — Muted or Inverted an icon is present — never color alone Agent states (thinking, reasoning, streaming, tool-executing, awaiting input, syncing, inspecting, rendering) have no approved visual yet. They get no automatic failure: flag the state as an open design question, say which User/System representation it borrows from, and route it to the spec loop.
How to score the matrix. Every captured state matched to a named representation whose token signature is visibly present, and consistent with the component's family ⇒ D13 passes. A state that implements an approved representation badly (opaque hover fill instead of an alpha tint, a raised shadow on a drag ghost, dimensions jumping on load) is a BLOCK via the relevant D-row. A state that invents a new representation where an approved one fits is a FIX — and if it is genuinely a new archetype, it routes to the spec loop, not to the PR. An agent state gets no automatic failure: flag it as an open design question and say which User/System representation it borrows from.
Contrast is measured from rendered pairs, not from isolated tokens. For every applicable screenshot state, record each meaningful foreground, its actual rendered backdrop, the resolved colors, the ratio, the threshold, and pass/fail. Alpha foregrounds are composited over the backdrop before measurement; if the backdrop is translucent, resolve the complete stack. The browser-rendered result is final.
Decide applicability before doing the arithmetic. For every painted part, run the removal test: if removing that part changes whether a user can identify content, a control, its state, or meaningful graphical information, it is meaningful and belongs in the required matrix; if the same information remains clear through text, spacing, structure, or another qualifying cue, it may be decorative or redundant. Still record decorative relationships when useful for visual review, but label the exception and do not turn an informational ratio into a WCAG failure. Re-evaluate the decision for each state and composition—a border can be decorative on a static Card and required on an otherwise indistinguishable interactive target.
Verify the artifact the consumer actually loaded. Source-theme math is predictive evidence, not a rendered measurement. Before recording a ratio or claiming a fix is visible: identify the exact import path used by the preview or consumer; rebuild any generated theme CSS, package
dist/, registry, or other compiled artifact on that path; restart or invalidate the consuming dev server when its module graph may cache the old artifact; then read the foreground, background, overlays, and inherited custom properties from the actual rendered element in every audited state. Calculate contrast from those final computed and composited colors. A successful source test, a generated CSS file on disk, or an HTTP 200 response does not prove the open page loaded that build. If the rendered colors disagree with the source calculation, the rendered result wins and the audit remains failing ornot measureduntil the build/cascade discrepancy is explained.Thresholds — WCAG 2.2 AA:
- Normal text and text in images: ≥4.5:1 (1.4.3).
- Large text: ≥3:1 — at least 24 CSS px regular or 18.66 CSS px bold.
- Meaningful non-text UI and graphics: ≥3:1 against adjacent colors (1.4.11). This includes control boundaries needed to identify the control, meaningful icons, state indicators, and focus indicators.
Pairs and states: measure labels, values, supporting and placeholder text, icons, control boundaries, selected/current indicators, status/error treatment, and focus indicators in every applicable state: rest, hover, focus-visible, active/pressed, selected/current, loading, read-only, and each status value. Run the matrix in light and dark and the required non-default theme. A theme-elevation review runs the full supported component/state matrix for that theme; sampling one component or inheriting a pass from the neutral theme is insufficient.
Exceptions: record the exact exception beside the pair. Incidental text or graphics, decorative content, logotypes, and genuinely inactive controls may be exempt under WCAG. Decorative/redundant Astryx tokens such as a hairline separator, skeleton fill, or track may be below 3:1 only when another qualifying visual carries the boundary or state. "Secondary," "muted," "subtle," or "the token already existed" are not exceptions.
Start from the component's contrast profile. The component accessibility section must inventory its rendered parts, actual backdrop relationships, variants/states, thresholds, and recorded exceptions as defined by Accessibility Checklist §Component contrast profiles. Expand every applicable profile row into this audit's rendered pair matrix. Do not substitute one generic "component contrast" number for the individual relationships, and do not inherit a pass merely because another component uses the same tokens. If the profile is missing or incomplete, derive the matrix from the rendered anatomy anyway and record the documentation gap; missing documentation never makes a pair not applicable.
Keep shared relationships visibly shared. When components intentionally use the same semantic combination—Badge and Token pills, Banner and FieldStatus status surfaces, or the shared bordered-input foundation—the audit records both the shared token relationship and each component's independently rendered result. This catches both kinds of drift: a component leaving the family, and a shared token failing on one component's different backdrop or state.
Minimum per-component evidence: name the part; state whether it is meaningful, redundant/decorative, or conditionally meaningful; name foreground and complete rendered backdrop; enumerate variants/states and parent surfaces; record the threshold/exception; and include nested or consumer-supplied content the API explicitly supports. Disabled, busy, and read-only are separate applicability decisions—never collapse them into one "inactive" row.
Button/composite-control decisions: visible normal-size labels are 4.5:1 in rest, hover, and pressed without rounding; large text uses 3:1 only at the WCAG size threshold. Meaningful icons—including icon-only controls—are 3:1, while label-redundant icons are decorative; removal changing the action or state is the test. A boundary is measured only when needed to identify the control because sufficiently contrasting text or an icon can carry that identification. Focus remains a separate 3:1 state check; the two-pixel perimeter-area rule is WCAG 2.2 AAA, not AA. For loading, measure the meaningful spinner arc against the button background at 3:1; a redundant track may be decorative, but the active indicator does not inherit disabled opacity merely because activation is temporarily blocked. For end content, measure visible badge/count text against its composited badge surface at 4.5:1; badge-to-button separation is decorative when the text carries the meaning, while an unlabeled dot or color-only mark is meaningful non-text content. Custom
ReactNodecontent is judged from the final rendered result, not exempted by ownership. The inactive-control exception applies to genuine disabled treatment, not to progress information communicating an operation still underway.Input/composer decisions:
TextInput,ChatComposer, and shared bordered-input components use one baseline matrix. Measure visible field/composer text, placeholder, description, counter, and status text against their actual surfaces; meaningful icons and nested clear/send/attachment controls against their own surfaces; any boundary needed to identify the editable region or state against every supported parent; the keyboard focus-visible cue against the surface on which it is visibly drawn; and warning/error content against the fully composited semantic surface. The status surface itself may be decorative when explicit message text carries the status. Disabled is exempt, while read-only and active loading remain applicable. Component-specific actions extend this matrix rather than inheriting a generic input pass.Progress decisions: classify each ProgressBar presentation before scoring it. A standalone bar has no nearby visible text that communicates the current value or an equivalent progress state; a name-only label such as "Uploading" is not equivalent. Its completed and remaining portions therefore need a 3:1 graphical distinction and its total extent must be unambiguous. Astryx may supply that extent with a contrasting track, endpoint, or another tested cue; the endpoint technique is an Astryx design decision, not a WCAG-prescribed pattern, and is sufficient only when the rendered fill and endpoint make current and total positions clear. A supplemental bar has nearby visible text carrying the current value or an equivalent progress state; measure the text, and allow the graphical treatment to be supplemental or decorative when removing it does not change understanding. In determinate mode, measure fill against track when the visual proportion carries progress. In indeterminate mode, measure the moving segment against its rendered backdrop only when it is the visible loading cue; an equivalent visible loading message may make the animation and static track redundant or decorative. Meaningful target marks are measured against every placement they can overlap. Semantic fills may reuse Badge color families, but status cannot be communicated by color alone. The inactive-control exception applies only to a genuinely inactive UI control. A canceled progress graphic is not automatically exempt, and an active indeterminate operation is not inactive merely because duplicate activation is blocked.
Card/container decisions: use this table to determine whether a painted Card surface is part of the required contrast matrix.
Rendering Contrast treatment Static Card whose grouping remains clear from content, spacing, and headings Background, border, and elevation are decorative; measure the content against the Card surface, not the Card against the page. Color variant with a visible category/status label Surface-to-parent separation is decorative; measure the label and every meaningful icon against the colored surface. Color is the only cue whose removal changes understanding First fail SC 1.4.1 and add a non-color identifying cue. Then reassess the surface: if the new cue preserves meaning without the tint, the surface is decorative; otherwise measure the remaining required graphical relationship at 3:1. ClickableCard Measure its boundary at 3:1 only when that boundary is required to identify the whole interactive target; always measure focus and meaningful content separately. SelectableCard The selection ring or equivalent visible state indicator is meaningful and needs a perceptible 3:1 relationship with the adjacent surface it relies on. For an indicator split across surfaces, measure the portion that communicates selection rather than automatically requiring every segment to contrast with both; selection must also be programmatically exposed. Disabled ClickableCard or SelectableCard The inactive-control exception applies; do not reuse it for busy, selected, or read-only states. Ownership and scoring: for a component audit, a component-specific wrong-token choice is D7; a token-layer failure remains
known systemic — #4652and is not scored against the component while that carve-out is active. For a theme-elevation review, the theme is the owning layer: every failing pair blocks elevation until fixed or supported by a recorded WCAG exception. Do not add an axe baseline entry as evidence of acceptance.
| # | Check | How | Sev | Mode | Source |
|---|---|---|---|---|---|
| D1 |
On-scale spacing — every padding/margin/gap comes from spacingVars, on the spacing scale (including its documented half-steps for optical work). Never a raw value. The scale's step is a theme dial; check membership of the resolved scale, not a pixel count.
|
auto/semi — run by the nightly pass | FIX | Design Conventions §Spacing | |
| D2 | No inverted nesting — a child's gap is never wider than its parent's; gaps step up monotonically with grouping order (label→input < fields < groups < sections). | semi | FIX | Design Conventions §Spacing | |
| D3 | Not monotonous — spacing is varied and intentional; ≤3 unique values across a component flattens relationships. Count unique spacing values per rendered element tree, not per file — a five-file compound component is one count. | manual (🟡) | NIT | POR | Design Conventions §Spacing |
| D4 |
Concentric radius — every radius is a role token (--radius-none / -inner / -element / -container / -page / -chat / -full), never a literal; and every nested pair satisfies r_inner ≈ r_outer − gap in the resolved scale of the theme under test. defineTheme derives the scale as base ×ばつ step ×ばつ multiplier and themes override it, so a rendered value differing from the default scale is not a finding — a raw radius, a role token used for the wrong role, or a non-concentric nested pair is. Sharpness/roundness stays one theme dial, not per-element overrides. |
semi | FIX |
Design Conventions §Radius; theme/defineTheme.ts
|
|
| D5 |
On-scale heights — fixed-height controls take their height from --size-element-{sm,md,lg}, never a literal; variable-height elements use the density (padding) system. Size and density are tuned together so a fixed-height control and a variable-height one share a baseline in the same row. Compare against the resolved token values for the theme under test.
|
semi | FIX | Design Conventions §Size · Density | |
| D6 |
Type hierarchy — sizes come from typeScaleVars role tokens, never raw values. The relationships are what to check, and they hold whatever the theme's base and ratio: adjacent steps separate by at least the scale's ratio (×ばつ — steps that blur together fail), multi-line body has line-height ≥1.3 (target 1.5), measure stays ≤75ch, letter-spacing ≤0.05em on body, no all-caps or justified body, and rendered body text never falls below the legibility floor of 12px. The px floor is a legibility limit, not a token — the one number here a theme may not tune away.
|
semi — run by the nightly pass | FIX | Design Conventions §Typography | |
| D7 | Contrast — complete the contrast pair matrix above. Every required rendered pair passes WCAG 2.2 AA in every required mode, theme, and state, or carries a valid recorded exception. A failure caused by the token pair itself is not scored against a component while the system-wide fix is open; a theme under elevation owns its token failures and does not receive that carve-out. | semi — measure rendered pairs; contrast.ts covers generated tokens only, not component/state composites |
BLOCK (component's own token choice or any unexcepted failure in a theme-elevation review) · not scored against a component when token-layer and tracked in #4652 | Accessibility Checklist §Contrast and themes; 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. Applies on every render path. A disclosure animating open to an unknown height is the one sanctioned exception; see the note below. |
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 §Consistent 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 §Consistent State Representations (Reorder contract) | |
| 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 | Design Conventions; Component Hardening Protocol §The Hardening 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 |
What D12 is guarding against.
transformandopacityare composited — the browser hands them to the GPU and skips layout and paint entirely. Everything else (width,height,margin,top,grid-template-rows) makes the browser recompute layout on every frame, for the animated element and usually its siblings. The rule's normal case is a free win: someone animatesmargin-leftwheretransform: translateX()would look identical and cost nothing. The finding is "you paid layout cost for an animation a transform could have done."The exception: animating open to an unknown height. There is no transform that expresses "grow from nothing to however tall the content happens to be."
scaleYdistorts the content and leaves siblings unmoved; opacity alone gives no reveal; a JS-measuredmax-heightcosts the same layout plus script. This is the one case where obeying the guard means not having the animation at all, rather than implementing it more cheaply.So animating
grid-template-rowsbetween1frand0fr(withoverflow: hiddenon the inner element) is accepted for a disclosure, provided:
- reduced motion is honoured — the duration collapses to
0s, no exception;- the animated subtree is small — a nav group or a details panel, not a page region or a virtualised list;
- it is user-initiated and brief — a click or a keypress, in the fast or medium band, never continuous or looping.
Those constraints are what keep the layout cost bounded, since you are paying it deliberately.
The exception is temporary, and the way out is view transitions — not a CSS property.
interpolate-size: allow-keywords/calc-size()are not the fix: they let you animateheight: autodirectly, butheightis still a layout property, so it is the same reflow with nicer syntax. Adopt them behind@supportsif you want simpler authoring; it does not change this ruling.The real fix is the View Transitions API, which snapshots the before and after states and animates the snapshots on their own layer — so the reveal is composited and the document does not re-lay-out per frame. That is a different animation model, which is why no CSS property was ever going to solve this.
Blocked on React, and being worked out in #4882. Browser support is no longer the issue — Chrome 111, Safari 18, Firefox 144 — but
<ViewTransition>is canary-only and Astryx ships against stable React. Whether to drivedocument.startViewTransition()directly, when to adopt, and whether a component library should start a document-scoped transition at all are open questions on that issue. When it resolves, this exception closes and view transitions become the expected pattern. One caveat recorded there: view transitions scale the old and new snapshots to fit, which distorts content whose aspect ratio changes — precisely the axis a disclosure changes — so the migration wants a look, not a find-and-replace.Precedent for the exception today:
SideNavItem's collapsible group.Everything else stays blocking, on any render path. Animating
width,height,margin,paddingortopwhere atransformwould have done the same job is jank bought for nothing — and jank on a secondary path is still jank. Unlike the DOM-shape rules, this one is about what the browser does per frame, not about which element owns the styling.
§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. There is no visual-regression suite (one snapshot file repo-wide). That is a fact about the harness, not a deduction against a component — a component cannot be marked down for tooling the repo does not have. Visual correctness is checked by capturing screenshots and comparing them against Design Conventions (§5b), which is a stronger check than a pixel diff: it asks whether the state matches its approved representation, not merely whether it changed. 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 | Component Hardening Protocol §Automation targets | |
| V15 | A visual change ships before/after screenshots in the relevant states, variants and themes, each with a passing [[Evidence Templates#Screenshot sensor receipt | sensor receipt]]; the images are then compared against Design Conventions via §5b's state-visual conformance matrix. A URL/global request is not proof it rendered; generic readiness selectors do not count. Do not deduct for the absence of a visual-regression suite. | semi — captureWithSensors() + manual visual judgement |
FIX; §5b not measured if receipts fail |
POR |
| 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. A component that does all of this scores 5 — the absence of a visual-regression suite, or of any other tooling the repo has not built, never holds a section off full marks. (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 | React props/state/context and explicit refs own component data; the DOM is output, not a second store. Do not reconstruct React ownership or state from attributes, computed styles, child identity, or upward closest()/querySelector() traversal. DOM reads/writes are limited to facts only the browser has (geometry, event boundaries) or imperative browser APIs (focus, popover/dialog, pointer capture), with a named performance/accessibility reason and correct cleanup/node replacement. |
semi — grep closest/querySelector/dataset/getComputedStyle and direct DOM writes, then inspect the owner; no lint rule |
FIX; BLOCK when it bypasses a shared owner or breaks a documented composition/accessibility path | React-first boundary; P14 covers child introspection | |
| 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 | |
| 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 | |
| C22 | Give a separately nameable state machine, gesture, timing protocol, or interaction algorithm a coherent test boundary: a pure utility for deterministic calculation, or a named hook for React lifecycle/refs/timers/handlers. Keep behavior inline when it is small and inseparable from rendering—do not extract a hook per callback. Grade lifecycle ownership separately: the hook must be invoked by the component/primitive that owns the behavior and survive every documented composition seam; moving the same wrong-layer behavior into a hook does not fix it. A new behavior component must justify semantic DOM, provider lifetime, a portal/top-layer host, descendant registration/order, or an actual rendered affordance; otherwise use a hook to avoid permanent wrapper/nesting cost. | manual | FIX; BLOCK when the missing boundary prevents focused testing of important paths, duplicates a shared state machine, loses behavior at a documented seam, or adds a wrapper with a named DOM/layout/focus/event defect |
useMenuHover + focused hook tests (positive); Toast swipe #5375 architecture calibration (counterexample); Architecture Cheat Sheet |
5/5 — zero Effects, or every Effect synchronizes a genuinely external system and says so; interaction logic lives in handlers and callback refs; complex independently nameable behavior has a focused hook/utility test boundary owned at the correct lifecycle seam; 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), one complex behavior buried in a component but still covered through integration tests, 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 complex interaction state machine embedded in a replaceable/wrong lifecycle owner and only testable through the full component; a behavior component adding unjustified nesting; 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 | |
| X12 | Every applicable state has a story — rest/hover/focus/active/disabled/loading/status/selected/empty/overflow. | semi — story-name grep, then read | FIX | — | |
| 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. Presence is what this grades; whether the category is right is P11's business, and only for released surface. |
auto — pnpm check:changesets
|
BLOCK | CONTRIBUTING; copilot-instructions | |
| X21 |
Example code in the docs must not demonstrate a pattern the library has deliberately removed. A worked example in .doc.mjs (examples[].code, usage.bestPractices, an @example block) is a template a reader — human or model — copies verbatim. When a defect is fixed in the components, every doc example teaching the fixed shape is part of that fix and lands in the same diff. The failure is specific and checkable: an example that would reproduce a closed bug, or that a shipped lint rule would flag in source. Not a style preference, and not "the example is simplified" — the test is whether following it re-creates a defect the repo has already paid to remove. |
semi — after any behavioral fix, grep the component's .doc.mjs for the shape just removed; no general enforcer exists |
BLOCK | #4893 → #4913 (indicators kept teaching {children ?? mark} after isRenderable replaced it); X5a rationale — doc prose is LLM training signal |
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 that compile to the shape the components actually ship; 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; a worked example that reproduces a bug the components have already fixed. (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.
One residual blind spot. Both paths — attribute value and call argument — are walked: a literal nested in a ternary, a
??/||fallback, or a template's fixed chunks is reported. The walk does not descend into a template's interpolated expressions, so a conditional inside a template (`${n} ${n === 1 ? 'result' : 'results'}`) is still invisible to it. Read announcements and interpolated labels rather than trusting a green lint for I3/I6.
| # | 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'), and never a string the component assembles itself. |
auto — same rule (call arguments are walked, as attribute values are) | BLOCK | rule source; #4920 | |
| 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 with a non-empty description — it is the Crowdin source, and the description is the only context a translator (human or machine) gets. An unlisted key renders as the raw key string. Locale catalogs may omit untranslated keys (they fall back to English), but may not carry a key absent from en.json. |
auto — check:i18n-catalog (source ⊆ en; descriptions present; locale extras rejected) |
BLOCK |
scripts/check-i18n-catalog.mjs; #4925 |
|
| I6 | Interpolation uses ICU placeholders — never string concatenation or template-joined fragments, and never a JS-side plural (${n} result${n === 1 ? '' : 's'}). One message per sentence, plurals via {count, plural, ...}. Applies to announcements as much as to visible text. |
manual | BLOCK — unlocalizable word order; a JS plural is wrong in most languages |
resolve.ts; @astryx.commandPalette.resultCount as the model |
|
| I7 | A shipped catalog key is never removed without a codemod and a [breaking] changeset — keys are public surface for overrides. Shipped is literal: a key added and removed between releases has no consumers (see §Scoring). |
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 visuals mirror exactly once. Icons (chevron/arrow/caret) use rtlStyles.mirror with xstyle on the Icon itself, or swap the glyph name, never both. Contextual text decorations are judged by role: / in prose is neutral, while an aria-hidden / between repeated breadcrumb items communicates progression and must mirror; Unicode-mirrored ›/‹/»/« must not also receive a CSS flip. |
semi: rtl:audit D1 icon-mirror + D6 directional-decoration |
BLOCK | rtl-audit README §A.1/§A.3 | |
| 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> for every audited component, new or existing. The applicability rollup must be measured or carry a specific checked-in verified N-A reason. An unexplained all-N-A result is a coverage gap, not a clean scorecard. |
semi: CI runs it scoped; weekly full sweep | FIX (BLOCK for promotion) | CONTRIBUTING §RTL audits; rtl-audit README §C | |
| I18 | Text expansion doesn't break layout — no fixed widths on translatable text; verify with ×ばつ pseudo-locale or a long-string story. | manual | FIX | i18n text expansion | |
| I19 | An editable surface that acts on a command key guards the IME composition keydown — early-return on isImeKeyEvent(e) (from utils/ime) before reading Enter/Escape/arrows. A composing Enter commits the composition; treating it as "accept" is a defect for every CJK user. |
auto — @astryx/no-unguarded-ime-keydown (error in both tiers) |
BLOCK |
utils/ime.ts; #4892, #4907–#4909 |
|
| I20 | User-visible text is measured and cut by grapheme cluster, never by UTF-16 code unit — no .length for a character count, no .charAt(0), no .slice(0, n) on a display string. One emoji is one character to the reader; a code-unit slice splits a surrogate pair and paints �, and a code-unit count tells the user (and the screen reader) the wrong number. Use characterCount / firstCharacter / truncateCharacters from utils/characters, never a local Intl.Segmenter. |
semi — no enforcer; grep .length / .slice( / .charAt( over user-facing text and read each hit |
BLOCK for a displayed count or a truncation; FIX elsewhere | #4759; utils/characters.ts (#4776) |
|
| I21 | Locale-sensitive user output follows InternationalizationProvider.locale, never the host/browser locale. Pass that locale explicitly to user-visible date, time, number, list, sort, plural, and relative-time formatting; pure helpers require a locale; intentionally stable machine formats pin one. Prove it with a provider locale that differs from the host, then switch the provider locale and assert the output updates. |
semi — manual + provider-vs-host behavioral tests today; planned @astryx/no-ambient-intl-locale will automate omitted/undefined locale arguments |
BLOCK | provider contract; #5074 |
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; locale-sensitive output follows the provider rather than the host; zero physical properties or values; rtl:audit D1+D5+D6 clean, curated targets cover applicable directional behavior, and every component is measured or verified N-A with no coverage gaps; 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 applicable D2/D3 behavior was verified by hand without adding a reusable target.
1/5: English literals in label/aria-label/announcements or a suppression on the i18n rule; locale-sensitive output that ignores the provider; physical marginLeft/left/textAlign: 'left' in shipped styles; a directional visual that neither mirrors nor swaps (or does both); a logical-anchor + physical-translate element landing on the wrong side; an unexplained all-N-A RTL result presented as clean.
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 | WCAG 1.4.10 | |
| 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) | — |
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 §lab → core |
| 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 §Layer 3: Design Review | |
| L10 | The Layer-1 audit dimensions pass (they are §§2–3 and §5 of this rubric). | semi | see those sections | Component Hardening Protocol §The three layers | |
| 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 — and record it in the ledger (component-scores.json in this wiki).
An audit that isn't recorded didn't happen. Every graded run — nightly, promotion, or on-demand — writes its scorecard to the ledger. The ledger is
component-scores.jsonin this wiki, so recording is a wiki push: no repo PR, no review latency. It is the only stored form — there is no generated table to regenerate, and therefore none to go stale.You record by editing that file: clone the wiki, add or replace the component's entry, pull, commit, push. Full procedure in Recording an audit below.
What to record: the overall score and grade; every section score with its state (
scored/limited/not_measured/na); the open BLOCK list as rule id + one line;distinct_defects/fixes/nits;lastAudited; thecommitaudited; themode; and therubricVersionyou graded under (see the version block at the top of this page).Whether a blocking finding gets an issue depends on who is going to fix it.
- The nightly pass files nothing. It fixes what it found and re-audits to prove it, so an issue for work already done is noise. What it could not fix stays in the ledger row against the component, where the next audit and the scores page both find it. A finding does not need a ticket to be tracked.
- A grading or a promotion audit files one issue per open blocking finding — title
[audit] <Component>: <one-line finding>, labelhardening— because nobody is fixing them in that pass, and unassigned work with no ticket is work nobody picks up. Store the issue number on the entry in the ledger so the scores page links straight to it.- Should-fix and nit findings never get an issue, in any pass. They live in the ledger row.
- A cross-cutting or system-level finding gets one issue, whoever found it, and every affected row links it rather than duplicating it.
The rule underneath: an issue exists to hand work to someone. If the work is already done, or it is recorded against the component where the next pass will see it, an issue adds a queue entry and nothing else.
Each issue must carry its context, because whoever picks it up will not have the audit in front of them: the component's ledger entry — name, package and recorded score, so the row can be found — a link to this rubric with the rule id and section the finding violates, the rubric version it was graded under, the evidence (file:line, and the screenshot for a visual finding), and the concrete fix. An issue that just says "Button hover is broken" is not actionable.
Closing the loop. A PR resolving an audit issue must: (1) re-run the audit for that component afterwards — a full grading, not just the diff; (2) update the PR description with the visual results — before/after screenshots of the affected states, which is also what §5b needs; (3) update the ledger with the new score, closing out the resolved BLOCK; and (4) close the issue by linking that PR. The score must move — a resolved BLOCK that leaves the recorded score unchanged means either the fix or the audit is wrong. See Recording an audit for the write path.
Only record what you measured. An unverified section is
not_measured— never a guess, and never a zero. Night Watch captures the full rendered evidence its mode requires; an unavailable evidence layer remains explicitlynot_measuredand makes the PR ineligible for auto-merge when that layer is required.The recorded score is a gate. The ratchet fails any later PR that lowers a component's score or introduces a new BLOCK. Score generously and you build a gate nobody can pass; score harshly and you block the next honest contributor. Record what you found.
Component: <Name> Package: core | lab | <pkg>
Mode: nightly | promotion | grading | review Rubric: v1.9 Date: YYYY-MM-DD
Commit/PR: <sha or #NNNN>
Quick checks (when reviewing a change): 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
| Nightly pass | Promoting to core | Grading a component | Reviewing a change | |
|---|---|---|---|---|
| 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 | all; objective findings may be fixed, manual-boundary findings are reported; the PR stays manual until the proposed gate is activated by current repository authority | all | all | all, scoped to the diff |
| Visual evidence (§5b) | required — the nightly pass captures screenshots and re-captures them after its fixes | required — every applicable state ×ばつ light, dark, ≥1 custom theme, + 320 px and coarse pointer. A promotion 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 | full grade + observational contract + batched objective-fix PR | full grade + go/no-go + contact sheet | full grade + scorecard + contact sheet | signal + delta grade |
| Gate | manual review today; proposed fail-closed auto-merge only after atomic repository/schema activation | 0 BLOCKs and ≥ B | none | any BLOCK ⇒ request changes |
| §11 evidence | skip (existing core component) | full | skip for an existing core component; full for a promotion | L4/L14/L15 only |
Commands for a full pass
pnpm lint:strict && pnpm test && pnpm build # Q1–Q3, the entry gate pnpm check:sync && pnpm check:changesets node scripts/check-use-client.mjs # not wired into any script — run it node scripts/sync-exports.js --check && node scripts/verify-exports.mjs pnpm -F @astryxdesign/core typecheck:docs pnpm -F @astryxdesign/docsite generate && pnpm -F @astryxdesign/docsite test pnpm storybook:build && pnpm a11y:audit -- --components <Name> pnpm rtl:audit -- --filter <Name> # §5 visual evidence — required, not optional pnpm -F @astryxdesign/build build && pnpm -F @astryxdesign/core build # Storybook reads dist/ pnpm storybook # :6006 npx playwright install chromium # then drive real Chromium: for each applicable state (rest, hover, focus-visible, # active, disabled, loading, each status, selected, empty, overflow) ×ばつ light + dark # (+ a custom theme for P/O), screenshot to # <Component>__<variant>__<state>__<theme>.png — and then LOOK at them. # Use page.hover() / keyboard Tab / page.mouse.down() to drive real states; # never fake a state by injecting a class. # then the greps lint doesn't cover: hex/rgb/hsl, boxShadow, <svg, # textSizeVars, defaultMarker, style={{, ':hover' without a guard, # and fooStyles[prop] vs the sibling themeProps() call
Where the data lives, and how to write to it.
Storage. component-scores.json in the wiki repo (github.com/facebook/astryx.wiki.git). It holds audited components only — there is no row for an unaudited one, and no placeholder list to maintain.
Viewing. The Component Scores page in the sandbox joins the ledger against the live roster generated from every component-bearing package in scripts/component-packages.cjs, so an unaudited or newly added public component shows as TBD without a hand-maintained list. It fetches the ledger at runtime, so a score recorded minutes ago shows without a rebuild. There is deliberately no generated wiki table: a second stored copy is a second thing to drift.
Who writes. Whoever ran the audit — the nightly auditor, or a human or agent running an on-demand pass. CI never writes it: most of this rubric needs judgment, so a workflow cannot produce a score. Only maintainers can push the wiki, which is the point — the ledger's integrity depends on the audit being honest, not on restricting who may read it.
Use the tool — scripts/score-ledger.mjs, on main since #4875. It applies the rules below rather than trusting you to remember them.
# Get the ledger once; reuse the clone afterwards git clone https://github.com/facebook/astryx.wiki.git /tmp/astryx-wiki # Record a scorecard and push it, in one command <your scorecard JSON> | node scripts/score-ledger.mjs --record <Component> \ --from - --ledger /tmp/astryx-wiki/component-scores.json --push
--push commits and pushes the wiki for you, rebasing first so concurrent auditors do not collide, and prints the commit URL. --from - reads the scorecard from stdin — pipe it rather than writing a file: the ledger is the only stored form, and a scorecard on disk goes stale within the hour. --dry-run prints the exact diff and commit message without writing anything; use it the first few times.
Other subcommands: --stats (coverage, grade distribution, open BLOCKs), --queue --limit N (the work order — unaudited first, then oldest-audited, then lowest-scoring), --check (where a PR's touched components stand), and --file-issues (create the BLOCK issues from a scorecard, idempotently, and write their numbers back).
Editing the JSON by hand still works and is not wrong — but the tool refuses the mistakes that matter, so prefer it.
The scorecard format, as JSON: the overall score and grade; every section with its score and state (scored / limited / not_measured / na); the open BLOCK list as {id, summary, issue}; distinct_defects, fixes, nits; lastAudited; the commit audited; the mode; and the rubricVersion you graded under (from the version block at the top of this page).
The tool enforces these; they bind just the same if you edit by hand.
-
A decrease or a new BLOCK must be deliberate and explained.
--recordrefuses it without--allow-regression "<why>", and the reason lands in the commit message. The ledger is a ratchet; lowering a score is never a silent edit. -
Only record what you measured. An unverified section is
not_measured— never a guess, never a zero. Night Watch captures the full screenshot set required by §5b; if any required rendered layer is unavailable,design_renderedisnot_measuredand the PR remains manual-review-only. -
BLOCK issue handling follows the audit mode. Night Watch files no ordinary
per-finding issues; grading and promotion use
--file-issuesfor each open BLOCK; every mode uses one shared issue for a cross-cutting/system-level gap. -
Entries are keyed
package/Component—Chatexists in bothcoreandlab, and they are different components.
node scripts/score-ledger.mjs --stats # coverage, grades, open BLOCKs node scripts/score-ledger.mjs --queue --limit 1 # what to audit tonight node scripts/score-ledger.mjs --check --analysis a.json # where a PR's components stand
Nothing in CI reads the ledger, and no PR is gated on a score. If a checker is ever wired up it will be a standing report, not a gate: a wiki-hosted ledger has no head-versus-base delta to ratchet, a component with no audit is judged on its merits, and an unreachable ledger would never block.
Current: 1.15.3, last changed 2026年09月06日.
Cite this version in every audit. The scorecard's Rubric: line and the ledger's rubricVersion field must both carry it. Scores taken under different minor versions are not comparable. Patch versions remain comparable when their history entry explicitly says so; otherwise the ratchet must fail closed and request a re-audit.
Versioning rule. Bump the minor when scoring changes (weights, floors, severities, what counts as evidence) — that invalidates comparison with earlier scores. Bump the patch when only wording, citations, or a check's detection recipe change — comparison still holds. Record the change in the history below, and date it.
| Version | Date | What changed |
|---|---|---|
| 1.15.3 | 2026年09月06日 |
The proposed Night Watch auto-merge contract is explicitly inactive while spec:AST-029 is draft. A missing contract does not block grading; auditors may optionally prepare a draft observational worksheet and batch objective fixes, but every such PR remains manual-review-only until a current repository record, versioned component template/schema, active-record migration, prompt and procedure activate together. Night Watch captures the full §5b screenshot set and may report 🟡/🔴 judgment findings as review recommendations, but never treats them as settled authority or auto-fixes them. Corrected the stale sentence claiming Night Watch captures no screenshots. Procedure/authority clarification only—checks, weights, severities, and comparability with 1.15.2 are unchanged. |
| 1.15.2 | 2026年09月06日 | Night Watch is specification-first and auto-mergeable under a fail-closed gate. Every registered component package participates. A missing component contract is backfilled from verified shipped behavior with a closed evidence matrix; shared current records and objective standards expose fixable defects rather than being overwritten by local behavior. One Night Watch PR may pair the backfill with objective tests, stable visual snapshots, doc-drift fixes, and bugs. Public API, compatibility, ownership, or subjective design changes remain manual. Night Watch still records only the post-fix row and files no ordinary issues; grading/promotion keep their existing per-BLOCK issue lifecycle. Procedure and detection only—checks, weights, severities, and comparability with 1.15.1 are unchanged. |
| 1.15.1 | 2026年08月29日 | Progress guidance now separates WCAG requirements from Astryx techniques and narrows the inactive exception. A name-only label does not make a ProgressBar supplemental; visible text must communicate the current value or an equivalent progress state. An endpoint is documented as an Astryx technique for making total extent unambiguous rather than a WCAG-prescribed pattern. Canceled graphics are not automatically inactive, and indeterminate animation may be redundant when an equivalent visible loading message exists. Clarification only; thresholds, severity, scoring, and comparability with 1.15 are unchanged. |
| 1.15 | 2026年08月28日 | D7 now distinguishes standalone and supplemental ProgressBar contrast, adds ChatComposer to the shared bordered-input matrix, and corrects focus and color-only applicability. Standalone progress graphics must carry a 3:1 completed/remaining distinction and a perceptible total extent; supplemental fill/track graphics may be decorative when visible text carries equivalent progress; indeterminate mode measures the moving segment against its rendered backdrop. Input guidance now explicitly includes ChatComposer and its send/status relationships. Focus indicators are measured against the adjacent surface used to perceive them rather than automatically against every surface they touch, and Card color-only failures are resolved under SC 1.4.1 before reassessing non-text contrast. These changes can alter D7 findings, so §5 scores taken under ≤1.14.4 are not comparable where these relationships apply. |
| 1.14.4 | 2026年08月28日 | D7 now requires an applicability decision before contrast arithmetic. Auditors run the removal test for every painted part, record why it is meaningful, redundant, decorative, or conditional, and keep informational ratios from becoming false WCAG failures. Applicability is reassessed by state and composition because the same border can be decorative on a static Card and required on an otherwise indistinguishable control. This clarifies evidence and reporting without changing thresholds, severity, or scoring, so scores remain comparable with 1.14.3. |
| 1.14.3 | 2026年08月28日 | D7 now distinguishes decorative Card surfaces from meaningful interactive and selection cues. Static Card backgrounds, borders, and elevation are exempt when content and spacing already communicate grouping; ClickableCard boundaries are measured only when needed to identify the target; SelectableCard selection indicators remain meaningful and require 3:1 against adjacent colors. The Accessibility Checklist and component profiles carry the same decision table. This clarifies applicability without changing thresholds, severity, or scoring, so scores remain comparable with 1.14.2. |
| 1.14.2 | 2026年08月28日 | D7 now starts from an explicit per-component contrast profile. The Accessibility Checklist defines the profile inventory and baseline decisions established while auditing Button, Badge, Token, Banner, FieldStatus, TextInput, Checkbox, Radio, Switch, Spinner, and ProgressBar. The rubric requires auditors to expand that profile into individual rendered pairs, preserve shared semantic relationships without inheriting another component's pass, and keep disabled, busy, and read-only applicability separate. This formalizes the existing complete-pair requirement and adds no new threshold, severity, or scoring rule, so scores remain comparable with 1.14.1. |
| 1.14.1 | 2026年08月28日 |
D7 now requires verification against the artifact the consumer actually loaded. A contrast calculation from source tokens is not enough when the preview imports built theme output: rebuild generated CSS/package artifacts, invalidate the consumer's cached module graph, inspect final computed colors in every state, and calculate from the complete rendered composite. Source tests, generated files, and HTTP success are supporting evidence only; a source/rendered disagreement leaves the audit failing or not measured. Detection recipe only; scoring, severity, and comparability with 1.14 are unchanged. |
| 1.14 | 2026年08月28日 |
Button evaluation is now an explicit accessibility contract. A11 distinguishes explicit disabled from busy: isDisabled uses native disabled, while loading retains focus and blocks duplicate action through aria-disabled plus activation guards unless an interruptible API explicitly keeps it actionable. D7 and the Accessibility Checklist now define the rendered pairs for button labels, meaningful versus redundant icons, optional boundaries, focus, spinner arc versus decorative track, disabled-state exceptions, and badge/custom end content. Requiring the default loading activation guard adds a BLOCK-capable condition to A11, so §1 scores taken under ≤1.13.1 are not comparable where A11 applies. Weights, floors, ceilings, and D7 severity are unchanged. |
| 1.13.1 | 2026年08月27日 | D7 contrast evidence is now explicit and anchored in the Accessibility Checklist. The new pair matrix records rendered foreground/background pairs across applicable states, light/dark and the required non-default theme; fixes the exact WCAG 2.2 AA thresholds and large-text definition; requires alpha compositing over the actual backdrop; and records the permitted exceptions. Theme-elevation reviews now state the existing ownership rule directly: the theme owns every unexcepted failing pair and cannot use the component-audit carve-out for systemic token debt. This is an evidence-recipe and ownership clarification, not a new component scoring rule; weights, floors, severities, and comparability with 1.13 are unchanged. |
| 1.13 | 2026年08月26日 | A10 no longer requires a ~44px hit area on coarse pointers. Pointer modality does not change WCAG 2.5.8 AA: the ×ばつ24 minimum and its exceptions apply equally, while ×ばつ44 belongs to the separate 2.5.5 AAA criterion. Missing coarse-pointer expansion is therefore neither a BLOCK nor a FIX. This removes a FIX-capable condition from §1, so §1 scores taken under ≤1.12 that cited missing coarse-pointer expansion are not comparable. Weights, floors, ceilings, and every other check are unchanged. |
| 1.12 | 2026年08月26日 |
A10 now carries the whole of WCAG 2.5.8, not just its ×ばつ24 headline. The check read "≥ ×ばつ24 px ... BLOCK <24px", which fails closed on targets the criterion actually passes: it now rules out the five SC exceptions before blocking — Spacing (an undersized target clears if a 24px-diameter circle on its bounding box misses every neighbor and neighboring undersized circle; calibrated with the ×ばつ20-at-4px-gap pass / touching-fail pair and the 24 − s clearance formula), Inline, Equivalent, User-agent, and Essential — plus the obscured-target carve-out and the spatial-single-target rule (Slider thumb, gradient, cursor area count as one). The stray "~44px (WCAG 2.5.8)" is corrected: 44px is 2.5.5 AAA / platform HIGs, kept as a FIX-level house rule, not implied by the 2.5.8 citation. This narrows when A10 may BLOCK — a standalone undersized control with sufficient spacing now passes — so §1 scores taken under ≤1.11.2 that cited A10 are not comparable (Avatar, AvatarGroup, and Breadcrumbs each carry an A10 block that must be re-derived against the exceptions). Weights, floors, ceilings, and every other check are unchanged. |
| 1.11.2 | 2026年08月26日 | Screenshot detection recipe tightened: hand-built/mutated arms require an inspected exact diff, expected sensor values cannot be derived from the observed page, and semantic state reads avoid hidden measurement copies. Wording/detection only; severity and scoring are unchanged from 1.11.1, so scores remain comparable. |
| 1.11.1 | 2026年08月26日 | Q12 clarifies screenshot necessity without changing severity: behavior-only changes may rely on the exact-head visual gate only when named stories cover both visible endpoints; otherwise manual frames are required. Non-rendering changes may record N/A with path evidence. Same scoring as 1.11; scores remain comparable. |
| 1.11 | 2026年08月26日 | Screenshot evidence now requires a fail-closed sensor receipt (Q12/V15 and §5b capture protocol): build/worktree SHA, story id, rendered theme/mode, computed direction, viewport/media, semantic state, visible geometry, settled render and capture errors. Before/after must match on every sensor except Build; missing or failed receipts make §5b not measured. This changes what counts as visual evidence, so §5 and §6 scores under ≤1.10 are not comparable where screenshots were used. Weights, floors and ceilings are unchanged. |
| 1.10 | 2026年08月25日 |
New C22 (§7): complex, separately nameable behavior gets a coherent hook/utility test boundary, while lifecycle ownership is graded independently; extracting wrong-layer behavior does not make it correctly placed. New behavior components must justify the semantic DOM/provider/portal/descendant-order boundary they own rather than adding nesting by default. useMenuHover plus its focused tests is the positive calibration; Toast swipe #5375 is the counterexample—a gesture state machine embedded in the replaceable card surface. C22 is normally FIX but can BLOCK when behavior is lost at a documented seam or the missing boundary prevents focused testing of important paths. This changes what §7 can find, so scores under ≤1.9 are not comparable in §7. Weights, floors and ceilings are unchanged. |
| 1.9 | 2026年08月25日 | Architecture crosswalk: T1 now records the enforced theme-layer ownership of light-dark(); T7/T23/T29 and C14 tighten painting-surface, public-var, reuse, and React/DOM boundaries; P39–P42 add data-attribute, component-substitution, size-cascade, and status-protocol checks. T24 is corrected to the four current per-edge container variables. New BLOCK-capable coverage changes what §2, §3, and §7 can find, so scores under ≤1.8 are not comparable in those sections. Weights, floors, and ceilings are unchanged. |
| 1.8 | 2026年08月23日 |
RTL applicability is now explicit for every component, new or existing. I10 adds contextual text decorations to the rendered RTL contract: role determines whether a glyph is directional, so / in prose remains neutral while an aria-hidden slash between repeated breadcrumb items must mirror; Unicode-mirrored angle quotes must not be flipped twice. I17 no longer accepts an unexplained all-N-A scorecard as clean: each component must be measured, carry a specific checked-in verified-N-A reason, or remain a coverage gap. This changes what counts as evidence in §9, so scores taken under ≤1.7.1 are not comparable. The executable contract is in #5365 and tracked by #5364. |
| 1.7.1 | 2026年08月22日 |
A21's expected value is default, not not-allowed (PR #5335). Same check, different target: a disabled control sealed behind pointer-events: none is never hit-tested, so it shows whatever its ancestor shows and no declaration on it can change that — 75 of 635 disabled elements in the story set are sealed. A cursor the library can only paint on part of itself is not a guarantee, and the disabled state already carries its own visual treatment; default also matches the internal XDS convention. A patch, not a minor: no component was audited under 1.7, so no score is invalidated. |
| 1.7 | 2026年08月22日 |
New A21 (§1, FIX): a disabled element must answer the pointer with not-allowed. A20 carried the cursor as a rider; it needs its own check because the failure has its own shape. StyleX merges props() one PROPERTY at a time, so a later style setting cursor at all discards the earlier declaration's conditions — SegmentedControlItem held a correctly guarded cursor: pointer on its base and a flat disabled: {cursor: 'default'} applied after it, and shipped a disabled segment answering with a plain arrow. The sweep that found it measures what a pointer would actually get: it hit-tests five points per disabled element in Chromium, so it also sees a child that declares its own cursor over part of the control. An element sealed behind pointer-events: none is N/A rather than a pass — 75 of 635 disabled elements in the story set are, and their cursor comes from an ancestor. Adding a FIX-capable check can move §1's graded floors: §1 scores taken under ≤1.6 are not comparable. No weight, ceiling or severity elsewhere moved. |
| 1.6 | 2026年08月19日 |
New A20 (§1, FIX): no disabled element may paint a hover state. :hover matches a disabled control in every engine, and the shape that hides the defect is specific to StyleX — a disabled style setting a flat value overrides the DEFAULT condition only, so the variant's :hover class survives the merge and wins under the pointer. Button shipped that in every variant and it reached eleven components that render a Button; Link, Item, NavHeadingMenuItem, SideNavItem and TopNavItem had their own. Both halves read as correct in review, which is why the check is scored auto: @astryx/no-hover-on-disabled rejects an unguarded hover at author time, and a Chromium sweep forces :hover on every disabled element in every story and fails on any painted difference (jsdom has neither a pointer nor a cascade, so no unit test can see this). Adding a FIX-capable check can move §1's graded floors, so this is a minor: §1 scores taken under ≤1.5 are not comparable. No weight, ceiling or severity elsewhere moved. |
| 1.5 | 2026年08月17日 | §9 corrected against the code and extended. A4 was stale: the shared focus hooks auto-detect direction from the container — the isRtl override was deprecated in #4682, and useTreeFocus never took one (Accessibility Checklist corrected to match). I3 now reflects #4920: announcement arguments are walked and the temporary allowlist is gone. I5 is now auto-enforced by check:i18n-catalog (#4925), and drops a key count that went stale every catalog PR. I6 states that a JS-side plural is a defect and applies to announcements as much as visible text. I19 (IME composition guard) is active after #4907–#4909. I20 (grapheme-safe counting and truncation) is active after #4776. I21 adds provider-locale correctness as a manual/semi check while its proposed lint automation is still unlanded. I20 and I21 are new BLOCK-capable checks, so §9 scores taken under ≤1.4 are not comparable; §1's A4 correction changes what the check means but not its weight or severity. |
| 1.4 | 2026年08月12日 |
Two scoping rules, both answering "does this defect have a victim?" — added after the Indicator audit produced two findings that graded correctly against the rule text and should never have been recorded. (1) Migration rules bind released surface only. Q11/T33/P11/X20/I7 all demand a [breaking] changeset and a codemod; all exist to protect installed consumers, and none said so. Indicator merged a week after the release that would have carried it, so every export in the layer was unreleased — and P11 was cited for omitting props from a type nobody could install (#4937, withdrawn; #4938 closed). The rules now require establishing the surface is published first, with the command to do it. P11 also drops to FIX in modes N/O: its subject is a changeset, which is deleted at release, so a BLOCK derived from one evaporates with nobody fixing anything — it belongs on the PR, as L14 already is. (2) A finding needs a reachable victim. "Score the failure, not its likelihood" is about users in rare states and still holds; it does not license grading a defect nothing can reach. A defect requiring code the type rejects, that no call site contains and no doc suggests, is a NIT at most — with a corollary that a finding closable by a type change should say so, rather than inviting a runtime mechanism (the tabIndex FIX produced exactly that, #4947). Both narrow when a BLOCK may be recorded, so this is a minor: §2, §3, §8 and §9 scores taken under ≤1.3 are not comparable where a migration rule was cited. Weights, floors and ceilings are unchanged. |
| 1.3 | 2026年08月11日 |
New X21 (§8, BLOCK): a worked doc example may not demonstrate a pattern the library has deliberately removed — an example that reproduces a closed bug is part of that bug, because doc code is copied verbatim by readers and models. Prompted by the Indicator audit: #4913 replaced {children ?? mark} in all three shipped indicators because it deletes the state mark on a falsy child (#4893), and left the only worked example of writing a replacement indicator teaching exactly that shape. The finding had no home — it is not X5a (no phantom prop) nor X5b (no missing entry) — so the first auditor to hit it filed it under a rule that did not cover it. Adding a check that can BLOCK changes what counts, so this is a minor: §8 scores taken under ≤1.2.1 are not comparable. Every other section is untouched, and no weight, floor or ceiling moved. |
| 1.2.1 | 2026年08月11日 | Corrections from the AlertDialog audit review (PR #4887). T28 rewritten: a composition layer may carry its own theme target for a distinct product concept on the painting element (alert-dialog over dialog, as #672 did for more-menu); the rule now forbids only a target on an outer node wrapping several independently themed components (#749). P33 rewritten: the html prefix is for props that are the native attribute; a prop reusing an HTML name with a different meaning keeps the semantic name and Omits the attribute. Wording and scope only — no weights, floors or severities changed, so scores stay comparable with 1.2. |
| 1.2 | 2026年08月10日 | Graded BLOCK ceilings (1 ⇒ ≤3 · 2 ⇒ ≤2.5 · 3 ⇒ ≤2 · 4+ ⇒ ≤1); "no BLOCKs" removed from the 3/5 anchors so the ceilings actually bind; §5 split into §5a objective (6) and §5b rendered judgment (4), with unmeasured §5b not measured rather than 0; limited re-keyed to vacuous passes; owner tiebreak and root-cause scoring for deduplication; check role tokens and resolved scales, not literal pixel values. |
| 1.1 | 2026年08月10日 | First graded floors; one-defect-one-score; token-layer contrast and promotion-lifecycle checks excluded from existing core components; X5/A5/A6 splits; sanctioned external-system Effect exception; §5 visual-evidence gate and state-visual conformance matrix. |
| 1.0 | 2026年08月10日 | Initial rubric: 11 sections, four run modes, weighted 0–100 grade. |
Checks that are written but not yet live. Flip them when the trigger lands; until then they are inactive and must not be cited.
| Check | Currently | Activates when |
|---|---|---|
| T34 — indicator convention | ACTIVE — #4712 merged 2026年08月11日 | — |
| I21 automation — ambient Intl locale | manual/semi today; the provider-locale behavior is active and scoreable |
@astryx/no-ambient-intl-locale lands, the scoped production backlog is fixed, and strict/CI promotes it to error |
| 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
|
Start here Astryx Philosophy Contributing with AI Assistants Contributing
Architecture System Architecture Architecture Cheat Sheet Theming Infrastructure Distribution
Building a component Component Lifecycle Component Authoring Guide API Conventions Design Conventions
Quality Component Audit Rubric Accessibility Checklist AST-009 assistive-technology verification
Operations Release Process Night Watch Overview