Skip to content

test(swc): add dedicated Chromatic VRT stories#6463

Merged
rubencarvalho merged 42 commits into
mainfrom
rcarvalho/feat-dedicated-vrt-stories
Jul 20, 2026
Merged

test(swc): add dedicated Chromatic VRT stories#6463
rubencarvalho merged 42 commits into
mainfrom
rcarvalho/feat-dedicated-vrt-stories

Conversation

@rubencarvalho

@rubencarvalho rubencarvalho commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add dedicated .vrt.ts Storybook mode for Chromatic so only intentional VRT stories are snapshotted.
  • Add shared VRT helpers for permutations, themes, static colors, pseudo-states, and custom-property coverage checks.
  • Apply the pattern to Button with split permutation, global-style, and custom-property VRT stories.
  • Document the dedicated VRT authoring pattern for migrations and contributors.

Test plan

  • Storybook Vitest for Button VRT stories.
  • Prettier and ESLint on touched VRT/helper files.
  • yarn lint:ai for AI/docs updates.
  • Contributor docs nav script run; it reports existing unrelated broken links in sidenav/toast analysis docs.

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.
@changeset-bot

changeset-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b61c0fd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

📚 Branch Preview Links

🔍 Gen1 Visual Regression Test Results

When 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: pr-6463

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

@coveralls

coveralls commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 29776849829

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage remained the same at 96.243%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 39208
Covered Lines: 37935
Line Coverage: 96.75%
Relevant Branches: 6466
Covered Branches: 6023
Branch Coverage: 93.15%
Branches in Coverage %: Yes
Coverage Strength: 461.13 hits per line

💛 - 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.
@rubencarvalho rubencarvalho added the run_vrt Triggers the Chromatic VRT run for 2nd-gen label Jul 2, 2026
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.
@rubencarvalho
rubencarvalho marked this pull request as ready for review July 3, 2026 14:06
@rubencarvalho
rubencarvalho requested a review from a team as a code owner July 3, 2026 14:06
@rubencarvalho rubencarvalho added 2.0.0 Status:Ready for review PR ready for review or re-review. labels Jul 3, 2026
rubencarvalho and others added 5 commits July 3, 2026 16:06
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken from #6317

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm making the PR previews have ALL the things (test stories, contributor docs, etc!) - I think it makes it easier to review 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 5t3ph left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Image

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 😅

Image

// 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([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing

  • disabled
  • pending

// 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([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing:

  • disabled
  • pending

…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.
@miwha-adobe

Copy link
Copy Markdown
Contributor

It looks like in vitest.config we exclude .stories and .tests. Since vrt lives under components, this will get included in the code coverage calculations. Is that desired? If not we should add vrt to the exclude list as well.

Screenshot 2026-07-15 at 3 44 21 PM

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.
@rubencarvalho

Copy link
Copy Markdown
Contributor Author

It looks like in vitest.config we exclude .stories and .tests. Since vrt lives under components, this will get included in the code coverage calculations. Is that desired? If not we should add vrt to the exclude list as well.

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.
@rubencarvalho rubencarvalho removed the run_vrt Triggers the Chromatic VRT run for 2nd-gen label Jul 16, 2026
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.
@rubencarvalho rubencarvalho added the run_vrt Triggers the Chromatic VRT run for 2nd-gen label Jul 16, 2026
…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.
@rubencarvalho
rubencarvalho requested a review from 5t3ph July 16, 2026 15:37
@rubencarvalho

Copy link
Copy Markdown
Contributor Author

@5t3ph Tried to address your comments, please check the latest run:
https://www.chromatic.com/build?appId=669e5de19faa57de85e1c1bb&number=556&groupPrefix=Components%2FButton
LMK if there's anything I should update!

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.

@5t3ph 5t3ph left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, LGTM!

@rubencarvalho
rubencarvalho enabled auto-merge (squash) July 20, 2026 20:38
@rubencarvalho
rubencarvalho merged commit 168ed73 into main Jul 20, 2026
30 of 33 checks passed
@rubencarvalho
rubencarvalho deleted the rcarvalho/feat-dedicated-vrt-stories branch July 20, 2026 20:43
Rajdeepc pushed a commit that referenced this pull request Jul 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2.0.0 High priority PR review PR is a high priority and should be reviewed ASAP run_vrt Triggers the Chromatic VRT run for 2nd-gen Status:Ready for review PR ready for review or re-review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants