test(swc): add dedicated Chromatic VRT stories#6463
Conversation
Chromatic was snapping every docs story automatically, and reviewing VRT-only coverage in local Storybook was hard since none of it had its own sidenav entry. This adds hand-authored `*.vrt.ts` story files (a new Storybook indexer/glob, discovered like `.stories.ts` files) that combine permutations into as few snapshots as possible, nested under `<Component>/VRT` in the sidenav. Chromatic's snapshotting now defaults to disabled globally and is re-enabled only on VRT files. A new `vrt` Storybook mode (env-gated, CI-only) builds just the `*.vrt.ts` files so the Chromatic-hosted catalog only lists what it actually snapshots; local dev keeps the full story set as before. Prototyped on Button; a shared `row()` helper and the existing `staticColorsDemo` decorator (already used by docs stories) are exported for reuse by future component VRT files.
|
📚 Branch Preview Links🔍 Gen1 Visual Regression Test ResultsWhen a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:
Deployed to Azure Blob Storage: If the changes are expected, update the |
Coverage Report for CI Build 29776849829Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage remained the same at 96.243%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats💛 - Coveralls |
…nd text behaviors Combine light/ltr and dark/rtl coverage, icon-slot anatomy (label-only, icon+label, icon-only), and text wrapping/truncation into the single Permutations story so it still costs one Chromatic snapshot. Adds shared `theme()` and `staticColorBackground()` helpers to .storybook/helpers/vrt.ts for reuse across future component VRT files. Drops getStorybookHelpers' template()/args/argTypes in favor of plain lit `html` bindings: more legible for a Controls-free story, and it avoids a real bug in observe-slot-text.ts, where a Lit child-position marker comment left by template()'s icon-slot serialization gets misread as label text, permanently defeating Button's `iconOnly` detection. Uses the same static-markup workaround button.stories.ts's own Anatomy story already relies on.
PR preview deploys were building the production-trimmed Storybook, which excludes internal stories, .test.ts fixtures, and (previously) VRT stories. Add an explicit SWC_STORYBOOK_MODE=dev override in main.ts, since `storybook build` forces NODE_ENV=production itself before the config runs, and wire it into preview-docs.yml. Also fixes an unescaped `|` inside a GFM table code span in the sidenav accessibility migration analysis doc, which broke MDX parsing once contributor-docs was included in a static build for the first time (previously only ever served live via `storybook dev`).
:hover and :active can't be triggered by synthetic events, and static VRT captures have no real pointer; :focus-visible has a similar heuristic gotcha. Adds a forcePseudoState() helper that mirrors a component's own :hover/:focus-visible/:active rules (in its shadow root's adopted stylesheets) into equivalent class selectors, then applies the matching class to its internal element. Takes the core trick from aziz/storybook-test-grid's pseudo-states tooling, but drops the generic data-vrt-host/data-vrt-control indirection, the toolbar toggle, and the recursive whole-document tree walk — none needed since we hand-write each VRT file and already know exactly which internal part needs the class. Wired into button.vrt.ts's Permutations story via a `play` function, covering hover/focus-visible/active for every variant.
Button's pseudo-states are styled via an internal shadow part (.swc-Button:hover); Tabs styles them directly on :host() (see tab.css). forcePseudoState()'s internalSelector param is now optional — omit it to force the class onto the host element itself instead of querying inside its shadow root. Reordered the signature to (host, state, internalSelector?) so the common case doesn't need a placeholder argument. Adds tab.vrt.ts as the second VRT file, covering both Tab densities (regular/compact) with default/selected/disabled tabs, plus forced hover/focus-visible/active. Confirmed visually (not just by class presence) that the mirrored :host(:hover)/:host(:active)/ :host(:focus-visible) rules actually render — text-color differences for hover/active, and the focus ring outline for focus-visible.
SWC intentionally never sets a page-level default text color (left to the consuming app, same policy as background), so theme()'s dark wrapper only setting background-color left plain slotted content (e.g. TabPanel's text) with nothing to inherit — it stayed illegible dark-on-dark. Add a matching color alongside background-color. Found via tab.vrt.ts's dark/rtl block.
static-color collapses semantic variants into two treatments: "solid" (primary/accent/negative, sharing one set of tokens) and "secondary" (its own, more subtle transparent-*-200/900 tokens) -- confirmed in button.css's :host([static-color=...][variant="secondary"]) overrides. The static-color rows only covered the solid treatment. Adds secondary alongside solid for both the base fill/outline permutations and the forced hover/focus-visible/active states, for both static-color white and black.
tab.vrt.ts was added to validate forcePseudoState()'s :host()-based pseudo-state support against a second component. That validation is done; keeping this PR scoped to Button, its dedicated VRT prototype. The :host() support itself stays in pseudo-state.ts for reuse whenever Tabs (or another :host()-styled component) gets its own VRT file.
Replace the composed-boolean-flags version with a flat STORIES_BY_MODE record: each mode's story list is spelled out in full, so "what does build actually include?" is answered by reading one array instead of tracing conditionals scattered through the file. Shared chunks (GUIDES, CORE_AND_CONTRIBUTOR_DOCS, TEST_FIXTURES) are only pulled out where the exact same array is reused verbatim across modes. Also drops `as const` from the extracted story-root/list constants — StorybookConfig['stories'] entries just want plain `string` fields, so it wasn't doing anything. Verified via yarn analyze + a build per mode (typescript.check: true catches type errors) that story counts are unchanged: dev 871, build 286, ci-a11y 252, vrt 1.
Chromatic supports `forced-colors` natively via parameters.chromatic.forcedColors (see tooltip.test.ts's ForcedColorsOpenTest for the existing pattern) -- a real browser media-feature emulation, not a hack like the :hover/:active/ :focus-visible forcing in pseudo-state.ts. Because it flips the whole page's palette rather than being scoped to a subtree, it can't fold into the existing Permutations snapshot the way light/dark/rtl does, so it gets its own story/snapshot. Verified via Playwright's forced-colors media emulation (the same mechanism Chromatic uses) that Button's @media (forced-colors: active) overrides actually render correctly.
Pulling the .vrt.ts glob out of the shared stories array (so it wouldn't leak into the production build) also removed it from dev's array, since both used to share that base list. dev should still show VRT stories locally -- only build/ci-a11y need to exclude them. Adds a shared VRT_STORIES chunk, included in dev's array and reused as-is for vrt mode's own array.
SWC_STORYBOOK_MODE=vrt set as a workflow-step env var only reached CI's chromaui/action run, not the local Chromatic "Visual Tests" addon panel -- that panel runs its own storybook build in the background, inheriting whatever environment the dev server itself was started with, which defaults to `build` (production) mode. buildScriptName is a real, validated Chromatic config option (not just CLI/addon UI metadata), read by the CLI, chromaui/action, and the local addon panel alike. Point it at a new storybook:build:vrt script that bakes SWC_STORYBOOK_MODE=vrt into the script itself, so every trigger path resolves the same way without depending on ambient shell state. Drops the now-redundant env override from chromatic-vrt.yml.
There was a problem hiding this comment.
I'm making the PR previews have ALL the things (test stories, contributor docs, etc!) - I think it makes it easier to review 😄
There was a problem hiding this comment.
Cleaned up this config file so it would be easier to read the what each Storybook mode includes
Registering an indexer alone does nothing -- it only runs against files the `stories` glob array actually surfaces. Only dev's array (via TEST_FIXTURES) includes a **/*.test.ts pattern at all, so gating testStoryIndexer on storybookMode === 'dev' was redundant; the stories config was already the real gate. Verified builds for build/ci-a11y/vrt are unaffected (286/252/2 entries, same as before).
forcePseudoState() previously only mirrored a shadow root's *adopted* stylesheets, so it silently did nothing for native <a>/<button> elements styled via global-button.css's classes -- that stylesheet applies at the document level, wrapped in `@layer swc-global-elements`, not a shadow root. Adds a document-level mirroring path: walks document.styleSheets and injects a <style> element with the mirrored rules, applied when a target has no shadowRoot. Also generalizes the rule collector to recurse into grouping rules (@media/@layer/@supports/@container) -- needed here since global-button.css wraps everything in one @layer, unlike Button/Tab's shadow-scoped rules which were never nested.
Full audit of Button's public API against button.vrt.ts's existing coverage turned up two gaps: - justified was untested. Added at a comfortable width and a very narrow one (a "min width" check -- confirms the button wraps text gracefully instead of collapsing or overflowing, since there's no enforced minimum). - pendingLabel is deliberately NOT added: it only changes the accessible name while pending, no visual effect, so it doesn't belong in a VRT. Adds two new stories: - GlobalStyles: native <a>/<button> elements using global-button.css's BEM classes (variant/fill-style, sizes, static colors, icon anatomy, truncate, justified, and forced hover/focus-visible/active), to confirm the shared stylesheet produces identical results to the swc-button component it's generated from, regardless of element type. `disabled` is button-only, matching the documented limitation that native links can't support a real disabled state. - CustomProperties: every `--swc-button-*` custom property is a public contract (see the Global Element Styling guide) -- a future CSS refactor that quietly drops one would be a breaking change. One row per property: a reference button next to the same button with that one property overridden to an obviously different value, forcing the relevant pseudo-state/disabled state for state-dependent properties so the difference is actually visible. ForcedColors also gained the same forced hover/focus-visible/active play function as Permutations, since forced-colors mode has its own UA-mandated focus-ring behavior worth confirming alongside the system-color palette. The repeated play-function logic across all three shadow-DOM-aware stories is now a shared applyForcedStates() helper.
…leSnapshot dance Dedupes the static-color gradient constant and ForcedPseudoState type, caches pseudo-state CSS mirrors by stylesheet instead of by shadow root, collapses three identical play functions into one, and removes the now-vestigial chromatic.disableSnapshot opt-in/opt-out now that the vrt build mode's stories glob already limits Chromatic's catalog to *.vrt.ts files only.
5t3ph
left a comment
There was a problem hiding this comment.
Really happy to see the progress on this!
Can we insert the component name vs just "VRT" to make it easier to identify in batch changes? (screenshot from your draft PR)
Also, I would really like some sort of sub-labeling to make identifying permutations with issues easier / checking all expected permutations exist. Esp for something like static colors, I have no idea what states are here at a glance 😅
| // Main button permutations: variant x fill-style x size, disabled/pending, | ||
| // forced hover/focus-visible/active per variant, and icon+label anatomy. | ||
| // Static-color and icon-only anatomy are deliberately separate below. | ||
| const BUTTON_PERMUTATIONS = createPermutations([ |
There was a problem hiding this comment.
Missing:
- disabled outline
- pending outline
|
|
||
| // Static-color buttons need contrast backgrounds, so keep their permutations | ||
| // separate from the main set instead of filtering them at render time. | ||
| const STATIC_COLOR_PERMUTATION_GROUPS = BUTTON_STATIC_COLORS.map((color) => ({ |
| // button.stories.ts's own Anatomy story hits the same bug and sidesteps it | ||
| // the same way: writing the markup directly instead of going through | ||
| // template()'s icon-slot. | ||
| const ICON_ONLY_PERMUTATIONS = createPermutations([ |
…gaps Rename the VRT story leaf to 'Button VRT' so the Chromatic component group is identifiable in batch review instead of a generic 'VRT'. Add the disabled and pending states that were missing on the outline fill-style, static-color, and icon-only anatomy permutations.
The core package was renamed from @spectrum-web-components/core to @adobe/spectrum-wc-core on main; update the button VRT import to match now that main is merged in.
Split the dense single permutation row into one row per variant (and per pseudo-state) via a new groupPermutationsBy helper, so a Chromatic diff is easy to scan. Buttons keep a plain label since the row heading conveys the variant/state. Label rows with the swc-Detail typography classes, stack static-color groups vertically, and override the detail font color to currentColor so labels stay legible on the contrast backgrounds. Also name the remaining button VRT files' group 'Button VRT'.
Document groupPermutationsBy and the judgment call it requires: group by the axis that carries the most meaning (variant, size, ...) or keep a single row when a component has no natural grouping axis, split pseudo-states into their own rows, keep component labels plain, and label rows with swc-Detail.
VRT stories live under components/ and match the coverage include glob, so they were being counted in code-coverage metrics like source. They are visual-test scaffolding, not product code; exclude *.vrt.ts alongside the existing *.test.ts and *.stories.ts exclusions.
ooooh great call! fixed |
Document that a multi-item row only works when each item carries visible text that names it; small controls and fixed-content widgets whose states look near-identical should get one labeled row per state instead of a generic 'States' row.
Storybook derives each story's display name from its export name using Title Case (ForcedColors -> 'Forced Colors'), which doesn't match this project's sentence-case convention. Rather than adding a storyName override to every multi-word export across every .vrt.ts file, lowercase every word but the first (preserving all-caps acronyms) once in the vrtStoryIndexer, so every VRT story picks up the fix automatically.
Match the spacing pass done on the VRT rollout branch, since button.vrt.ts shares these same helpers: - vrtParameters' outer gap: 16px -> --swc-spacing-500 (32px), more room between rows. - row()'s label-to-content gap: 4px -> --swc-spacing-100 (8px). - row()'s item gap (both branches): 16px -> --swc-spacing-400 (24px), and tokenized. - theme() and staticColorBackground()'s outer gaps: 16px -> --swc-spacing-400, and tokenized.
…ok build button-custom-properties.vrt.ts statically imports the build-generated, gitignored .storybook/custom-elements.json. In CI, Chromatic's build has failed repeatedly with an unresolved-import error immediately after yarn analyze reports success, while the same command succeeds reliably locally; cem's own write (fs.writeFileSync) is synchronous, so the failure is not inside that tool. Add a `test -s` gate between analyze and the storybook build so a missing or empty manifest fails fast with a clear signal, instead of surfacing deep inside Rollup as a confusing "could not resolve import" error.
main moved the generated custom-elements.json manifest from .storybook/custom-elements.json to dist/custom-elements.json (cem.config.js outdir, preview.ts, blocks/*.tsx, package.json's customElements field). This branch was 58 commits behind main and still pointed at the old path in button-custom-properties.vrt.ts and the new verify-cem-manifest.mjs script, which is what Chromatic's CI build was actually failing to resolve. Verified end-to-end: a clean `yarn storybook:build:vrt` (no prior dist/ or .storybook/custom-elements.json) now builds successfully.
|
@5t3ph Tried to address your comments, please check the latest run: |
The actual bug was a stale custom-elements.json path (now fixed), not a race in cem's file write (which is synchronous). The verify script doesn't prevent anything the build's own unresolved-import error wouldn't already catch; keeping it around is unnecessary ceremony for a problem that's already root-caused.
Merging main brought in button-custom-properties.vrt.ts (added by PR #6463 after this branch diverged), which enforces that every @cssprop documented on Button.ts has a matching visual test row. This docs PR documented --swc-button-down-state-transform (and --swc-button-max-inline-size, already covered) in Button.ts's JSDoc without a corresponding row, which failed the coverage check on the PR/main merge build ("Button VRT: Custom Properties"). Add the missing row, forcing the active state and overriding to a visually obvious rotate() so the reference/override pair shows a real difference. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Summary
.vrt.tsStorybook mode for Chromatic so only intentional VRT stories are snapshotted.Test plan
yarn lint:aifor AI/docs updates.