{ "ledgerVersion": 1, "rubricVersion": "1.2.1", "updated": "2026-10-26", "about": "Audit scores for Astryx components, one entry per AUDITED component. A component with no entry here has not been audited — the roster of components is read from packages/core/src and packages/lab/src, never from a list. This file is the ONLY stored form of the ledger: there is no generated table, because a second copy goes stale the moment this one changes. A rendered view in the sandbox app is planned but not built, so this file is also the only way to read it today. Hand-edit it: add or replace the component's entry, pull, commit, push. A score-ledger tool that would apply the ratchet automatically is planned but is not on main, so the ratchet is a rule the auditor applies, not a program.", "caveats": [ "All five rows come from the same calibration exercise on commit `dccdabea0b`, run by five independent graders. They are the only audits that exist so far, and they are honest about their limits rather than tidy.", "`score` is the round-2 total under rubric **v1.1** with §5 excluded and its weight redistributed — the figure the calibration record itself publishes. It was produced from **static evidence only**: no lint, test, build, axe or RTL run.", "The §5a/§5b numbers come from a later pass over 256 rendered screenshots at the same commit. That pass produced a single §5 grade (it predates the v1.2 split), so the same number is recorded on both halves. Those points are **not** folded into `score`: recomputing the total would need the per-section scores the calibration record never published.", "`blocks.count` **is** the union of both passes. Every other section reads `unpublished`: it was audited and is inside `score`, but the record published only the total. That is not `not_measured` — somebody did look — and inventing the split would be fabrication.", "`distinct_defects` is the record's own count; only Badge's (14) is a deduplicated figure, the rest are its approximations.", "Most BLOCKs here have no issue filed yet. Two link pre-existing issues. Filing the rest is the next step, not a completed one.", "Treat every row as a floor to re-audit under v1.2 with the tooling actually run, not as a settled grade." ], "components": [ { "component": "AlertDialog", "package": "core", "status": "audited", "score": 89.6, "grade": "B", "sections": { "a11y": { "score": 4.5, "weight": 16, "state": "scored", "note": "A2 closed: the APG Alert Dialog pattern is named and linked in the docs and the JSDoc. A5 closed: initial focus on cancel is pinned with data-autofocus rather than depending on cancel being first in the footer. Held at 4.5 rather than 5 deliberately — the remaining §1 findings (A11 busy-disables-the-button, A10 coarse-pointer target size, and the tab stop that lands on document.body) are root-caused to Button and Dialog and so are not scored here, but they still composite in this component and no screen-reader pass was run." }, "theming": { "score": 4.5, "weight": 14, "state": "scored", "note": "Unchanged. The T28 finding from the first pass is withdrawn: alert-dialog beside Dialog's astryx-dialog target is the sanctioned shape for a composition layer naming its own concept on the painting element, and rubric T28 was rewritten in v1.2.1 to say so." }, "api": { "score": 4, "weight": 14, "state": "scored", "note": "Unchanged. The P33 finding from the first pass is withdrawn as a false positive: BaseProps already omits title from React.HTMLAttributes, so nothing was shadowed. API Conventions and rubric P33 were corrected in v1.2.1. onAction / isActionLoading versus the clickAction convention is ruled a nit by the TL — these are callbacks, not transition actions — and is not carried as an open finding." }, "behavior": { "score": 4.5, "weight": 12, "state": "scored", "note": "B10 evidence closed: long content is rendered by a story and the overflow measured (maxHeight 300px with overflowY auto at a 400px-tall viewport; the action button stays on screen). Adversarial pass clean: no double-fire on rapid clicks, Escape mid-flight, cancel mid-flight, hook show/hide/show, and scroll-lock release." }, "design_objective": { "score": 5, "weight": 6, "state": "scored", "note": "Unchanged. Six of the eleven objective rows are N/A by absence: the component ships no styling of its own, composing Dialog, Layout, Heading, Text and Button." }, "design_rendered": { "score": 4, "weight": 4, "state": "scored", "note": "Partial-coverage cap lifted: long content, the custom cancel label and the inline preview now have stories, so every documented state was captured. Held at 4 because the judgment rows (D3 spacing rhythm, D15 proportion and density, D16 AI tells) were graded from decoded pixel data, per-pixel diffs, resolved colors and contrast ratios rather than by a human viewing the images." }, "testing": { "score": 4.5, "weight": 8, "state": "scored", "note": "Both BLOCKs cleared. V6/V7: keyboard and focus tests added — Escape-to-cancel, initial focus, focus restoration. V3: useImperativeAlertDialog covered by six tests. 13 cases to 31." }, "code_health": { "score": 5, "weight": 8, "state": "scored", "note": "Unchanged. Zero Effects across both files." }, "docs": { "score": 4.5, "weight": 8, "state": "scored", "note": "X16 BLOCK cleared (APG pattern named and linked). X3: bestPractices now 3 do's and 3 don'ts. X4: usage.anatomy documents five parts. X10/X12: cancelLabel and isInline are exercised by stories, taking prop coverage to 100%." }, "i18n_rtl": { "score": 5, "weight": 5, "state": "scored", "note": "I18 closed by the long-string story; rtl:audit --filter AlertDialog reports 0 fail, 0 surprise." }, "responsive": { "score": 4, "weight": 5, "state": "scored", "note": "R11 and R5 evidence closed: 288px wide with no horizontal scroll at a 320px viewport for both short and long content, and the dialog caps and scrolls at a 400px-tall viewport with the action button still reachable. A10 (32x32 footer buttons, no coarse-pointer expansion) is root-caused to Button and tracked on #4476." } }, "blocks": { "count": 0, "open": [] }, "distinct_defects": 10, "fixes": 7, "nits": 0, "lastAudited": "2026-08-11", "rubricVersion": "1.2.1", "mode": "N", "commit": "ec85ba0b6f", "evidence": [ { "label": "31 post-fix screenshots (24 before), real Chromium via Playwright against local Storybook with @astryxdesign/core rebuilt between passes: 7 stories at rest plus action hover, cancel hover, focus-visible, pressed, loading, custom cancel label, long content, inline, 320px, coarse pointer and a 400px-tall viewport, in neutral light, neutral dark and y2k", "note": "https://github.com/facebook/astryx/tree/assets/pr-4887/pr-assets/after (before pass: .../pr-assets/before)" }, { "label": "No visual regression: every state captured in both passes is identical at 0.00% of pixels changed, except two animation-timing artifacts (loading spinner 0.02%, pressed transform 0.09%)" }, { "label": "Tooling actually run: pnpm test, pnpm build, pnpm lint:strict (0 errors, 48 warnings — the main baseline), a11y:audit --components AlertDialog against a freshly built Storybook (0 violations across all 7 stories including Inline, no new baseline entries), rtl:audit --filter AlertDialog (0 fail, 0 surprise), check:sync, check:changesets, check-use-client, verify-exports" } ], "notes": "Post-fix score, replacing the 79.4 (C) pre-fix row recorded the same night. Delta +10.2 and 3 BLOCKs to 0; most of the move is the §6 and §8 BLOCK ceilings lifting (2 BLOCKs cap a section at 2.5, 1 caps it at 3), the rest is evidence that did not exist before — three documented states no story rendered. Landed by PR #4887. Reviewed by the TL on the same PR, which settled four of the five open questions: T28 (second theme target on one node) and P33 (title shadowing the native attribute) were WITHDRAWN — both were rule defects, not component defects, and Component Audit Rubric v1.2.1 plus API Conventions §HTML Attribute Collisions were corrected accordingly; onAction / isActionLoading versus the async-action convention was ruled a nit and closed; and the isInline path, which rendered role=\"alertdialog\" on a non-modal always-present element, was FIXED to role=\"group\" in the same PR. Scored under v1.2.1, which is a patch over the v1.2 the pre-fix row used — wording and scope only, no weight, floor or severity moved — so the two scores are comparable and the delta is real. Still open, both root-caused elsewhere and scored against those components per the shared-module rule: isActionLoading reaches Button.isLoading, which sets native disabled, so focus drops to document.body mid-flight (Button A11, open BLOCK in its row); and 32x32 footer buttons with no coarse-pointer expansion (A10), now tracked on the Q3 responsive-web tracker #4476 with the measurement recorded there. One more is Dialog's: a tab stop lands on document.body before wrapping back into the modal, reproduced on the plain Dialog story. Zero Effects across both files." }, { "component": "AppShell", "package": "core", "status": "audited", "score": 88, "grade": "B", "sections": { "a11y": { "score": 4.5, "weight": 16, "state": "scored", "note": "Both pre-fix FIXes are closed and each was re-measured in real Chromium. (1) A2: the sidenav-only mobile top bar now carries role=banner when there is no headerContent, so the landmark inventory no longer depends on which nav slots a page fills — measured at 390px, SideNavOnly goes from bannerCount 0 to 1, TopNavWithSideNav stays at 1, and a test asserts both (red without the fix, verified by reverting the one line). (2) A15: the skip link composes focusOutlineProps.focusVisible instead of relying on the browser default — measured 2px solid rgb(38,38,38) at 3px offset in neutral light, rgb(37,37,42) in stone, rgb(45,36,27) in y2k light and rgb(237,239,252) in y2k dark, so the ring follows --color-accent across themes where it was Chromium's rgb(0,95,204) before. A18: the nine stale color-contrast entries AppShell carried in .github/a11y-baseline.json are deleted; a scoped audit against the built Storybook reports 0 total violations across all nine stories and the baseline drops 227 to 218 entries. Still not a 5: no screen-reader pass was run, which remains the honest gap." }, "theming": { "score": 4.5, "weight": 14, "state": "scored", "note": "T23 closed in the component: the header-height custom property is renamed --appshell-header-height to --_app-shell-header-height (AppShell.tsx:413, 414, 586), which is the component's own name and the --_ shape that marks a runtime-written private var; per the standing T23 ruling it needs no derivedVarRegistry or doc entry, since the registry test skips --_ vars and its source scan never sees a var written with setProperty. Everything else from the pre-fix pass stands: all three rendered targets documented and painting, no colour/spacing/radius literals, the stone theme moves 4.35% of pixels. FIX opened by this pass and not yet fixed: the old name had a consumer. apps/docsite reads --appshell-header-height in 25 places across 9 files, including a :root seed in globals.css written for #2713 whose comment says the AppShell overwrites it with the measured value — after the rename nothing does, so every docsite consumer is pinned to the 48px seed. Measured on the deployed docsite after the merge, at 1440x900 on the home page: the shell's inline style now reads --_app-shell-header-height, and with the root font at 24px the header measures 58px while the hero's top, the main scroll-padding and every other consumer resolve 48px — a 10px overlap that grows with text size (48/53/58/68px header at 16/20/24/32px root font, against a constant 48px seed). At the default 16px the two agree, so nothing is visible until a reader enlarges text. The migration is a follow-up PR rather than a component defect, and it is why STRUCTURAL_VARS in derivedVarRegistry.test.ts still names the old var." }, "api": { "score": 4.5, "weight": 14, "state": "scored", "note": "No TypeScript public API change in the fix pass, and the diff was re-checked for one: AppShellProps gains and loses nothing, no export moves, no type widens or narrows, and the only edit inside the interface is a JSDoc example. The one contract that did change is a CSS custom property, scored in §2. P17 is WITHDRAWN as a false positive — useAppShellMobile is not missing from packages/core/src/hooks/index.ts, because that barrel is not where this repo puts component hooks: 27 documented public hooks (useTooltip, useToast, useLayer, usePopover, useTheme, useTranslator, useImperativeDialog, useImperativeAlertDialog, useCollapsible, useHoverCard, useResizable and the 13 Table hooks) live in their component's directory and reach src/index.ts through that component's barrel, and not one of them is in the hooks barrel. AppShell follows the house pattern; the rubric's P17 wording does not describe this repo and that is a rubric fix. P23 stays open by design: mobileNav inverts the boolean-or-config polarity (omitted means on, true is not accepted), which is a public API shape and a human decision rather than a nightly correction. P1/P2/P3/P15/P20/P21 re-verified green." }, "behavior": { "score": 4.5, "weight": 12, "state": "scored", "note": "Unchanged, and shown unchanged rather than assumed: of the 27 states recaptured before and after in real Chromium, 25 are byte-identical and the two that differ are the skip-link focus captures, at 0.153% and 0.172% of the viewport, which is the new ring. The drawer's focus trap and focus restore were re-verified in Chromium (:modal true on open, Escape closes, focus returns to the toggle rather than document.body, light and dark) and deliberately not asserted in jsdom, which has no implementation and where this file shims showModal()/close() with an open attribute. The pre-fix NIT stands: mobileNav={{isOpen: true}} with no onOpenChange is uncloseable with no dev warning." }, "design_objective": { "score": 4, "weight": 6, "state": "scored", "note": "Unchanged from the pre-fix pass and held at 4 by the same single open FIX: D11, zIndex 9999 on the skip link (AppShell.tsx:339), a magic number and the largest in the repo by a factor of twenty against ToastViewport's 500. Left open on purpose: theme/tokens.stylex.ts has no z-index scale to reach for, so the right value is a system decision and not something a nightly pass should invent, and a skip link cannot beat the browser top layer whatever number it carries. The D5 NIT (mobile top bar height from spacingVars rather than a --size-element-* token) also stands." }, "design_rendered": { "score": 4, "weight": 4, "state": "scored", "note": "Full state coverage again — 55 captures this pass, 27 before and 28 after — and held at 4 for the same reason as the pre-fix row: no tool in this pass could put an image in front of a person, so D3 spacing rhythm, D15 proportion and density and D16 AI tells were graded from decoded pixel data rather than by eye. The measurable rows pass: light-to-dark flips 99.97% of pixels (mean luminance 248.4 to 38.3), RTL mirrors 40.76%, the drawer dims the page 245.9 to 228.2 and returns within 0.37% of its pre-open state after Escape, skip-link text contrast is 15.0:1 light and 13.4:1 dark, and the settled reduced-motion drawer frame is pixel-identical to the normal one. The one intended visual change in the whole diff is the skip link's focus ring." }, "testing": { "score": 4.5, "weight": 8, "state": "scored", "note": "42 tests to 48, green, and the six new ones cover what had no coverage at all: user-event did not appear in this file before, and two fireEvent.click calls were its whole interaction surface. Added: Tab reaches the skip link and Enter moves focus to main; Enter on the nav toggle opens the drawer, flips aria-expanded and aria-controls names the drawer that opened; the mobile top bar is a banner landmark (red without its fix); exactly one banner landmark when a banner slot joins the mobile top bar; useAppShellMobile is inert outside an AppShell; and useAppShellMobile reports mobile state, the drawer id and the open transition — closing the pre-fix gap that the context and hook had no test at all. V7 focus trap and restore remain untested in jsdom by choice (no , so an assertion there would prove nothing) and are covered in Chromium instead, which is what holds this at 4.5 rather than 5. Nothing in the suite would have caught the renamed custom property leaving its consumers behind, which is the §2 finding's other half." }, "code_health": { "score": 4.5, "weight": 8, "state": "scored", "note": "C13 fixed: setMobileNavOpen now depends on mobileNavConfig?.onOpenChange rather than the whole config object, so the documented inline mobileNav={{isOpen, onOpenChange}} literal no longer gives the callback — and the mobileContextValue memo built from it — a new identity on every render of the shell, and no longer re-renders every useAppShellMobile consumer. The file's only lint warning is cleared (headerInner != null to isRenderable(headerInner) at AppShell.tsx:678); lint:strict on the repo goes 49 warnings to 48, none in AppShell. C4 stays open and is the reason this is not a 5: the header is still measured from a mount Effect keyed on isAuto (AppShell.tsx:576), so an auto-height shell that gains a topNav or banner after mount never gets an observer and the sticky sidenav offset stays 0. C4 asks for a callback ref; it is a real behaviour change in a load-bearing path and wants a test for the late-mounting header, so it is more than a nightly fix." }, "docs": { "score": 4.5, "weight": 8, "state": "scored", "note": "The X21 BLOCK is fixed, so the ceiling lifts: both worked examples of the mobileNav escape hatch passed title to MobileNav (AppShell.tsx:220 in the prop JSDoc, AppShell.tsx:444 in the component JSDoc) where MobileNavProps is declared extends Omit and the heading prop is header — copying either gave a type error and a drawer with no heading. Both now say header. X4 and X16 closed too: usage.anatomy lists the six named parts of a component that is entirely slots, and bestPractices gains the accessibility guidance a component owning the page's skip link, banner and main landmarks should carry. docsZh deliberately not updated — those entries are translations and should be written by someone who writes Chinese. X11/X12 stay open and hold this at 4.5: no story renders any variant but the default, and there is no narrow-container, long-text or empty story." }, "i18n_rtl": { "score": 4, "weight": 5, "state": "scored", "note": "Unchanged. Both AppShell-owned strings still go through useTranslator with catalog keys and descriptions, no physical properties, no directional icon of its own. rtl:audit --filter AppShell now runs and reports 0 fail and 0 surprise, but the verdicts are vacuous rather than reassuring: D1 auto-discovery is 1 N/A and positional mirror is 10 N/A, because no curated targets.json entry covers AppShell. RTL was checked in the browser instead, where 40.76% of pixels mirror. No pseudo-locale text-expansion pass, so it stays at 4 for missing evidence rather than for a defect." }, "responsive": { "score": 4, "weight": 5, "state": "scored", "note": "Unchanged, and 320px re-measured clean after the fixes: scrollWidth equals clientWidth on the kitchen-sink story with every control still present and 28px or larger. R11 stays open and holds it at 4 — still no narrow-container, long-text or empty story, so the a11y and RTL sweeps cannot see those states. The R8 NIT stands: the header region is still built twice, once as headerContent and once as autoMobileTopBar, which is why the banner landmark had to be applied in two places and conditioned on the other." } }, "blocks": { "count": 0, "open": [] }, "distinct_defects": 17, "fixes": 6, "nits": 3, "lastAudited": "2026-08-12", "rubricVersion": "1.3", "mode": "N", "commit": "d1441a63d7", "evidence": [ { "label": "§5 rendered pass : 55 Playwright/Chromium captures against local Storybook, 27 before and 28 after, published on the branch assets/pr-4944 under pr-assets/before and pr-assets/after", "note": "27 states recaptured on both sides: 25 byte-identical, the two skip-link focus captures the only difference. Judgment rows still graded from decoded pixel data rather than by a human viewing the images." }, { "label": "Fix PR #4944, merged to main as d1441a63d7", "note": "pnpm test 9804 tests with 6 pre-existing failures (2 known macOS APFS case-insensitivity, 4 timing-sensitive suites that pass in isolation); lint:strict 0 errors 48 warnings; a11y:audit --components AppShell 0 violations, gate passed; AppShell suite 42 to 48 tests green." }, { "label": "Post-merge check of the --appshell-header-height rename against the deployed docsite, in Chromium at 1440x900", "note": "The shell's inline style now reads --_app-shell-header-height while apps/docsite still reads the old name from a :root seed fixed at 48px. Header height measured 48/53/58/68px at root font 16/20/24/32px; at 24px the hero top, main padding and scroll-padding all resolve 48px against a 58px header. Identical at the default 16px. Recorded as an open FIX in §2." } ], "notes": "Post-fix score, replacing the pre-fix row (78.8 / C, 1 open BLOCK) recorded the same night before any fixing, per the standing amendment. The fixes are on main as d1441a63d7 (PR #4944), which is what this commit field names. Sensitivity: the total is carried by seven sections at 4.5 and four at 4; a half-step in any of a11y, theming or api moves the total by 0.7 to 0.8, and nothing here is within that of the B boundary at 80. P17 (§3) is withdrawn as a false positive rather than left open — see the api note — and that is a rubric fix, not a component fix. Left open by design: D11 (magic z-index, needs a z-index scale that does not exist), P23 (mobileNav polarity, a public API decision), C4 (measuring the header from a mount Effect, a real behaviour change wanting its own test) and X11/X12/R11 (the story file shows one variant). One new finding was opened by the fix pass itself and is recorded in §2 rather than hidden: the T23 rename is right for the component but apps/docsite still reads the old --appshell-header-height in 25 places across 9 files, so the docsite is pinned to its 48px :root seed and drifts under the real header as soon as a reader enlarges text. A follow-up PR should migrate those references and drop the stale --appshell-header-height entry from STRUCTURAL_VARS in derivedVarRegistry.test.ts. The general lesson for the rubric: T23 currently reads as a pure component rename, and it should require a repo-wide search for readers of the old name before the rename is credited. One system-level gap still routed rather than worked around: VisuallyHidden cannot express a skip link, since it omits xstyle/className/style and sets pointerEvents and userSelect to none, so AppShell's hand-rolled visually-hidden-until-focus block is not an A17 reimplementation." }, { "component": "AspectRatio", "package": "core", "status": "audited", "score": 83.9, "grade": "C", "sections": { "a11y": { "score": 4.5, "weight": 16, "state": "limited", "note": "limited: 1 of 18 applicable items returns a non-vacuous verdict. AspectRatio is a presentational container with no interactive element, no state, no strings, no animation and no painted surface, so A1-A17 pass by absence rather than by construction. The one real verdict is A18, measured rather than assumed: a11y:audit --components AspectRatio reports 0 violations across all 12 stories, the gate passes, and .github/a11y-baseline.json contains no AspectRatio entry at all, so there is nothing stale to harvest. A2 is N/A with a reason: there is no APG pattern for a ratio box, and the component correctly adds no role, no tabIndex and no aria-* of its own. A14 is N/A under the standing ruling that forced-colors triggers on painted state; this component paints no background, border or colour. Not scored 5 only because no screen-reader pass was run. One NIT recorded and not fixed: overflow: clip plus shape=\"ellipse\" silently clips anything in the corners, and no doc says so." }, "theming": { "score": 4.5, "weight": 14, "state": "scored", "note": "Both Vitest guards pass unmodified (themingTargets 279 tests, derivedVarRegistry 12) and lint:strict is clean on the component, the story and the six blocks. One rendered target, astryx-aspect-ratio, documented with visualProps ['shape'], on the only element that paints (it owns the ellipse radius and the clip); shape is the only style-driving prop and it is in the themeProps call, so T4/T5/T6/T7 hold. No colour, spacing, shadow or duration literal anywhere in the component (grepped for hex/rgb/hsl, boxShadow, textSizeVars, defaultMarker, ':hover' - all empty). 'use client' present, check-use-client green over 223 files. Verified rendered in neutral, stone and y2k, light and dark: geometry is invariant across all six and only the container padding moves with the y2k spacing scale. One open FIX holds this off 5: T19/P18, shape is a closed union (AspectRatioShape = 'rectangle' | 'ellipse') rather than an extensible AspectRatioVariantMap, so a theme cannot add a shape and type it at the call site. Unknown values already fall through to the base styles, which is half of what T19 asks. Left open deliberately: converting it is a public API change, and 6 shipped core components ship closed visual unions against 15 that use a variant map, so whether shape is a themeable axis is an open convention question, not a nightly correction. WITHDRAWN after checking the source: borderRadius: '50%' on the ellipse is not a T3/D4 raw-radius finding. The 50% is a shape function, not a roundness dial - it is what makes the clip follow the box and produce an oval at non-square ratios, and --radius-full (a large px value) cannot express it. The prop is named ellipse; a theme author changing that value would be removing the feature." }, "api": { "score": 4.5, "weight": 14, "state": "scored", "note": "P1 BaseProps, P2 ...rest spread on the primary rendered element (the one carrying themeProps), P15 ref declared on the props interface and forwarded plus displayName set, P16 type names, P17 exports reachable and sync:exports:check green, P19 no any, P20/P21 docPropLiterals and docPropReferences green, P30 ratio and children required and nothing else, P31 the shape default agrees across source, JSDoc and .doc.mjs, P34 both @example blocks use bare fences, P36 file header present and check-sync green. P10 checked against the library rather than assumed: no other core component exposes a public shape prop (AvatarStatusDot's is an internal glyph type) and none exposes fit, and cover/contain/center is object-fit's own vocabulary, so there is no parallel name to converge with. Held at 4.5 by P18, cross-referenced from T19 and scored there: the shape union is hand-maintained. One NIT recorded, not fixed and not worth a mechanism: {...props} is spread after themeProps, so a consumer could clobber data-shape and detach the theming surface. Nothing in core does it, no doc suggests it, and the fix if anyone wants one is to re-apply the owned attributes after rest, not a runtime guard." }, "behavior": { "score": 3, "weight": 12, "state": "scored", "note": "Ceiling 3 from one open BLOCK. Everything measured in real Chromium against the built Storybook, not read off the CSS. What passes: ratio fidelity is excellent - 63 measurements over 9 ratios (from 9/16 to 2.35) x 7 container widths (180 to 600px), worst error 0.016%, and the box holds the declared ratio at every one. fit=cover, contain and center each do exactly what the docs say (measured object-fit and child rects). The box is stable when its media fails to load (568x319.5 before and after pointing the img at a broken src). No horizontal scroll and no collapsed box at 320px across all 12 stories. BLOCK, open: with fit=\"cover\" or \"contain\" the reset.css rule [data-astryx-aspect-ratio-override] > :where(*) sizes EVERY direct child to 100%/100%, so a second child is laid out below the first and pushed entirely outside the box, where overflow: clip makes it invisible and unscrollable. Measured with 3 children in a 600x337.5 box: children at y offsets 0, 337.5 and 675, total child height 1012.5. children is typed ReactNode and no doc restricts it to one node, so image-plus-overlay-caption - ordinary code - silently loses the caption. Not fixed tonight: the candidate fix is scoping the rule to :only-child versus stacking the children, that is a semantics decision for a shared reset.css that every consumer inherits, and it belongs to a human. FIX, open: the box has no intrinsic size and pins width: 100%, and both consequences were measured. (a) A height constraint alone silently breaks the ratio - maxHeight: 120px on a 16/9 box renders 568x120, a measured ratio of 4.73, and minHeight: 400px renders 1.42; the remedy is to pass width: 'auto' alongside, which restores it to 213.33x120. (b) In a shrink-to-fit parent (inline-flex, width: fit-content, max-content) the box collapses to 0x0. The repo already works around both: AspectRatioShowcase.tsx writes style={{height: 120, width: 'auto'}} and AspectRatioImageGallery.tsx carries a comment explaining the zero-width collapse in the docsite preview. Three alternative width values were measured over a 165-cell matrix (auto, stretch, minWidth: fit-content, flexBasis: auto) and every one is worse than the shipped 100%: auto and stretch collapse in flex rows too. There is no CSS one-liner, so the real answer is an explicit sizing API and that is a spec decision. Documented in this pass instead of left silent." }, "design_objective": { "score": 5, "weight": 6, "state": "limited", "note": "limited: 2 of 11 objective rows return a non-vacuous verdict, unchanged. Both pass. D4 stands as a pass after review rather than a deduction: borderRadius '50%' on the ellipse is a shape function, not a roundness dial, and --radius-full cannot express it. The pre-fix NIT is fixed: the Ultrawide21x9 placeholder no longer carries a 135deg #667eea to #764ba2 gradient with color: 'white' (a raw literal outside the theming-demo exception and the canonical D16 AI tell); it reuses the token-based placeholder the other stories use. Measured consequence: that capture moves 62.35% of its pixels and its mean luminance goes 164.1 to 246.1 in light and 82.8 to 31.3 in dark, which is the placeholder starting to follow the theme instead of ignoring it. First attempt at the fix used --color-accent behind a Text that keeps its own colour, and a11y:audit caught the resulting contrast violation before it left the machine; recorded because it is the gate doing its job." }, "design_rendered": { "score": 4, "weight": 4, "state": "scored", "note": "36 captures after the fixes against 30 before, all against a rebuilt Storybook with a rebuilt core, so nothing is stale. Held at 4 for the same reason as both previous nightly runs: no tool in this pass can put an image in front of a person, so D3, D13, D15 and D16 were assessed from decoded pixel data and measured geometry rather than by eye. What the pixels say: of the 30 captures that exist on both sides, 28 are byte-identical and the two that differ are the intended placeholder change, at 62.35% each. Light-to-dark separation is real on every story (249.4 to 33.8 on all-ratios-comparison, 253.6 to 41.0 on with-placeholder-skeleton). Geometry identical across neutral, stone and y2k in light and dark. The state-visual conformance matrix stays short by construction: AspectRatio has no hover, focus, pressed, disabled, loading, status or selected state, so there is no approved representation to match and the captured states are rest per ratio, per fit and per shape." }, "testing": { "score": 4.5, "weight": 8, "state": "scored", "note": "26 tests, green. Two added this pass close the one genuinely untested public contract: the consumer style prop survives the merge and the computed aspectRatio wins over a consumer-supplied one (mergeProps(..., {...style, aspectRatio: ratio}) is the single non-obvious line in the component and nothing asserted either half), and a consumer className lands beside the astryx-aspect-ratio target rather than replacing it. Everything from the pre-fix row still holds: each ratio, the ellipse at a non-square ratio, the shape default, ref forwarding, passthrough, and the block that parses reset.css and pins the fit baseline selectors. a11y:audit re-run against the rebuilt Storybook: 0 violations across all 15 stories, 0 new baseline entries. Not 5: the multiple-children path is still untested, which is where the open BLOCK lives, and rtl:audit --filter AspectRatio still returns 0 fail with every verdict N/A (1 auto, 15 positional) for want of a curated targets.json entry, so its reassurance stays vacuous and the browser RTL check is the real evidence." }, "code_health": { "score": 5, "weight": 8, "state": "limited", "note": "limited: 3 of 21 items return a non-vacuous verdict. There is nothing here to get wrong - zero Effects, zero useState, zero refs read in render, zero observers, zero timers, zero listeners, so C1 through C11, C13, C14, C16, C17 and C19 all pass by absence. The three real verdicts all pass: C18, the component is presentational and hook-free and the lint rule that errors in both tiers is green; C20, the only numeric literals are layout values (width: 100%, borderRadius: 50%, inset 0) with no magic constants in interaction math; C21, the four comments in the file all explain why rather than what - why the marker attribute exists, why the child sizing lives in reset.css, and why 50% is not --radius-full - which is exactly what C21 asks for." }, "docs": { "score": 4.5, "weight": 8, "state": "scored", "note": "Ceiling lifted: all three pre-fix FIXes are closed. X4, usage.anatomy now names the two parts (the ratio box that carries the theme target and the clip, and the content slot that positions and optionally sizes the child), added to docs, docsZh and docsDense alike. X16, bestPractices gains the accessibility guidance a media wrapper should carry: AspectRatio adds no role and no accessible name, so the child's alt is the whole description, and decorative media takes alt=\"\". The raw radius literal is gone from the shipped example a consumer copies: AspectRatioImageGallery.tsx now uses var(--radius-element), matching its sibling showcase block. usage.description no longer claims the ratio holds 'regardless of screen size', which the measurements disproved, and it now says where the width comes from. Two new don'ts carry the measured sizing traps and one new do carries the single-child expectation, so bestPractices is 5 do and 4 don't. X21 re-checked after the edits and still clean. typecheck:docs, docPropReferences and docPropLiterals green. Not 5: there is still no composition story (X11 asks for the component inside Dialog, Table, Card or AppShell), and docsZh remains English prose with Chinese prop descriptions, which is a 39-of-81 library-wide gap rather than this component's, but it still leaves these docs incomplete." }, "i18n_rtl": { "score": 4.5, "weight": 5, "state": "limited", "note": "limited: 3 of 18 items return a non-vacuous verdict. The component renders no string of its own, so I1 through I7 and I18 pass by absence. The real verdicts pass: the child wrapper anchors with insetInlineStart rather than left (I8/I9), and there is no physical translate paired with a logical anchor (I11). Measured in Chromium with direction: rtl on the fit-modes story: all three boxes render flush with their container, the inner wrapper offset is 0.00px in both directions, and there is no horizontal scroll. top: 0 sits beside insetInlineStart in the child style and is NOT recorded as a finding: the repo's own @astryx/no-physical-properties rule maps only the inline axis (left/right, margin, padding, border, corner radii) and deliberately omits top/bottom, because the block axis does not flip under RTL. The rubric's T13/I8 wording, 'logical properties only', over-states what the repo enforces." }, "responsive": { "score": 4.5, "weight": 5, "state": "scored", "note": "R11 closed: three stories added, so the a11y and RTL sweeps can now see the states that had none. NarrowContainer renders in a 240px container, EmptyAndLongContent covers both a child with no media and text that overruns the box, and HeightDriven demonstrates the width: 'auto' remedy for the sizing trap. All 15 stories re-measured at a 320px viewport after the change: scrollWidth equals clientWidth on every one, nothing collapses, smallest box 74.7px. Worth recording because the first version of HeightDriven regressed this: three fixed-height boxes in a non-wrapping HStack pushed scrollWidth to 557 at 320px, the re-measurement caught it, and wrap=\"wrap\" fixed it. R1, R2 and R7 unchanged and passing. Not 5: no coarse-pointer capture, which is vacuous here since the component has no pointer interaction, but it was not done." } }, "blocks": { "count": 1, "open": [ { "id": "B12", "summary": "§4 With fit=cover/contain the reset.css rule sizes every direct child to 100%/100%, so a second child is pushed outside the box and clipped invisible; children is typed ReactNode and nothing warns. Measured: 3 children in a 600x337.5 box land at y=0, 337.5 and 675.", "issue": null } ] }, "distinct_defects": 6, "fixes": 1, "nits": 2, "lastAudited": "2026-08-13", "rubricVersion": "1.4", "mode": "N", "commit": "079588550a", "evidence": [ { "label": "Rendered geometry pass: 165-cell width matrix plus a 63-cell ratio-fidelity sweep in real Chromium against the built Storybook", "note": "Ratio fidelity worst error 0.016% over 9 ratios x 7 container widths. Constraint matrix shows maxHeight/height/minHeight alone break the ratio (16/9 rendering as 4.73, 5.0, 1.42, 0.8 depending on the constraint) and that width: 'auto' alongside restores it. Shrink-to-fit parents collapse the box to 0x0. Three alternative width values measured and all are worse than the shipped 100%." }, { "label": "30 screenshots: 12 stories x light and dark, 3 at 320px, 1 RTL, ellipse in stone and y2k", "note": "Captured before any fix. Judgment rows graded from decoded pixel data, not by eye, which is what holds design_rendered at 4." }, { "label": "Automated battery, all green on the component", "note": "24 unit tests; themingTargets 279 and derivedVarRegistry 12; docPropReferences and docPropLiterals; typecheck:docs; check-use-client 223 files; sync:exports:check; check:sync; lint:strict clean on the component, story and blocks; a11y:audit 0 violations over 12 stories with 0 baseline entries; rtl:audit 0 fail but every verdict N/A." }, { "label": "Released-surface check before citing any migration rule, per rubric v1.4", "note": "npm view @astryxdesign/core@latest exports shows ./AspectRatio published in 0.3.0, so the surface is released and P11/T33/X20/I7 would bind if anything were removed. Nothing is removed in this pass." } ], "notes": "POST-FIX scorecard, PREPARED AND HELD, deliberately not pushed. Per Cindy's standing amendment the post-fix row goes into the ledger only once the fixes are on main, so the commit field above is a placeholder and must be replaced with the real main sha for PR #4984 before this is recorded. The pre-fix row (80.6 / C, 1 open BLOCK) was pushed the same night as wiki commit c261bea44a and is what the ledger currently holds. Delta 80.6 to 83.9, +3.2, from three half-steps: testing 4 to 4.5 (the style-merge contract is tested), docs 4 to 4.5 (the X4, X16 and raw-radius FIXes all closed, lifting the 3-to-5-FIX ceiling) and responsive 4 to 4.5 (R11 closed with three new stories). Theming, api, behavior and design_rendered are unchanged, and shown unchanged rather than assumed: every geometry measurement (ratio fidelity, constraints, parent contexts, fit modes, broken media, ellipse, RTL, theme invariance) is byte-identical before and after, because this diff is docs, stories and tests only. The grade stays C because the BLOCK is still open. Sensitivity: fix the one open BLOCK and change nothing else, and behavior goes from its ceiling of 3 to about 4, which is +3.7, so roughly 87.5 / B." }, { "component": "Avatar", "package": "core", "status": "audited", "score": 78.4, "grade": "C", "sections": { "a11y": { "score": 2.5, "weight": 16, "state": "scored", "note": "Unchanged, and shown unchanged rather than assumed. A18 closed: the two resolved Avatar color-contrast entries are gone from .github/a11y-baseline.json, and the post-fix run reports 0 new, 0 baselined, 0 resolved across 18 stories (up from 15, because the new Interactive story means the axe sweep reaches the standalone /