packages/composites Add one static Test Bed story for the component's visual regression diff to the target *.stories.tsx.
- Regression harness filter: starts with
story.name이'Test Bed'+ starts withtitle이'Composites/'. (packages/composites/__tests__/regressions.test.ts:12) —Test Bed Light,Test Bed Darkboth stories match. - 2 stories per component:
TestBed_Light(light mode) +TestBed_Dark(dark mode). Render the same regression condition table in each of the two appearances. - Table structure: a column per regression condition (currently one per variant prop) + a final
rendercolumn (the render result of the component with the condition applied). - For table rendering, reuse the
packages/composites/src/utils/regressions.tsx의Regression.Tableconvenience component.conditions(for each condition'skey·label·values·format),render(row + container → component), the Cartesian product rows·ColumnGroup·Header·Condition cells·Render cells are generated automatically. If fine-grained customization is needed, assemble the lower-level compounds (Regression.Root / ColumnGroup / ConditionColumn / RenderColumn / Header / Body / Row / Heading / Condition / Render) directly. It is layered on top of VaporTable, and styles are attached with$css.
Steps
1. Confirm the target component
If the user has not specified it, ask with AskUserQuestion. The target is under packages/composites/src/components/<kebab-name>/ .
Done: the folder path is confirmed, and <name>.tsx와 <name>.stories.tsxexists in that folder.
2. Check the Default story
<name>.stories.tsx. export const Defaultin
- If absent, exit immediately. Tell the user to "add the Default story first".
- If present, read
Default.args와Default.rendercompletely, and list the JSX values and args passed to each slot position.
Done: a mapping of each slot name → JSX value and each arg name → value is organized into a list.
3. Identify the implementation
<name>.tsxRead createSlots(...) call arguments. Required slots are identified by the second generic argument of SlotProps<typeof slots, ...>.
Done: confirmed that the project conventions are automatically loaded as packages/composites/** , and all props of the component can be enumerated flawlessly. .claude/rules/composites.mdupon entering
4. Classify props
Classify each prop exclusively into exactly one of three categories.
- variants — a prop that changes visual presentation. Its value domain is a finite literal union or boolean. Example: a slot declared as
size: 'md' | 'lg' | 'xl',disabled: boolean. - slots —
createSlots. Ifchildrenis a slot role, it is a slot. It becomes the source of the defaultSlots constant reused in the variants cell. - functional — controlled state (
open/defaultOpen/onOpenChange),ref,container,keepMounted,ariaLabels,closeOnClick, etc. Test Bed does not handle it.
Determine the value list for each variant prop. If the value domain is infinite or an arbitrary string, exclude it from variants.
Done: there are 0 props not distributed into the three categories, and the value array corresponding to each variant prop is confirmed as a literal constant.
5. Render the Variants table
All variant prop values' Cartesian product을 Regression render as a compound. Condition columns are one per variant prop + a final render column. If variants is 0, the story itself is meaningless — ask the user for confirmation and exit.
conditions는{ key, label, values, format }[]array.keyis the row field name,labelis the header text,valuesis the array of candidate values for that condition (Cartesian product material),formatis the value converter for cell display (e.g.,(v) => \description = ${v ? 'O' : 'X'}`). Boolean/null 인코딩된 조건은 반드시format` to convert to O/X, literals, etc.Regression.Table이valuesautomatically generate Cartesian product rows. There is no need to build rows externally.- Markup:
<Regression.Table conditions={[...]} render={(row, container) => <Component ... />} />in a single line, rows, headers, ColGroup, condition columns, and render cells are automatically generated. - Slots are spread from the
defaultSlotsconstant at the top of the file. Functional props (e.g.,defaultOpen)은renderspecified inside the function. - Column width, cell style, wrapper overflow, and portal reset are handled inside
Regression.Table. Do not touch them in the story. - Per-component workarounds (e.g., hiding the Dialog overlay) are added to the
<style>tag in the story itself, to selectors under.regression-cell. Regression.Tableautomatically displays the total case count (<tfoot>) at the bottom of the table총 N개 케이스. Consumers need no separate handling.
Done: <Regression.Body> internal <Regression.Row> count = values[a].length * values[b].length * ... computed value. Each row has all condition column cells + one render cell.
6. Reflect in the story file
<name>.stories.tsx At the bottom, two stories reusing the same render component TestBed_Light·TestBed_Darkare exported. Only in the Dark story, add globals: { appearance: 'dark' }so that the preview decorator (apps/storybook/.storybook/preview.tsx:34 — ThemeProvider forcedTheme={context.globals.appearance}) forces the dark token.
import { Regression } from '~/utils/regressions';
const TestBedRender = () => (
<Regression.Table
conditions={[
{
key: 'size',
label: 'size',
values: ['md', 'lg', 'xl'],
format: (v) => `size = ${v}`,
},
{
key: 'hasDescription',
label: 'description',
values: ['설명 텍스트', null],
format: (v) => `description = ${v ? 'O' : 'X'}`,
},
]}
render={(row, container) => (
<Component
{...defaultSlots}
size={row.size}
description={row.hasDescription ?? undefined}
defaultOpen
container={container ?? undefined}
/>
)}
/>
);
export const TestBed_Light: StoryObj<typeof Component> = {
render: () => <TestBedRender />,
};
export const TestBed_Dark: StoryObj<typeof Component> = {
globals: { appearance: 'dark' },
render: () => <TestBedRender />,
};
meta.title이'Composites/<Name>'if not, modify it.- Export names are exactly
TestBed_Light·TestBed_Dark. Storybook auto-name renders each as'Test Bed Light'·'Test Bed Dark'. The regression harness matches both stories withstory.name.startsWith('Test Bed'). - Both stories must reference the same
TestBedRendercomponent. Do not duplicate the condition/render logic. If Dark-specific adjustments are needed, handle them insideTestBedRender(a case outside the skill's scope). globals: { appearance: 'dark' }is a Storybook 8+ story-level globals override. preview.tsx passes this value asThemeProvider forcedTheme.- Default and Test Bed reference the same
defaultSlotsconstant. If the slot JSX is created only inside Defaultrenderand cannot be reused, extract it to the top of the file asconst defaultSlots = { title: <...>, ... }and refactor Defaultrenderto use it as well.
Done: TestBed_Light·TestBed_Dark both exports exist, TestBed_Darkonly in globals: { appearance: 'dark' }, both stories reference the same TestBedRender , meta.title이 'Composites/<Name>', in each story <Regression.Body> internal <Regression.Row> count = the value computed in step 5.
7. Verification
packages/composites/package.jsoncheck the lint/typecheck script names.pnpm --filter @vapor-ui/composites <lint-script>run, and fix if it fails.pnpm --filter @vapor-ui/composites <typecheck-script>run, and fix if it fails.- Launch Storybook
Composites/<Name> > Test Bed Light와Composites/<Name> > Test Bed Darkand visually confirm that two tree nodes render, that the cell count in each story matches the 5-step computed value, and that the Dark story actually has dark tokens (background, etc.) applied.
Complete: items 2 and 3 above pass, and the cell count in 4 matches. Updating the screenshot baseline files is outside this skill's scope — the user separately runs packages/composites/package.json's visual regression script.
8. Axis rationale report — MANDATORY, always last
In the Test Bed task completion response, the last sectionis always the explanation of the rationale for axis selection. Omission is prohibited. Since it serves as the basis for judgment when the user requests axis adjustments, it is appended after the verification pass summary.
Items to include:
- Final axis summary —
축A × 축B × ... = N 케이스한 줄. - Rationale for each adopted axis — why each axis is a regression target. For a Variant prop, specify the value domain and visual differences; for a content length variant, specify which layout regressions it catches.
- Rationale for excluded props — among the props classified in step 4, those not made regression axes. Group them by category and state the reason.
- Required slot: reason O/X is not possible.
- Optional slot: actual rationale such as a default fallback exists / meaningless due to TestBed forced open / not standard usage.
- Functional prop: state that it is not a visual regression target.
- When a content length axis is introduced — specify the reason for making required slots a short/long content variant axis instead of O/X (which layout threshold it verifies: wrap, max-height, leading, etc.).
Complete: the 4 items above are filled in at the very bottom of the response, and each item contains concrete rationale the user can use to decide on axis readjustment.
Notes
- Test Bed is for static rendering only. For components that are only meaningful when open at mount time (e.g., Dialog's
defaultOpen: true), specify the forced-open state via functional args. - For components where Overlay/Portal are fixed to the viewport and do not fit inside the cell (Dialog family), bypassing popup positioning and the containing block is already handled by
Regression.Render's internal CSS (.regression-cell { transform: translateZ(0); min-width: 100% }+[data-base-ui-portal] > [data-open][role="presentation"] { position: static !important; ... }). Only elements that need additional overrides per component (e.g., hiding the Dialog overlay) are added in the story via a<style>tag — the selector is scoped under.regression-cell. - The Render cell is kept at or above the component's natural width. When the viewport narrows, horizontal scrolling is created with
Regression.Root가overflow-x: auto. Consumers do not need to override this width. - Column width distribution (
Regressiondefault): the condition column is$css={{ width: 'auto' }}(content natural width), and the render column is$css={{ width: '100%' }}(absorbs the remaining width). In auto-table-layout, this combination fits the condition column to the content width and lets the render column absorb the viewport's remaining width. Since it does not work with the Grid's1fr은<col>, this trick is used instead. - Required slots (e.g., Dialog's
title) are excluded from regression conditions, and only optional slots (e.g.,description) have their presence or absence (O/X) in the condition column. Keep the condition cell text short to narrow the natural width of the condition column.