Skip to content

test: add dedicated VRT stories for components and patterns - #6511

Draft
rubencarvalho wants to merge 64 commits into
mainfrom
rcarvalho/test-add-vrts-to-components
Draft

test: add dedicated VRT stories for components and patterns#6511
rubencarvalho wants to merge 64 commits into
mainfrom
rcarvalho/test-add-vrts-to-components

Conversation

@rubencarvalho

Copy link
Copy Markdown
Contributor

Description

Adds dedicated .vrt.ts visual regression stories across 20 components and 11 conversational-AI patterns, following the dedicated-VRT-stories pattern established in this base branch.

Each unit gets a permutation matrix covering its meaningful visual axes (sizes, variants, states, anatomy, themes, static-color), plus separate stories where applicable for:

  • Forced colors (Windows high-contrast)
  • Global styles (plain-class API from stylesheets/global/)
  • Custom properties

Composed patterns use deterministic realistic content (fixed prompts, sources, attachments, feedback states, response text) rather than behavior demos, so snapshots stay stable.

Motivation and context

Demonstrates how the dedicated VRT authoring approach scales across the full component and pattern set, so the team can review the story shape, coverage density, and Storybook sidebar ordering before we standardize on it.

Related issue(s)

Notes for reviewers

This is a demo/preview PR targeting the feature branch, not main. Open Storybook and look under each component/pattern's VRT group (Permutations → Forced Colors → Global Styles → Custom Properties).

