Add Sheet, ErrorBanner, Skeleton and EmptyState primitives
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high)
CI / ci (pull_request) Successful in 1m28s
CI / ci (push) Successful in 1m28s

Sheet is a portalled modal bottom sheet: role="dialog" with aria-modal
and a required accessible name, focus moved in, trapped and restored on
close, Escape and scrim click call onClose, and the rest of <body> is
inert while open. It rises from translateY(100%) over 320 ms
var(--ease-out-soft) over a scrim fading to 50% neutral-900; under
prefers-reduced-motion it opens in place.

ErrorBanner is a muted neutral-100 Card with grape-700 text and a Retry
Button; Skeleton is a --color-surface pill with a 1.2 s opacity pulse
switched off under reduced motion; EmptyState has a Bagel title, body
and an optional primary Button action.

jsdom never matches prefers-reduced-motion, resolves var() colours to
transparent and does not expand the animation shorthand, so the new
src/test/cssRules.ts reads the CSS Modules' declared rules, the
reduced-motion media block and the hashed @keyframes directly.

Maestro task: 2f9b64dd-0c26-47b9-b2b8-557bdf506d68

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:09:47 -04:00
co-authored by Claude Opus 5.5
parent 02f1dd6491
commit 1dd97b123c
14 changed files with 619 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
// Reads the CSS Modules' rules straight from the stylesheets Vitest injects
// (css: true). jsdom's getComputedStyle cannot answer two things these
// primitives need checked: it resolves var() colours to transparent, and it
// evaluates only "all" and "screen" media queries, so styles inside
// @media (prefers-reduced-motion: reduce) never apply.
function cssRules(): CSSRule[] {
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
}
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
for (const rule of rules) {
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
for (const property of Array.from(rule.style)) {
style.setProperty(property, rule.style.getPropertyValue(property))
}
}
}
// The declarations the stylesheets give the element, as written. Read the
// shorthands the CSS uses (background, animation): jsdom does not expand
// animation, nor any shorthand holding a var(). Later rules win; specificity
// is not considered.
export function declaredStyle(element: Element): CSSStyleDeclaration {
const style = document.createElement('div').style
collect(element, cssRules(), style)
return style
}
// Only the declarations @media (prefers-reduced-motion: reduce) gives the element.
export function reducedMotionStyle(element: Element): CSSStyleDeclaration {
const style = document.createElement('div').style
for (const rule of cssRules()) {
if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) {
collect(element, Array.from(rule.cssRules), style)
}
}
return style
}
// The @keyframes the element's animation runs. CSS Modules hash keyframe
// names, so this follows the element's own animation-name.
export function animationKeyframes(element: Element): CSSKeyframesRule {
const [name] = declaredStyle(element).animation.split(' ')
const keyframes = cssRules().find(
(rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name,
)
if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`)
return keyframes
}