diff --git a/2nd-gen/scaffolding/README.md b/2nd-gen/scaffolding/README.md index 6c3c926a954..aacfaa1821a 100644 --- a/2nd-gen/scaffolding/README.md +++ b/2nd-gen/scaffolding/README.md @@ -1,14 +1,27 @@ # 2nd-gen scaffolding -A [plop](https://plopjs.com/) generator that produces the deterministic file -skeleton for a 2nd-gen component: the `core` base layer, the `swc` concrete -layer, and the stories, docs, and test files that go with them. +A [plop](https://plopjs.com/) scaffolder that produces the deterministic file +skeletons for 2nd-gen components: the component itself, and the **test** and +**VRT** files that go with it. -The generator exists to take the mechanical, every-time-identical part of -Phase 2 (`migration-setup`) off the critical path. It writes the boilerplate so -a human or an agent only has to apply the decisions that actually differ between -components: the base-vs-concrete property split, the real Spectrum 2 tokens, the -variant and state API, and the prose in the docs page. +The generators exist to take the mechanical, every-time-identical part of +authoring off the critical path. They write the boilerplate so a human or an +agent only has to apply the decisions that actually differ between components: +the base-vs-concrete property split, the real Spectrum 2 tokens, the +variant/state API, the assertions, and the docs prose. + +## Generators + +| Generator | Scaffolds | Target | +| ----------- | ------------------------------------------------------- | --------------------------------- | +| `component` | Core base + SWC concrete + stories, docs, tests, VRT | new component (`core` + `swc`) | +| `test` | Unit (`*.test.ts`) + a11y (`*.a11y.spec.ts`) test files | **existing** component (retrofit) | +| `vrt` | A Chromatic VRT story (`test/vrt/.vrt.ts`) | **existing** component (retrofit) | + +`component` emits a baseline `test`, `a11y`, and `vrt` file as part of the +skeleton. Use the standalone `test` and `vrt` generators to add (or refresh) +that coverage on a component that already exists — for example, one migrated +before these generators landed. ## Usage @@ -16,87 +29,99 @@ variant and state API, and the prose in the docs page. ```bash yarn plop component +yarn plop test +yarn plop vrt ``` -You will be prompted for a component name. Enter it in any form: `action-button`, -`actionButton`, `Action Button`, or even `sp-action-button` (the `sp-`/`swc-` -prefix is stripped). The name is normalized by the built-in case helpers, so the -output is identical regardless of how you type it. +You are prompted for a name (enter it in any form: `action-button`, +`actionButton`, `Action Button`, or `sp-action-button` — the `sp-`/`swc-` +prefix is stripped and the case helpers normalize the rest). ### Headless (agents, scripts, CI) -Pass the name as a bypass argument and `--force` to skip the interactive -confirmation: +Pass the name as a positional bypass argument, plus `--force`: ```bash yarn plop component "action-button" --force +yarn plop test "action-button" --force +yarn plop vrt "action-button" --force ``` -## What it generates +Each generator emits a single fixed scaffold (a permutation-grid + forced-colors +story for `vrt`, and a play-function + a11y-snapshot pair for `test`). The output +is lint-clean and formatted; `TODO`s mark where the author fills in the +component-specific detail. -For a component named `action-button`: +## Retrofit contract (`test`, `vrt`) -``` -2nd-gen/packages/core/components/action-button/ - ActionButton.types.ts VALID_SIZES + Size type - ActionButton.base.ts abstract base class (SizedMixin + SpectrumElement) - index.ts re-exports base + types - -2nd-gen/packages/swc/components/action-button/ - ActionButton.ts concrete class, render(), styles getter - index.ts re-exports the concrete class - swc-action-button.ts defineElement registration + tag-name map - action-button.css :host + .swc-ActionButton block, token() scaffolding - action-button.mdx per-unit docs page (DocsHeader/Canvas/DocsFooter) - stories/action-button.stories.ts Playground/Overview/Anatomy/Sizes/States/Accessibility - test/action-button.test.ts Vitest play function reusing the Overview story - test/action-button.a11y.spec.ts Playwright ARIA-snapshot accessibility test -``` +`test` and `vrt` add files to a component that **already exists**. They: + +- **Guard first.** If `2nd-gen/packages/swc/components//` is missing, the + generator aborts with a clear message before writing anything (a typo cannot + silently scaffold tests for a non-existent component). +- **Never clobber by default.** The `add` actions use `skipIfExists`, so an + existing test/VRT file is left untouched. Pass `--force` to overwrite, or + delete the file first to regenerate it. +- **Stay scoped.** They format only the `test/` (or `test/vrt/`) subtree they + wrote, leaving the surrounding component files untouched. -It also wires the core package's `exports` map: the `swc` package uses wildcard -exports and needs no edit, but `@spectrum-web-components/core` uses explicit -per-component entries, so the generator adds `./components/action-button` and -`./components/action-button/index.js` and re-sorts the `exports` keys -alphabetically (a minimal, deterministic diff). Finally it runs Prettier on the -two new directories so the output lands pre-formatted. +## What gets wired automatically -## What it intentionally does NOT do +- **Core `exports` + `typesVersions`.** The SWC package (`@adobe/spectrum-wc`) + uses wildcard `exports`, so a new component needs no package.json edit there. + The core package (`@spectrum-web-components/core`) uses explicit per-component + entries, so the `component` generator adds both the `exports` and the matching + `typesVersions` entry for the new component and re-sorts the keys (a minimal, + deterministic diff). The `test`/`vrt` generators touch no package.json. +- **Formatting.** Each generator runs Prettier on the directories it wrote. -The skeleton is a starting point, not a finished component. After generating, -follow `migration-setup` and the later migration phases to: +## Custom-element registration -- Move properties, methods, and types from 1st-gen into the base and concrete - classes (Phase 3, `migration-api`). -- Implement semantics, ARIA, and keyboard support (Phase 4, `migration-a11y`). -- Replace the placeholder CSS with migrated Spectrum 2 tokens (Phase 5, - `migration-styling`). -- Flesh out the tests and stories (Phase 6, `migration-testing`). -- Write the docs prose and any `migration-guide.mdx` (Phase 7, - `migration-documentation`; the consumer guide is owned by the - `consumer-migration-guide` skill, so it is not scaffolded here). +Components are custom elements; the split matters: -Every generated file contains `TODO`/placeholder markers showing where this work -goes. +- `index.ts` re-exports the class only (no registration). +- `swc-.ts` is the side-effectful entry: it calls + `defineElement('swc-', Class)` (from `@adobe/spectrum-wc-core/element`) + and augments `HTMLElementTagNameMap`. + +## What the generators intentionally do NOT do + +The skeletons are starting points, not finished components. After generating, +follow `migration-setup` and the later migration phases (or the `vrt-authoring` +skill for VRT) to move properties/types into the classes, implement semantics and +ARIA, migrate CSS to Spectrum 2 tokens, and flesh out the tests, VRT grids, and +docs prose. Every generated file contains `TODO`/placeholder markers. ## Conventions the templates encode -These mirror the `badge` reference component and the project rules in `.ai/`: +These mirror the `badge` and `button` reference components and the project rules +in `.ai/`: - **Two-layer architecture.** Shared, non-visual API lives on the core base class; visual and version-specific API lives on the concrete `swc` class. -- **Sizing via host attribute.** `SizedMixin` reflects `size` to the host, so - size is styled with `:host([size="..."])` selectors, never a modifier class. -- **Public styling API.** Custom properties are exposed as `--swc--*` with - a `token()` fallback. +- **Sizing via host attribute.** `SizedMixin` reflects `size` to the host, styled + with `:host([size="..."])` selectors, never a modifier class. +- **Public styling API.** Custom properties exposed as `--swc--*` with a + `token()` fallback. - **BEM-ish class block.** The render root carries `swc-`. -- **Stories + per-unit MDX.** The Playground is tagged `['dev']` (not - `['autodocs', 'dev']`) because the generated `.mdx` is the docs page; each +- **Stories + per-unit MDX.** The Playground is tagged `['dev']`; each section-tagged story is referenced from the MDX via ``. +- **Sentence-case titles.** Storybook titles are sentence case (`Action button`), + matching the project's title rule. ## Editing the templates -Templates live in `templates/component/` as Handlebars (`.hbs`) files. Name -derivations use plop's built-in case helpers plus two custom helpers defined in +Templates live under `templates/` as Handlebars (`.hbs`) files, grouped by +generator: + +``` +templates/ + component/{core,swc}/*.hbs the component skeleton + test/*.hbs the standalone test + a11y files + vrt/vrt.ts.hbs the VRT story +``` + +Name derivations use plop's built-in case helpers plus custom helpers defined in `plopfile.js`: | Helper | `action-button` renders as | @@ -104,14 +129,16 @@ derivations use plop's built-in case helpers plus two custom helpers defined in | `{{dashCase name}}` | `action-button` | | `{{pascalCase name}}` | `ActionButton` | | `{{constantCase name}}` | `ACTION_BUTTON` | -| `{{titleName name}}` | `Action Button` (custom) | +| `{{titleName name}}` | `Action button` (custom) | | `{{lb}}` / `{{rb}}` | `{` / `}` (custom) | -`titleName` produces the space-separated proper-noun title used for Storybook +`titleName` produces the space-separated sentence-case title used for Storybook titles; plop's built-in `titleCase` keeps the dash (`Action-Button`) and must not be used for titles. `lb`/`rb` emit literal braces in `.mdx` templates, where a -bare `{` next to a `{{helper}}` would otherwise be parsed as Handlebars. +`{` next to a `{{helper}}` would otherwise be parsed as Handlebars. When you change the file layout, naming, or wiring, update both the templates and -the `add` actions in `plopfile.js`, then regenerate a throwaway component and run -`yarn lint` / `yarn lint:css` on it to confirm the output still passes. +the actions in `plopfile.js`, then regenerate a throwaway component and run +`yarn lint`, `yarn lint:css`, and `yarn lint:docs-pages` on it to confirm the +output still passes. Delete the throwaway component and revert +`2nd-gen/packages/core/package.json` afterward. diff --git a/2nd-gen/scaffolding/plopfile.js b/2nd-gen/scaffolding/plopfile.js index da06f9a46f0..ca265455922 100644 --- a/2nd-gen/scaffolding/plopfile.js +++ b/2nd-gen/scaffolding/plopfile.js @@ -22,20 +22,26 @@ const CORE_COMPONENTS = '2nd-gen/packages/core/components'; const SWC_COMPONENTS = '2nd-gen/packages/swc/components'; /** - * 2nd-gen component scaffolder. + * 2nd-gen scaffolder. * * Mirrors the file layout and conventions described by the migration skills - * (`migration-setup`, `stories-format`, `stories-documentation`) and the `badge` - * reference component. It produces the deterministic Phase 2 skeleton so the - * migration-setup skill only has to apply plan-specific architecture decisions - * (the base-vs-concrete split) rather than author every file by hand. + * (`migration-setup`, `stories-format`, `stories-documentation`, `vrt-authoring`) + * and the `badge` / `button` reference units. It produces the deterministic + * skeleton so a human or an agent only has to apply the decisions that actually + * differ between units (the base-vs-concrete split, the real API, the + * assertions) rather than author every file by hand. + * + * Generators: + * component — a custom-element component (core base + swc concrete + docs/tests) + * test — retrofit an existing component with unit + a11y test files + * vrt — retrofit an existing component with a Chromatic VRT story * * Built-in plop case helpers do most name derivation, plus the custom - * `titleName` helper for the space-separated proper-noun title: + * `titleName` helper for the space-separated sentence-case title: * {{dashCase name}} -> action-button (kebab tag, dir, css, file names) * {{pascalCase name}} -> ActionButton (class names, CSS BEM block) * {{constantCase name}} -> ACTION_BUTTON (exported constant prefixes) - * {{titleName name}} -> Action Button (Storybook title, proper noun) + * {{titleName name}} -> Action button (Storybook title, sentence case) * * @param {import('plop').NodePlopAPI} plop */ @@ -54,19 +60,37 @@ export default function (plop) { plop.setHelper('lb', () => '{'); plop.setHelper('rb', () => '}'); - // Space-separated Title Case for human-facing labels (Storybook titles, - // describe blocks, prose). plop's built-in `titleCase` keeps the dash - // ("action-button" -> "Action-Button"), but Spectrum treats component names - // as proper nouns rendered with spaces ("Action Button"). - plop.setHelper('titleName', (text) => - String(text) + // Space-separated sentence-case label for human-facing text (Storybook + // titles, describe blocks, prose). Spectrum titles are sentence case: + // capitalize only the first word ("action-button" -> "Action button"). + // Authors re-capitalize any acronyms or proper nouns the scaffold can't know. + // plop's built-in `titleCase` can't do this (it keeps the dash and caps every + // word). + plop.setHelper('titleName', (text) => { + const words = String(text) .replace(/^(sp|swc)-/i, '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // split camelCase .split(/[-_\s]+/) .filter(Boolean) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - ); + .map((word) => word.toLowerCase()); + if (words.length === 0) { + return ''; + } + words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1); + return words.join(' '); + }); + + // Shared prompt for the retrofit generators. A component name is entered in + // any form (`action-button`, `actionButton`, `Action Button`, + // `sp-action-button`); the case helpers normalize it. + const namePrompt = (message) => ({ + type: 'input', + name: 'name', + message, + filter: (input) => input.trim().replace(/^(sp|swc)-/i, ''), + validate: (input) => + input.trim().length > 0 || 'A name is required. You can rename it later.', + }); // ────────────────────── // CUSTOM ACTIONS @@ -142,8 +166,42 @@ export default function (plop) { } }); + // Retrofit generators (`test`, `vrt`) only make sense for a component that + // already exists. Fail loudly and early — before any file is written — when + // the target directory is missing, so a typo does not silently scaffold tests + // for a non-existent component. + plop.setActionType('assert-component-exists', (answers) => { + const name = render('{{dashCase name}}', answers); + const dir = path.join(repoRoot, SWC_COMPONENTS, name); + if (!fs.existsSync(dir)) { + throw new Error( + `Component "${name}" does not exist at ${SWC_COMPONENTS}/${name}. ` + + `Scaffold it first with: yarn plop component "${name}"` + ); + } + return `found component ${name}`; + }); + + // Format the directories a retrofit generator wrote (a `test/` or `test/vrt/` + // subtree), scoped so the surrounding component files are left untouched. + // Config: { paths: string[] } — handlebars-templated repo-relative dirs. + plop.setActionType('format-dir', (answers, config) => { + const targets = (config.paths ?? []) + .map((p) => render(p, answers)) + .join(' '); + try { + execSync(`yarn prettier --write ${targets}`, { + cwd: repoRoot, + stdio: 'ignore', + }); + return `formatted ${targets}`; + } catch { + return `skipped formatting (run "yarn lint" manually)`; + } + }); + // ────────────────── - // GENERATOR + // GENERATORS // ────────────────── plop.setGenerator('component', { @@ -226,10 +284,61 @@ export default function (plop) { path: `${swcDir}/test/{{dashCase name}}.a11y.spec.ts`, templateFile: t('swc/a11y.spec.ts.hbs'), }, + { + type: 'add', + path: `${swcDir}/test/vrt/{{dashCase name}}.vrt.ts`, + templateFile: path.join(here, 'templates/vrt/vrt.ts.hbs'), + }, // ── wiring + formatting ────────────────────────────────── { type: 'wire-core-export' }, { type: 'format-component' }, ]; }, }); + + plop.setGenerator('test', { + description: 'Retrofit an existing component with unit + a11y test files', + prompts: [namePrompt('Component to add tests for (e.g. action-button):')], + actions: () => { + const swcDir = `${SWC_COMPONENTS}/{{dashCase name}}`; + + return [ + { type: 'assert-component-exists' }, + { + type: 'add', + path: `${swcDir}/test/{{dashCase name}}.test.ts`, + templateFile: path.join(here, 'templates/test/test.ts.hbs'), + skipIfExists: true, + }, + { + type: 'add', + path: `${swcDir}/test/{{dashCase name}}.a11y.spec.ts`, + templateFile: path.join(here, 'templates/test/a11y.spec.ts.hbs'), + skipIfExists: true, + }, + { type: 'format-dir', paths: [`${swcDir}/test`] }, + ]; + }, + }); + + plop.setGenerator('vrt', { + description: 'Retrofit an existing component with a Chromatic VRT story', + prompts: [ + namePrompt('Component to add a VRT story for (e.g. action-button):'), + ], + actions: () => { + const swcDir = `${SWC_COMPONENTS}/{{dashCase name}}`; + + return [ + { type: 'assert-component-exists' }, + { + type: 'add', + path: `${swcDir}/test/vrt/{{dashCase name}}.vrt.ts`, + templateFile: path.join(here, 'templates/vrt/vrt.ts.hbs'), + skipIfExists: true, + }, + { type: 'format-dir', paths: [`${swcDir}/test/vrt`] }, + ]; + }, + }); } diff --git a/2nd-gen/scaffolding/templates/test/a11y.spec.ts.hbs b/2nd-gen/scaffolding/templates/test/a11y.spec.ts.hbs new file mode 100644 index 00000000000..98e25b556a9 --- /dev/null +++ b/2nd-gen/scaffolding/templates/test/a11y.spec.ts.hbs @@ -0,0 +1,56 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, test } from '@playwright/test'; + +import { gotoStory } from '../../../utils/a11y-helpers.js'; + +/** + * Accessibility tests for the {{titleName name}} component (2nd generation). + * + * ARIA snapshot tests validate the accessibility tree structure. aXe WCAG + * compliance and color contrast validation run via test-storybook (see + * .storybook/test-runner.ts). Both are included in the `test:a11y` command. + */ + +test.describe('{{titleName name}} - ARIA Snapshots', () => { + test('should have the correct accessibility tree by default', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-{{dashCase name}}--overview', + 'swc-{{dashCase name}}' + ); + // TODO: replace with the expected accessibility tree for this component. + await expect(root).toMatchAriaSnapshot(` + - text: {{titleName name}} + `); + }); + + test('should expose the correct accessibility tree in each state', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-{{dashCase name}}--states', + 'swc-{{dashCase name}}' + ); + // TODO: assert the accessibility tree for the disabled/selected/invalid + // states this component supports (aria-disabled, aria-selected, etc.). + // Point at whichever states story exists; delete this test if the + // component has no additional states. + await expect(root).toMatchAriaSnapshot(` + - text: {{titleName name}} + `); + }); +}); diff --git a/2nd-gen/scaffolding/templates/test/test.ts.hbs b/2nd-gen/scaffolding/templates/test/test.ts.hbs new file mode 100644 index 00000000000..29953375751 --- /dev/null +++ b/2nd-gen/scaffolding/templates/test/test.ts.hbs @@ -0,0 +1,68 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { {{pascalCase name}} } from '@adobe/spectrum-wc/{{dashCase name}}'; + +import '@adobe/spectrum-wc/components/{{dashCase name}}/swc-{{dashCase name}}.js'; + +import { getComponent } from '../../../utils/test-utils.js'; +import meta, { Overview } from '../stories/{{dashCase name}}.stories.js'; + +// Dev-only test stories that reuse the main story metadata. +export default { + ...meta, + title: '{{titleName name}}/Tests', + parameters: { + ...meta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +// ────────────────────────────────────────────────────────────── +// TEST: Defaults and reflection +// ────────────────────────────────────────────────────────────── + +export const OverviewTest: Story = { + ...Overview, + play: async ({ canvasElement, step }) => { + const el = await getComponent<{{pascalCase name}}>( + canvasElement, + 'swc-{{dashCase name}}' + ); + + await step('renders', async () => { + expect(el).toBeDefined(); + expect(el).toBeInstanceOf({{pascalCase name}}); + }); + + await step('applies the default size', async () => { + expect(el.size).toBe('m'); + }); + + await step('reflects size to the host attribute', async () => { + el.size = 'l'; + await el.updateComplete; + expect(el.getAttribute('size')).toBe('l'); + }); + + // TODO: extend coverage for this component's real API. Loop the valid + // sizes and variants (import {{constantCase name}}_VALID_SIZES / + // {{constantCase name}}_VARIANTS from + // '@adobe/spectrum-wc-core/components/{{dashCase name}}'), assert each + // state (disabled, selected, invalid, ...) toggles the expected + // attribute/behavior, and assert any dev-mode warnings via + // withWarningSpy from '../../../utils/test-utils.js'. + }, +}; diff --git a/2nd-gen/scaffolding/templates/vrt/vrt.ts.hbs b/2nd-gen/scaffolding/templates/vrt/vrt.ts.hbs new file mode 100644 index 00000000000..5d7c647c6b2 --- /dev/null +++ b/2nd-gen/scaffolding/templates/vrt/vrt.ts.hbs @@ -0,0 +1,79 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html } from 'lit'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import '@adobe/spectrum-wc/components/{{dashCase name}}/swc-{{dashCase name}}.js'; + +import { + createPermutations, + forcedColorsVrtParameters, + groupPermutationsBy, + renderStorybookPermutation, + row, + theme, + vrtParameters, +} from '../../../../.storybook/helpers/index.js'; + +// Metadata + +const meta: Meta = { + title: '{{titleName name}}/{{titleName name}} VRT', + component: 'swc-{{dashCase name}}', + tags: ['dev'], +}; + +export default meta; + +// Helpers + +// TODO: replace the placeholder `size` axis with this component's real API. +// Import its constants from '@adobe/spectrum-wc-core/components/{{dashCase name}}' +// (e.g. {{constantCase name}}_VALID_SIZES, {{constantCase name}}_VARIANTS) and +// cross the axes that matter. Add disabled/pending/selected state rows, forced +// pseudo-states (forcePseudoStates + a `play` function), and static-color +// backgrounds where applicable. See button.vrt.ts and the vrt-authoring skill. +const {{constantCase name}}_PERMUTATIONS = createPermutations([ + { size: ['s', 'm', 'l'] }, +]); + +const render{{pascalCase name}}Permutation = renderStorybookPermutation( + 'swc-{{dashCase name}}', + { 'default-slot': '{{titleName name}}' } +); + +const permutationContent = () => html` + ${groupPermutationsBy({{constantCase name}}_PERMUTATIONS, 'size').map( + ([size, perms]) => + row(perms.map(render{{pascalCase name}}Permutation), String(size)) + )} +`; + +// VRT stories + +// Rendered once in light/ltr and once in dark/rtl (that combination covers both +// axes) in a single story so it costs one snapshot. +export const Permutations: Story = { + render: () => html` + ${theme(permutationContent(), 'light', 'ltr')} + ${theme(permutationContent(), 'dark', 'rtl')} + `, + parameters: vrtParameters, +}; + +// `forced-colors` replaces the whole page palette, so it needs its own +// story/snapshot rather than folding into Permutations. +export const ForcedColors: Story = { + render: () => theme(permutationContent(), 'light', 'ltr'), + parameters: forcedColorsVrtParameters, +};