assets/lab-kit/callouts.css
/* Callout layer for ui-review. Copy unchanged. Font and color come from .lab-overlay in lab-kit.css. */
.lab-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.lab-leaders {
position: absolute;
left: 0;
top: 0;
overflow: visible;
}
.lab-leaders path {
fill: none;
stroke: #c4c4c4;
stroke-width: 1;
stroke-dasharray: 3 3;
}
.lab-leaders circle {
fill: #9a9a9a;
}
.lab-callout {
position: absolute;
box-sizing: border-box;
width: 280px;
padding: 16px 18px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
pointer-events: auto;
}
.lab-callout h4 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 500;
line-height: 1.4;
color: #1a1a1a;
}
.lab-callout p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #6b6b6b;
text-wrap: pretty;
}
.lab-callout[data-fixed] {
opacity: 0.6;
}
.lab-callout-check {
color: #1f9d55;
}
/* Narrow screens: a numbered list below the component, with matching pins on the targets. */
.lab-overlay[data-stacked] {
position: static;
width: 100%;
max-width: var(--lab-width, 420px);
margin-top: 24px;
}
.lab-overlay[data-stacked] .lab-callout {
position: static;
width: auto;
margin-bottom: 12px;
}
.lab-callout-n,
.lab-pin {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 999px;
background: #1a1a1a;
color: #fff;
font-size: 12px;
font-weight: 500;
}
.lab-callout-n {
margin-right: 8px;
vertical-align: 1px;
}
.lab-pin {
position: absolute;
margin: -10px 0 0 -10px;
}
assets/lab-kit/callouts.tsx
// Callout layer for ui-review: pins each finding to its element, the way a designer marks up a screenshot.
// Copy unchanged next to lab-kit.tsx and pass it to <LabStage overlay={...}>. Show at most 4 findings per view.
import { useLayoutEffect, useRef, useState } from "react"
import "./callouts.css"
export type Finding = {
id: string
/** Selector inside the rendered component, e.g. "h3" or 'button[aria-label="Bookmark"]'. */
target: string
title: string
body: string
fixed?: boolean
}
/** Findings for one view: 4 per page, 2 per side. */
export const PER_VIEW = 4
export const pageOf = (findings: Finding[], page: number) => findings.slice(page * PER_VIEW, page * PER_VIEW + PER_VIEW)
const CARD_W = 280
const GAP = 40
const STACK = 16
const EDGE = 12
type Side = "left" | "right"
type Placed = { id: string; side: Side; x: number; y: number; h: number; tx: number; ty: number }
type Box = { left: number; top: number; width: number; height: number }
export function Callouts({ findings, showFixed = false, layoutKey }: { findings: Finding[]; showFixed?: boolean; layoutKey?: string }) {
const layer = useRef<HTMLDivElement>(null)
const cards = useRef(new Map<string, HTMLDivElement>())
const [placed, setPlaced] = useState<Placed[]>([])
const [pins, setPins] = useState<{ n: number; x: number; y: number }[]>([])
const [stacked, setStacked] = useState(false)
useLayoutEffect(() => {
const el = layer.current
const canvas = el?.parentElement
const root = canvas?.querySelector<HTMLElement>("[data-lab-root]")
if (!el || !canvas || !root) return
const measure = () => {
const c = canvas.getBoundingClientRect()
// Works under CSS zoom or transforms: convert screen px back to the canvas's own px.
const scale = c.width / canvas.offsetWidth || 1
const local = (r: DOMRect): Box => ({
left: (r.left - c.left) / scale,
top: (r.top - c.top) / scale,
width: r.width / scale,
height: r.height / scale,
})
const rr = local(root.getBoundingClientRect())
const mid = rr.left + rr.width / 2
// Cards may hang past the stage, but never past the window.
const screen = root.getBoundingClientRect()
const room = Math.min(screen.left, document.documentElement.clientWidth - screen.right) / scale
const narrow = room < CARD_W + GAP + EDGE
setStacked(narrow)
const items = findings.map((f, i) => {
const t = root.querySelector<HTMLElement>(f.target)
const r = t ? local(t.getBoundingClientRect()) : { left: mid, top: rr.top, width: 0, height: 0 }
const side: Side = r.left + r.width / 2 < mid ? "left" : "right"
return { f, n: i + 1, r, side }
})
if (narrow) {
setPins(items.map((i) => ({ n: i.n, x: i.r.left + i.r.width, y: i.r.top })))
setPlaced([])
return
}
// Balance the columns: move the target nearest the middle when one side has too many.
const cap = Math.ceil(items.length / 2)
for (const [from, to] of [
["left", "right"],
["right", "left"],
] as const) {
while (items.filter((i) => i.side === from).length > cap) {
const nearest = items
.filter((i) => i.side === from)
.sort((a, b) => Math.abs(a.r.left + a.r.width / 2 - mid) - Math.abs(b.r.left + b.r.width / 2 - mid))[0]
nearest.side = to
}
}
const out: Placed[] = []
let top = rr.top
let bottom = rr.top + rr.height
for (const side of ["left", "right"] as const) {
let floor = -Infinity
items
.filter((i) => i.side === side)
.sort((a, b) => a.r.top - b.r.top)
.forEach((i) => {
const h = cards.current.get(i.f.id)?.offsetHeight ?? 96
const ty = i.r.height > 40 ? i.r.top + 20 : i.r.top + i.r.height / 2
const y = Math.max(ty - 28, floor + STACK)
floor = y + h
top = Math.min(top, y)
bottom = Math.max(bottom, floor)
const x = side === "left" ? rr.left - GAP - CARD_W : rr.left + rr.width + GAP
const tx = side === "left" ? i.r.left - 6 : i.r.left + i.r.width + 6
out.push({ id: i.f.id, side, x, y, h, tx, ty })
})
}
// Grow the canvas when a column runs past the component. Each side is measured from the component, so this is stable.
const above = rr.top - top + 32
const below = bottom - rr.top - rr.height + 32
canvas.style.paddingTop = above > 64 ? `${Math.ceil(above)}px` : ""
canvas.style.paddingBottom = below > 64 ? `${Math.ceil(below)}px` : ""
setPins([])
setPlaced(out)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(root)
ro.observe(canvas)
return () => ro.disconnect()
}, [findings, layoutKey])
const at = new Map(placed.map((p) => [p.id, p]))
return (
<div ref={layer} className="lab-overlay" data-stacked={stacked ? "" : undefined}>
{!stacked && (
<svg className="lab-leaders" width="1" height="1" aria-hidden="true">
{placed.map((p) => {
const cx = p.side === "left" ? p.x + CARD_W + 10 : p.x - 10
const cy = Math.min(Math.max(p.ty, p.y + 22), p.y + p.h - 22)
const mx = (cx + p.tx) / 2
return (
<g key={p.id}>
<path d={`M${cx} ${cy}H${mx}V${p.ty}H${p.tx}`} />
<circle cx={cx} cy={cy} r="3" />
<circle cx={p.tx} cy={p.ty} r="3" />
</g>
)
})}
</svg>
)}
{pins.map((p) => (
<span key={p.n} className="lab-pin" style={{ left: p.x, top: p.y }} aria-hidden="true">
{p.n}
</span>
))}
{findings.map((f, i) => {
const p = at.get(f.id)
const done = showFixed && f.fixed
return (
<div
key={f.id}
ref={(node) => {
if (node) cards.current.set(f.id, node)
else cards.current.delete(f.id)
}}
className="lab-callout"
data-fixed={done ? "" : undefined}
style={stacked ? undefined : p ? { left: p.x, top: p.y } : { visibility: "hidden" }}
>
<h4>
{stacked && <span className="lab-callout-n">{i + 1}</span>}
{done && <span className="lab-callout-check">✓ </span>}
{f.title}
</h4>
<p>{f.body}</p>
</div>
)
})}
</div>
)
}
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/callouts.md
# Callout layer spec
Callouts pin each finding to the element it is about, the way a designer marks up a screenshot. Use [callouts.tsx](../assets/lab-kit/callouts.tsx) unchanged as the `overlay` of `LabStage`; it implements everything below.
## Look
- A card per finding: white, 1px `#e8e8e8` border, 16px radius, 16px by 18px padding, 280px wide, a soft shadow.
- Title at 15px, `#1a1a1a`, regular weight. Body at 14px, `#6b6b6b`, line height 1.5. System font stack, with no project tokens.
- A dashed leader line (`1px dashed #c4c4c4`) from the card's near edge to the target element, with a 6px `#9a9a9a` dot at each end. Route it with right-angle elbows.
- In After, a fixed finding gets a small ✓ before its title and the card fades to 60% opacity. An open finding stays at full opacity.
## Placement
1. Show at most 4 findings per view, 2 per side. When there are more, the tray gets a `1 2` page pill and the rest go on the next page. Order findings by severity so page 1 holds the worst.
2. Measure each target with `getBoundingClientRect()` relative to the canvas, corrected for any zoom or transform.
3. Put a callout on the side of the target's centre. If one side gets more than half, move the target nearest the middle to the other side.
4. In each column, sort by the target's top edge and push cards down so they never overlap (16px gap). A card starts level with its target.
5. Cards may hang past the stage edge but never past the window. When there is less than one card plus its 40px gap beside the component, show the findings as a numbered list below it, with matching numbered pins on the targets.
6. Grow the canvas above and below when a column runs past the component, measured from the component so it never grows on its own.
7. Recompute on resize and when switching Before, After or page.
## Targeting
- Target elements by a selector scoped to the rendered component (`[data-lab-root] h3`, `[data-lab-root] button[aria-label="Bookmark"]`). Never add attributes to the component itself.
- If a selector matches more than one element, target the first and say "and 3 more" in the body.
## Keys
`H` hides the tray and the callouts together, for a clean screenshot of the component alone.
references/checks.md
# Checks by category
Run these against the rendered component. When the project's own system sets a different value on purpose, the system wins. Note it instead of flagging it.
## UI
| Check | Pass |
| --- | --- |
| Concentric radius | Inner radius = outer radius minus the inset between them. A 12px card with a 4px inset holds an 8px inner element |
| Consistent radius | Siblings of the same kind share one radius |
| Borders and dividers | One border weight and color per level; no double borders where two boxes meet |
| Icon alignment | Icons optically centered with their label; one icon size per row |
| Icon style | One family and stroke weight across the component |
| Shadows | At most one elevation per level; shadows match the light direction used elsewhere |
## Typography
| Check | Pass |
| --- | --- |
| Scale | Every size comes from the type scale |
| Hierarchy | At most 3 visible text levels in one component |
| Headings wrap | `text-wrap: balance` on short headings so lines are even |
| Body wraps | `text-wrap: pretty` on paragraphs so the last line is not one orphan word |
| Line length | 45 to 75 characters for body text |
| Line height | About 1.4 to 1.6 for body text, 1.1 to 1.3 for headings |
| Numbers | `tabular-nums` where numbers change or align in columns |
| Truncation | Truncated text has a way to read the full value |
## Layout
| Check | Pass |
| --- | --- |
| Spacing scale | Every gap and padding is on the spacing scale (often a 4px grid) |
| Proximity | The space between related items is smaller than the space between groups |
| Alignment | Text, icons and controls share left edges and baselines |
| Padding balance | Optical padding looks even; horizontal padding is usually equal to or larger than vertical |
| Flex safety | Text columns in flex rows have `min-width: 0` |
## Color
| Check | Pass |
| --- | --- |
| Body text contrast | At least 4.5:1 against its actual background |
| Large text and UI parts | At least 3:1 (text 24px and up, or 18.66px bold and up; icons, borders of controls, focus rings) |
| Tokens | Colors come from tokens, not raw values |
| Meaning | Status uses color plus an icon or label, never color alone |
| Semantic use | Danger colors only on destructive or error states |
| Dark mode | Checked if the app has one; no pure black on pure white surfaces in dark |
## Writing
| Check | Pass |
| --- | --- |
| Case | One case style for labels and buttons (usually sentence case) |
| Labels | Buttons say what happens ("Delete project", not "OK") |
| Length | Titles fit in one or two lines with realistic content |
| Numbers | Plurals agree ("1 user"); large numbers are formatted for the locale |
| Errors | Each error says what happened and how to recover |
| Terms | One word per concept across the component |
## Accessibility
| Check | Pass |
| --- | --- |
| Hit area | At least 24 by 24px for any control (WCAG 2.2 minimum); 44 by 44px on touch-first products |
| Names | Icon-only buttons have an `aria-label` |
| Focus | Every interactive element shows a visible focus ring with at least 3:1 contrast |
| Keyboard | Every action reachable with Tab, Enter and Space, in visual order |
| Semantics | Headings, lists and buttons use the right elements, not styled divs |
| Zoom | Nothing clips or overlaps at 200% zoom or 320px wide |
| Motion | Any movement respects `prefers-reduced-motion` |
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`. The callout layer is [callouts.tsx](../assets/lab-kit/callouts.tsx) with [callouts.css](../assets/lab-kit/callouts.css). 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: ui-review
description: >-
Review one component's craft across UI, typography, layout, color, writing and
accessibility, show each finding as a callout pinned to the exact element on a
throwaway page, then apply approved fixes with a Before and After toggle. Use
when the user says "review this component", "what's off about this card", or
"polish this UI". Not for stress tests (edge-case), new designs
(design-variant) or motion code (review-animations).
user-invocable: true
disable-model-invocation: true
argument-hint: '[component name or path] [--only typography,color,...]'
---
# UI review
This skill does one thing: it inspects one rendered component the way a senior designer would, points at each problem on the component itself, and fixes what the user approves. It does not feed it worst-case content (that's `edge-case`), invent alternatives (that's `design-variant`), walk product states (that's `state-machine`) or review animation code (that's `review-animations`).
## Stance
Default to flagging. Approval is earned. But every finding must point at one element, name a measurable problem and carry a concrete fix. "Feels a bit busy" is not a finding. "Description text is #b4b4b4 on white, 2.1:1, below the 4.5:1 minimum" is.
Two failure modes, and the first is worse:
1. **Reviewing code instead of pixels.** Reading the JSX and guessing. Contrast, wrapping and hit areas must be measured on the rendered component.
2. **Taste dressed as a rule.** Flagging a choice the design system made on purpose. Read the system first, then hold the component to it.
## Hard rules
1. **One component per run.** Ask which one if the request spans several.
2. **Measure on the rendered component.** Use computed styles and `getBoundingClientRect` in the browser, not values read from source.
3. **Hold it to the project's own system first,** then to the universal checks. A component that uses the system's tokens correctly is not wrong because another system would differ.
4. **Every finding has a target, a measurement and a fix.** Target is one element. Measurement is a number or a quote. Fix is a specific change.
5. **Six categories, in a fixed order.** UI, Typography, Layout, Color, Writing, Accessibility. Checks with values are in [checks.md](references/checks.md).
6. **At most 8 callouts on screen.** If there are more findings, show the 8 most severe and list the rest in the report.
7. **Fix only after a yes, in the real component, after a snapshot.** See [lab-page.md](references/lab-page.md#the-before-snapshot).
8. **One folder, dev only.** The page, fixtures, snapshot, callout layer 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.
## The sequence
### 1. Scope and read the system
Restate the component in one sentence. Find the design tokens, type scale, spacing scale and any design guidelines file the project has. Note them in one line each.
### 2. Build the page
Create `/lab/ui-review/<component>` per [lab-page.md](references/lab-page.md). Render the real component with realistic mock data, at its production width, on the stage. Add the callout layer described in [callouts.md](references/callouts.md), at most 4 findings per view. The Before and After toggle stays disabled until fixes land.
### 3. Inspect and measure
Walk the six categories in order against the rendered component. For each candidate finding, measure it. Drop anything you can't measure or point at.
**Gate:** if nothing survives, say "No findings" with the categories checked, hand over the page and stop.
### 4. Pin the callouts and report
Each finding becomes a callout: a short title (2 to 3 words) and one sentence, connected to its element with a dashed leader line. Then present the table:
| # | Category | Element | Problem | Fix | Severity |
| --- | --- | --- | --- | --- | --- |
| 1 | Color | Description | `#b4b4b4` on `#fff` is 2.1:1, below 4.5:1 | Use `--text-secondary` (`#6b6b6b`, 5.3:1) | High |
| 2 | Accessibility | Bookmark button | Hit area 16 by 16px, below 24 by 24px | 32px button with the 16px icon centered | High |
| 3 | Typography | Title | Last line holds one orphan word ("code") | `text-wrap: balance` | Low |
| 4 | UI | Footer bar | Inner radius 12px inside a 12px card with 4px inset | Inner radius 8px (outer 12 minus inset 4) | Medium |
Severity is High when it blocks someone from reading or using it, Medium when it visibly breaks the system, and Low when it is polish. Ask the user which rows to fix, recommending all High and Medium.
### 5. Fix and compare
Snapshot the component, apply the approved fixes to the real component, and enable Before and After. In After, each fixed callout shows a ✓ and stays pinned so the user can see what changed. Re-measure every fixed finding and confirm the number now passes.
### 6. Clean up when asked
Delete the lab folder (including the snapshot) and any route line. Run the type check or build.
## Output
```text
UI review: <component>
Page: http://<dev-url>/lab/ui-review/<component>
System: tokens in <file>, type scale 12/14/16/20, spacing 4px grid
Findings: 6 (High 2 · Medium 2 · Low 2) · Fixed: 4 · Open: 2
Keys: B/A before/after · H hide tray and callouts
Remove: delete <folder> (+ <route file> line <n>)
```
## Anti-patterns
- **The vibe finding.** "Could use more breathing room." The tell: no number and no element.
- **The source-code guess.** Contrast computed from a hex in the CSS file while a parent sets opacity 0.6. The tell: no computed-style measurement.
- **The callout storm.** 20 callouts cover the component. The tell: callouts overlap or the component is hard to see.
- **The system override.** The fix replaces a token with a raw value. The tell: `#6b6b6b` in the diff where `--text-secondary` exists.
Inspired by Jakub Krehel's `interface-review` skill (MIT).