assets/lab-kit/lab-kit.css
/* Lab kit: chrome for a throwaway lab page. Copy unchanged; it is a tool, not the design.
Only chrome classes set a font, so the component keeps the app's own font. */
.lab-page {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: #fafafa;
}
.lab-stage {
--lab-line: #e8e8e8;
position: relative;
box-sizing: border-box;
width: min(100%, 960px);
padding: 8px;
background: #fff;
border: 1px solid var(--lab-line);
border-radius: 28px;
}
.lab-canvas {
position: relative;
box-sizing: border-box;
display: grid;
place-items: center;
min-height: 320px;
padding: 64px 24px;
}
.lab-subject {
box-sizing: border-box;
width: var(--lab-width, auto);
max-width: 100%;
min-width: 0;
}
.lab-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 28px;
}
.lab-tray {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
background: #fafafa;
border: 1px solid var(--lab-line);
border-radius: 20px;
}
.lab-bar,
.lab-tray,
.lab-overlay {
font: 15px/1.2 system-ui, -apple-system, "Segoe UI", sans-serif;
color: #6b6b6b;
-webkit-font-smoothing: antialiased;
}
.lab-seg {
display: inline-flex;
gap: 2px;
padding: 4px;
background: #fff;
border: 1px solid var(--lab-line);
border-radius: 999px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.lab-seg-btn,
.lab-round,
.lab-label {
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
border-radius: 999px;
font: inherit;
color: inherit;
white-space: nowrap;
}
.lab-seg-btn {
padding: 0 16px;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
}
.lab-seg-btn[data-square] {
width: 40px;
padding: 0;
font-variant-numeric: tabular-nums;
}
.lab-seg-btn:hover {
color: #1a1a1a;
}
.lab-seg-btn[aria-pressed="true"] {
background: #f2f2f2;
color: #1a1a1a;
box-shadow: inset 0 0 0 1px #e8e8e8;
}
.lab-seg-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.lab-label {
padding: 0 16px;
background: #fff;
border: 1px solid var(--lab-line);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.lab-steps {
display: inline-flex;
gap: 8px;
}
.lab-round {
width: 40px;
background: #fff;
border: 1px solid var(--lab-line);
color: #1a1a1a;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.lab-round:hover {
background: #f7f7f7;
}
.lab-seg-btn:focus-visible,
.lab-round:focus-visible {
outline: 2px solid #1a1a1a;
outline-offset: 2px;
}
.lab-icon {
width: 16px;
height: 16px;
flex: none;
}
[data-chrome-hidden] .lab-bar,
[data-chrome-hidden] .lab-tray,
[data-chrome-hidden] .lab-overlay {
visibility: hidden;
}
@media (prefers-reduced-motion: reduce) {
.lab-seg-btn {
transition: none;
}
}
assets/lab-kit/lab-kit.tsx
// Lab kit: the stage, tray and controls for a throwaway lab page.
// Copy this folder into the lab folder unchanged. It is a tool, not part of the design.
// React 18+, no other dependencies.
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"
import "./lab-kit.css"
/** Keeps one value in the URL query, so every view is a shareable link that survives reload. */
export function useUrlParam<T extends string>(name: string, values: readonly T[], fallback: T = values[0]) {
const read = useCallback((): T => {
const v = new URLSearchParams(window.location.search).get(name) as T | null
return v !== null && values.includes(v) ? v : fallback
}, [name, fallback, values.join("|")]) // eslint-disable-line react-hooks/exhaustive-deps
const [value, setValue] = useState<T>(read)
useEffect(() => {
const onPop = () => setValue(read())
window.addEventListener("popstate", onPop)
return () => window.removeEventListener("popstate", onPop)
}, [read])
const set = useCallback(
(next: T) => {
const url = new URL(window.location.href)
if (next === fallback) url.searchParams.delete(name)
else url.searchParams.set(name, next)
window.history.replaceState(window.history.state, "", url)
setValue(next)
},
[name, fallback],
)
return [value, set] as const
}
/** A URL value plus previous and next, wrapping at the ends. */
export function useStepper<T extends string>(name: string, values: readonly T[]) {
const [value, set] = useUrlParam(name, values)
const i = Math.max(0, values.indexOf(value))
const prev = () => set(values[(i - 1 + values.length) % values.length])
const next = () => set(values[(i + 1) % values.length])
return { value, set, prev, next, index: i }
}
/**
* Bare-key shortcuts. Skips modified keys, events another handler already handled,
* and focus inside form fields or editable regions. With several stages on one page,
* only the stage under the pointer or holding focus responds.
*/
export function useLabKeys(stage: RefObject<HTMLElement | null>, bindings: Record<string, () => void>) {
const latest = useRef(bindings)
latest.current = bindings
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return
const target = e.target as HTMLElement | null
if (target?.closest?.('input, textarea, select, [contenteditable]:not([contenteditable="false"])')) return
const el = stage.current
if (!el) return
const several = document.querySelectorAll("[data-lab-stage]").length > 1
if (several && !el.contains(document.activeElement) && !el.matches(":hover")) return
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key
const run = latest.current[key]
if (!run) return
e.preventDefault()
run()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [stage])
}
type LabStageProps = {
children: ReactNode
/** Row above the component: usually <CaseBar>. */
bar?: ReactNode
/** Controls in the bottom tray: <BeforeAfter>, <Segmented>. */
tray?: ReactNode
/** Absolutely positioned layer over the canvas, e.g. ui-review callouts. */
overlay?: ReactNode
/** The component's production width in px. */
width?: number
keys?: Record<string, () => void>
/** Wraps the stage in a full-height #fafafa page. Turn off to place several stages on one page. */
standalone?: boolean
label?: string
}
export function LabStage({ children, bar, tray, overlay, width, keys = {}, standalone = true, label = "Lab" }: LabStageProps) {
const ref = useRef<HTMLElement>(null)
const [hidden, setHidden] = useState(false)
useLabKeys(ref, { ...keys, h: () => setHidden((h) => !h) })
const stage = (
<section
ref={ref}
className="lab-stage"
aria-label={label}
data-lab-stage=""
data-chrome-hidden={hidden ? "" : undefined}
style={width ? ({ "--lab-width": `${width}px` } as CSSProperties) : undefined}
>
<div className="lab-canvas">
<div className="lab-subject">
{bar && <div className="lab-bar">{bar}</div>}
<div data-lab-root="">{children}</div>
</div>
{overlay}
</div>
{tray && (
<div className="lab-tray" role="toolbar" aria-label="Lab controls">
{tray}
</div>
)}
</section>
)
return standalone ? <main className="lab-page">{stage}</main> : stage
}
export function CaseBar({ label, onPrev, onNext, noun = "scenario" }: { label: string; onPrev: () => void; onNext: () => void; noun?: string }) {
return (
<>
<span className="lab-label" aria-live="polite">
{label}
</span>
<span className="lab-steps">
<button type="button" className="lab-round" aria-label={`Previous ${noun}`} onClick={onPrev}>
<Icon d="m15 18-6-6 6-6" />
</button>
<button type="button" className="lab-round" aria-label={`Next ${noun}`} onClick={onNext}>
<Icon d="m9 18 6-6-6-6" />
</button>
</span>
</>
)
}
export type SegOption<T extends string> = { value: T; label: ReactNode; disabled?: boolean }
export function Segmented<T extends string>({
label,
options,
value,
onChange,
square = false,
}: {
label: string
options: SegOption<T>[]
value: T
onChange: (v: T) => void
square?: boolean
}) {
return (
<div className="lab-seg" role="group" aria-label={label}>
{options.map((o) => (
<button
key={o.value}
type="button"
className="lab-seg-btn"
data-square={square ? "" : undefined}
aria-pressed={o.value === value}
disabled={o.disabled}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
))}
</div>
)
}
export type View = "before" | "after"
export const VIEWS = ["before", "after"] as const
/** Before renders the untouched snapshot, After the real component. Disabled until the first fix lands. */
export function BeforeAfter({ value, onChange, disabled = false }: { value: View; onChange: (v: View) => void; disabled?: boolean }) {
return (
<Segmented
label="Before or after"
value={value}
onChange={onChange}
options={[
{
value: "before",
label: (
<>
<Icon d={["M3 12a9 9 0 1 0 3-6.7L3 8", "M3 3v5h5"]} />
Before
</>
),
disabled,
},
{
value: "after",
label: (
<>
<Icon d={["M21 12a9 9 0 1 1-3-6.7L21 8", "M21 3v5h-5"]} />
After
</>
),
disabled,
},
]}
/>
)
}
export function Icon({ d }: { d: string | string[] }) {
return (
<svg
className="lab-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{(Array.isArray(d) ? d : [d]).map((p) => (
<path key={p} d={p} />
))}
</svg>
)
}
references/lab-page.md
# Lab page spec
The lab page is a throwaway dev page that holds one component and a small control tray. The tray is a tool, not part of the design. Build it to this spec and leave it alone.
## The kit
Copy [lab-kit.tsx](../assets/lab-kit/lab-kit.tsx) and [lab-kit.css](../assets/lab-kit/lab-kit.css) into the lab folder unchanged and build the page from `LabStage`, `CaseBar`, `BeforeAfter` and `Segmented`. Do not restyle the kit or rebuild it by hand: a hand-built tray drifts from this look on every run. The kit is React 18+ with no other dependencies. On another stack, port it and keep the values below.
## Layout
- A `#fafafa` page with a white stage centred on it: 1px `#e8e8e8` border, 28px radius, 8px padding.
- The component sits in the middle of the stage at its production width (`width` on `LabStage`), with 64px of space above and below.
- The tray is an inset box at the bottom of the stage: `#fafafa`, 1px `#e8e8e8` border on all sides, 20px radius, 16px padding, controls centred.
- An optional label pill at the top left of the component ("Default", "Long text") and round prev and next buttons at the top right, 40px each, aligned to the component's edges.
- Only the chrome sets a font: the system stack (`system-ui, -apple-system, "Segoe UI", sans-serif`) at 15px, in neutral greys. The component inherits the app's own font, so it renders as it does in the product. The chrome must never be mistaken for the design.
## Controls
| Control | Look | URL param |
| --- | --- | --- |
| Scenario stepper | Label pill plus `‹` `›` round buttons | `?case=<slug>` |
| Before / After | Two-segment pill with icons: `↺ Before` and `↻ After`. Active segment `#f2f2f2` fill and 1px inner border | `?view=before` or `?view=after` |
| Variant picker | Segmented pill `1 2 3`, 40px square segments | `?v=1` |
The URL is the source of truth, so every view is a shareable link that survives reload.
## Keys
| Key | Action |
| --- | --- |
| `←` / `→` | Previous or next scenario or variant, wrapping at the ends |
| `1` to `9` | Jump to that variant |
| `B` / `A` | Before or After |
| `H` | Hide or show the tray, for clean screenshots |
Shortcuts only fire on bare key presses outside text entry: skip the event when Ctrl, Cmd, Alt or Shift is held, when another handler already handled it, or when focus is in a form field or an editable region.
## Accessibility
- The tray is a `<div role="toolbar" aria-label="Lab controls">`.
- Segments are `<button type="button" aria-pressed>`, with visible focus rings.
- Prev and next buttons carry `aria-label="Previous scenario"` and `aria-label="Next scenario"`.
- Switching is instant and keeps the scroll position.
## Screenshots
Capture the whole stage, never the bare component: `deviceScaleFactor: 2`, clipped to `[data-lab-stage]` with 48px of the page around it. Press `H` first only when the image must show the component alone.
## The before snapshot
When the page offers Before and After, take the snapshot before the first edit:
1. Copy the component file into the lab folder as `before/<Component>.tsx` (or the project's extension).
2. Rewrite its relative imports to the project's path alias so it still resolves.
3. Never edit the snapshot again. "Before" renders the snapshot, and "After" renders the real component.
4. If the component spans several files, snapshot only the files you will change.
## Where the page lives
Put everything in one folder: the page, fixtures, the tray and any snapshot.
| Stack | Page location | Dev-only guard |
| --- | --- | --- |
| Next.js App Router | `app/lab/<skill>/<component>/page.tsx` (no `_` prefix, which makes a folder private and unroutable) | `if (process.env.NODE_ENV === "production") notFound()` |
| Next.js Pages Router | `pages/lab/<skill>/<component>.tsx` | The same check |
| Vite + React Router | `src/lab/<skill>/<component>/` plus one route entry | Register the route only when `import.meta.env.DEV` |
| SvelteKit | `src/routes/lab/<skill>/<component>/+page.svelte` | `dev` from `$app/environment`, else `error(404)` |
| Astro | `src/pages/lab/<skill>/<component>.astro` | Redirect to 404 when `import.meta.env.PROD` |
| Plain HTML | `lab/<skill>/<component>.html` | Not linked; delete before deploy |
In frameworks that split server and client code, make the page client code (`"use client"` in Next.js). Otherwise mock data passed across that split may not arrive, and the page renders blank with no error. If the project already has a sandbox (Storybook, Ladle, a playground route), ask whether to use it instead.
SKILL.md
---
name: design-variant
description: >-
Build three genuinely different designs of one component behind a 1-2-3
picker on a throwaway page, iterate on any of them by number, then promote the
one the user picks into the real component. Use when the user says "give me 3
options", "show me variants of this card", or "explore other layouts for X".
Not for product states (state-machine), stress tests (edge-case) or a critique
(ui-review).
user-invocable: true
disable-model-invocation: true
argument-hint: '[component name or path] [what to explore]'
---
# Design variant
This skill does one thing: it turns one component into three real, working alternatives so the designer can flip between them, push on any one, and pick. It does not render product states (that's `state-machine`), stress-test content (that's `edge-case`) or score finished UI (that's `ui-review`).
The agent builds the options. The designer picks. The agent never ranks the variants or chooses a winner on its own.
## Stance
Three variants that differ only in color are one variant. Each option must be a different answer to the same brief, so that choosing between them teaches the designer something.
Two failure modes, and the first is worse:
1. **Three tints of one idea.** Same layout, different accent color or radius. The picker becomes a slot machine.
2. **Three ideas that break the floor.** A variant looks striking but fails contrast or hides a control. A beautiful option the team can't ship wastes the comparison.
## Hard rules
1. **One component per run.** Ask which one if the request spans several.
2. **Pick one primary axis and spread the variants along it.** Choose from the table below. Secondary changes must follow from the axis, never vary on their own.
3. **Same content, same data, same component API.** Every variant takes the same props and renders the same information, so the comparison is fair and the winner drops in.
4. **Use the project's design system.** Variants are built from existing tokens, components and type scale. A variant that needs a new token says so in its note.
5. **Every variant clears the floor.** See the floor below. If a direction only works by breaking it, drop the direction and say why.
6. **Production stays untouched until the user picks.** Variants live in the lab folder. Only the chosen one is promoted.
7. **Iterate one variant at a time.** "Make 2 denser" changes variant 2 only.
8. **One folder, dev only.** The page, fixtures, variant files and tray live in one folder behind a dev-only guard, and production never imports from it. Removal is one delete plus any route registration line.
## Axes
| Axis | What changes | Example spread |
| --- | --- | --- |
| Structure | Grouping, order, columns, where metadata sits | 1: stacked, footer bar · 2: icon tile, inline meta · 3: split card, meta column |
| Density | Spacing and how much fits | 1: comfortable · 2: compact · 3: dense table-like row |
| Emphasis | What gets filled color and weight, what recedes | 1: title-led · 2: status-led · 3: metric-led |
| Type | Scale steps, weight contrast, line length | 1: one size, weight contrast · 2: large title · 3: small caps labels |
| Copy | Labels, tone, how much text | 1: full sentences · 2: terse labels · 3: icon plus count |
If the user names what to explore ("other layouts"), that sets the axis. Otherwise recommend one axis in one line and proceed.
## The floor
Identical for all three variants and never traded against the axis:
- Text contrast at least 4.5:1, large text and icons at least 3:1.
- Every control has an accessible name, a visible focus ring and a hit area of at least 24 by 24px (44px on touch-first products).
- No meaning carried by color alone.
- Nothing clips or overflows at 320px wide or at 200% zoom.
- Truncated text has a way to reach the full value.
## The sequence
### 1. Scope and brief
Restate the component and the goal in one sentence ("Issue card: make severity and impact easier to scan"). **Gate:** if the goal is unclear, ask one question with a recommended answer, then wait.
### 2. Declare the axis and the three positions
Write them before building:
```text
Axis: Structure
1 Stacked: title block, footer bar with severity, users, status
2 Icon tile: colored severity tile on the left, metadata inline under the text
3 Split: text left, metadata in its own right column
```
### 3. Build the page
Create `/lab/design-variant/<component>` per [lab-page.md](references/lab-page.md), from the copied lab kit. Each variant is its own file in the lab folder (`variant-1.tsx`, `variant-2.tsx`, `variant-3.tsx`), rendered on the same stage with the same fixture data. The tray holds the `1 2 3` picker. Variant 1 is the default.
If the component only makes sense in its page (a header, a sidebar), render the variants inside a copy of that page's layout instead of on the stage.
### 4. Check and hand over
Open each variant, check the floor, and hand over the link with one line per variant naming what it bets on. Then stop and wait.
### 5. Iterate on request
Apply each request to the named variant only. Keep a short change log per variant in the report. If the user asks for a fourth direction, add `4` to the picker rather than replacing one.
### 6. Promote the pick
When the user picks, apply the chosen variant to the real component, keeping its props and its callers unchanged. Run the type check or build, open the real page where the component lives, and confirm it renders the same as the lab. Then delete the lab folder and any route line.
## Output
```text
Design variant: <component>
Page: http://<dev-url>/lab/design-variant/<component>
Axis: Structure
1 Stacked: footer bar keeps metadata scannable in long lists
2 Icon tile: severity reads first; costs 48px of width
3 Split: metadata column aligns across rows; needs 560px or more
Keys: 1/2/3 or ←/→ variant · H hide tray
```
## Anti-patterns
- **The tint spread.** Three variants with the same layout and different colors. The tell: the diff between variants is only CSS color values.
- **The secret favorite.** The agent polishes one variant and leaves two rough. The tell: one file is twice as long as the others.
- **The forked API.** A variant adds a new prop so it can work. The tell: the winner can't be promoted without editing its callers.
- **The token drift.** A variant invents `#6b4eff` and `13.5px`. The tell: raw values where the system has tokens.
Inspired by Jakub Krehel's `variant` skill (MIT).