rubencarvalho and others added 28 commits July 1, 2026 18:31
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.
…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.
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.
Renders icon+label and icon-only permutations at every size instead of just
the default, and drops the now-redundant standalone label-only row since the
sized icon+label rows already cover it.
That change is now its own dedicated PR (#6473). Removes the unused
SWC_STORYBOOK_MODE=dev branch from main.ts along with the preview-docs.yml
wiring, since nothing else in this branch sets that env var.
- split button vrt coverage into focused stories so each snapshot has a clear purpose
- move common vrt rendering, pseudo-state, and parameter setup into shared helpers
- keep button permutations data-driven to make future component vrt stories easier to scan

Co-authored-by: Cursor <cursoragent@cursor.com>
- group global stylesheet coverage so the snapshot is easier to scan
- reuse shared custom property vrt helpers to keep component stories smaller
- verify documented custom properties remain covered by the vrt cases

Co-authored-by: Cursor <cursoragent@cursor.com>
- add a short vrt-authoring skill for dedicated visual regression stories
- point migration testing guidance at the dedicated vrt file pattern
- update contributor testing docs to describe the shared helper approach

Co-authored-by: Cursor <cursoragent@cursor.com>
- cover CJK button text, truncation, and icon spacing in the visual matrix
- clarify VRT authoring guidance to prefer maximum meaningful coverage

Co-authored-by: Cursor <cursoragent@cursor.com>
Broaden VRT authoring guidance from components-only to components and
patterns: pattern test/vrt paths, deterministic realistic content for
composed patterns, the global-<component>.css coverage check, and a
Storybook story-sort order for VRT subsections under Components and
Patterns.
Add dedicated .vrt.ts visual regression coverage across 20 components
and 11 conversational-ai patterns, including permutation matrices,
forced-colors, global-styles, and custom-property stories where
applicable.
@rubencarvalho
rubencarvalho requested a review from a team as a code owner July 15, 2026 09:31
@changeset-bot

changeset-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 58ab595

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

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 rubencarvalho removed the run_vrt Triggers the Chromatic VRT run for 2nd-gen label Jul 16, 2026
Accordion, color-handle, color-loupe, and avatar bundled several states
under one generic 'States' row whose items render near-identical visuals
with no visible per-item label, so a reviewer could not tell which state
was which. Give each state its own row labeled with the state name, the
same way button's pseudo-state rows read.
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.
Tooltip defaults to popover="auto", which shares one page-wide top-layer
dismissal group: opening one instance light-dismisses any other open auto
popover, and Tooltip's own toggle listener syncs that dismissal back into
its open property. tooltip.vrt.ts rendered many open tooltips side by side
(every variant, every placement), so only the last one connected was ever
actually open by the time Chromatic snapshotted it — every earlier instance
silently closed itself during initial render.

Add a shared forceManualPopover play-function helper that switches each
instance to popover="manual" (no cross-instance dismissal) and re-toggles
its open property so the component's real open lifecycle runs again under
the new mode. Shaped to be reusable for Popover, whose default-mode popover
lives on an internal shadow element rather than the host.
PlacementController positions a tooltip relative to a trigger resolved via
for="id"; with no trigger to measure against, every tooltip rendered at
the popover's unpositioned default (the viewport corner), regardless of its
declared placement. Pair each tooltip with a small swc-button trigger and
link them via for, mirroring the pattern the docs stories already use.

tooltipContent() runs twice per story (once per theme() block sharing one
document), so ids now take an idPrefix argument to stay globally unique
across both copies.
Six placement tooltips forced open simultaneously overlapped badly in a
plain row(): a left/right/start/end tooltip's bubble is top-layer content
positioned by translate, not clipped by its trigger's local box, so it
spilled into the neighboring cell's trigger and tooltip.

Arrange the six placements on the same compass-rose grid the docs stories
use (top/right/end/bottom/left/start around an empty center), so every
direction has a blank cell to pop into. The docs story only opens one
tooltip at a time and never hits this; VRT forces all six open at once, so
it needs the room the grid provides.
Show each Placements grid cell's placement value as its tooltip content
(instead of the generic 'Tooltip content' default) so the snapshot itself
identifies which direction each tooltip demonstrates.
vrtParameters' outer flex-column gap (16px / --swc-spacing-300) packed each
story's rows tightly enough that row labels and content crowded each other.
Every .vrt.ts file shares this parameter (vrtParameters or
forcedColorsVrtParameters), so bumping it to --swc-spacing-500 (32px) gives
every VRT story more breathing room between rows in one change.
row()'s label wrapper used a hardcoded 4px gap between the row label and its
content, tighter than any other spacing in the shared VRT helpers. Use
--swc-spacing-100 (8px) instead, applying to every row() call across all
.vrt.ts files.
Bump vrtParameters' outer gap from --swc-spacing-500 (32px) to
--swc-spacing-700 (48px) for more breathing room between rows across all
.vrt.ts stories.
--swc-spacing-500 (32px) was the right amount; --swc-spacing-700 (48px)
was too much.
row()'s inner content wrapper (both the labeled and unlabeled branches) used
a hardcoded 16px gap between items within a row, not a design token. Use
--swc-spacing-400 (24px) instead, applying to every row() call across all
.vrt.ts files.
…orBackground()

theme()'s and staticColorBackground()'s outer wrappers used a hardcoded
16px gap between stacked rows, not a design token. Use --swc-spacing-400
(24px), matching row()'s item gap.
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.
Popover's VRT coverage previously lived as 10 individually-tagged stories
inline in popover.stories.ts (VrtPlacementTop, VrtSizeSmall, VrtModal,
VrtNested, ...), each opening exactly one popover per Chromatic snapshot to
avoid the popover="auto" light-dismiss collision. Consolidate onto the same
dedicated test/vrt/*.vrt.ts pattern used by every other component:

- One row per placement (12 total; a compass grid, as used for Tooltip's 6
  placements, does not generalize to Popover's 4-direction x 3-alignment
  matrix), plus one row each for sizes and hide-arrow.
- forceManualPopover keeps every non-modal instance open simultaneously,
  scoped to :not([modal]) and resolving the popover-bearing element from
  Popover's internal shadow child (.swc-Popover) rather than the host,
  since Popover's default mode attaches popover="auto" there instead of
  on the host the way Tooltip does.
- Modal mode keeps its own separate story: showModal()'s ::backdrop
  covers the full viewport, which would dim every other row's snapshot if
  folded into the same story.

Removed the now-redundant inline Vrt* stories and the stale meta-level
chromatic: { disableSnapshot } override from popover.stories.ts (dead
weight either way: Chromatic's CI build uses chromatic.config.json's
buildScriptName, which only scans *.vrt.ts files via SWC_STORYBOOK_MODE=vrt,
so .stories.ts files were never part of that catalog).
…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.
… components

main moved the generated custom-elements.json manifest from
.storybook/custom-elements.json to dist/custom-elements.json. The merge
from feat-dedicated-vrt-stories brought that change plus the button fix;
update the remaining 11 *-custom-properties.vrt.ts files (accordion,
action-button, avatar, badge, button-group, divider, icon, meter,
progress-circle, status-light, tooltip) to match.

Verified end-to-end: a clean `yarn storybook:build:vrt` (no prior dist/ or
.storybook/custom-elements.json) builds successfully across all 45 VRT
files plus Popover.
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.
Base automatically changed from rcarvalho/feat-dedicated-vrt-stories to main July 20, 2026 20:43
…ts-to-components

# Conflicts:
#	2nd-gen/packages/swc/.storybook/helpers/index.ts
#	2nd-gen/packages/swc/.storybook/helpers/vrt.ts
#	2nd-gen/packages/swc/.storybook/main.ts
@Rajdeepc

Copy link
Copy Markdown
Contributor

This is a reference PR we would like to separate this PR into multiple batches. This is the plan

  1. Buttons: action-button, button-group (button already in PR 0)
  2. Status/feedback: badge, status-light, meter, progress-circle
  3. Layout/structure: accordion, tabs, divider, illustrated-message
  4. Content/visual: icon, avatar, asset, link, typography
  5. Color tools: color-handle, color-loupe, opacity-checkerboard
  6. Overlays: popover, tooltip
  7. Conversational AI pattern: all 11 units

Each unit's VRT file only touches its own test/vrt/ folder, so these batches are independent and can be merged in any order

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants