@askewly/design 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/recipes.json +1 -1
- package/data/terms.json +1 -1
- package/dist/index.js +13 -1
- package/dist/typography.js +97 -24
- package/dist/verify.js +32 -2
- package/package.json +1 -1
package/data/recipes.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
[{"id":"canvas-particle-field","name":"Canvas Particle Field","pattern_group":"application-ui","kind":"visual-effect","status":"draft","surface_refs":["websites"],"tokens_used":["color.semantic.action.primary","color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/canvas-particle-field.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/canvas-particle-field.md","body":"## Intent\n\nA canvas particle field draws dozens-to-hundreds of drifting dots into a 2D pixel buffer as ambient backdrop texture. Tier ③ in `knowledge/expressive-stack.md`: at this element count, DOM nodes pay layout/paint cost per node while the canvas pays none. An experimental/showcase touch (signature principle 5) — hero and event surfaces on request, never behind forms or data UI.\n\n## Anatomy\n\n- One `<canvas>`, `aria-hidden` + `pointer-events: none` — pure decoration, invisible to assistive tech and input.\n- Dot color read from the element's computed token variables (`--primary`) at mount — no hardcoded colors in the draw loop.\n- Device-pixel-ratio scaling so dots stay crisp on high-DPI screens.\n- A component-owned rAF loop, cancelled on unmount; particle positions wrap at edges.\n- Deterministic pseudo-random seeding (index arithmetic) so renders are stable across mounts.\n\n## States\n\n- Animating (default when motion allowed): slow drift, subtle alpha variation.\n- Reduced motion: **a single static frame is drawn and the loop never starts** — canvas loops are JS-driven and are not auto-suppressed by the media query, so the branch is explicit.\n- Unmounted: rAF cancelled — no leaked frame callbacks.\n- Foreground content: always rendered above the canvas with intact contrast.\n\n## Variants\n\n- Ambient hero backdrop (this demo).\n- Cursor-influenced field: pointer position biases drift — still tier ③, adds a throttled pointermove listener.\n- Density variants: fewer/larger dots for texture, more/smaller for dust.\n- Margin-confined field: particles occupy only the whitespace beside the content axis (e.g. left margin), leaving the reading column fully undecorated — ambient energy without competing with content (taste ledger T-12, Antigravity measured).\n\n## Code\n\n```tsx\nif (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n draw() // one static frame, no loop\n return\n}\nlet frame = requestAnimationFrame(tick)\nreturn () => cancelAnimationFrame(frame)\n```\n\n## Checks\n\n- rAF loop cancelled on unmount (cleanup returns `cancelAnimationFrame`).\n- Reduced-motion branch draws a static frame instead of animating.\n- Canvas never intercepts pointer events or appears in the accessibility tree.\n- Dot color derives from token custom properties — re-themes with dark mode on remount.\n- Text over the field passes contrast; the field's alpha stays low (≤0.75 peak).\n\n## Anti-patterns\n\n- **DOM particles**: rendering each dot as a div — the exact per-node cost this tier exists to avoid.\n- **Unkilled loop**: missing `cancelAnimationFrame` cleanup keeps the loop alive after unmount, wasting CPU on an invisible canvas.\n- **Media-query-only motion gating**: assuming `prefers-reduced-motion` CSS will stop a JS loop — it will not; the branch must be in code.\n- **Interactive canvas**: putting the field above clickable UI without `pointer-events: none`.\n\n## Agent notes\n\n- prompt_phrases: \"aria-hidden canvas particle backdrop with token-derived dot color, rAF cleanup, and explicit reduced-motion static frame\"\n- fallbacks: if canvas is unavailable, render nothing — the surface below is complete without it.\n- canonical guidance: `knowledge/expressive-stack.md` decision row \"수백 개 파티클·커서 트레일\"(③) + 판정 절차 3(③④ 티어 reduced-motion 의무)."},{"id":"chat-conversation-panel","name":"Chat Conversation Panel","pattern_group":"application-ui","kind":"block","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.text.on-accent","color.semantic.action.primary","color.semantic.action.destructive","color.semantic.border.default","color.semantic.border.input","color.semantic.border.focus","dimension.space.2","dimension.space.4","dimension.radius.md","dimension.radius.lg","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/chat-conversation-panel.tsx","component_refs":["button","actionable-toast"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/chat-conversation-panel.md","body":"## Intent\n\nA chat conversation panel turns a request-response exchange with a person or an AI assistant into one continuously readable thread: a bounded, self-scrolling message log above a single always-visible input bar. It exists for turn-taking conversation — not for comment threads (no reply nesting), notification feeds (no input), or one-shot form submission.\n\nDemand source: dogfooding ledger DF-1/DF-2 — two consecutive real chat-UI tasks found no recipe covering this surface.\n\n## Anatomy\n\n- Bounded thread container that owns its own vertical scroll and pins to the newest message; the page never scrolls to follow the conversation.\n- Message bubbles aligned by author (user trailing, assistant leading), each with a visually hidden author prefix for screen readers.\n- A polite live region (`role=\"log\"`, `aria-live=\"polite\"`) around the thread so new turns are announced without stealing focus.\n- Waiting indicator rendered *as a thread entry* in the assistant position — the reply appears where the user is already looking.\n- Error row rendered as a thread entry with an icon + text (never color alone) and a single Retry action; the failed draft is never discarded.\n- Input bar: multiline-capable field + send control. Enter sends, Shift+Enter breaks a line; send stays disabled while the draft is empty or a reply is pending.\n\n## States\n\n- Empty: first-use guidance (\"no messages yet\" + what to ask) — distinct from an error and from a cleared conversation.\n- Waiting: typing indicator with `motion-safe` animation, a `motion-reduce` static fallback, and an `sr-only` \"Assistant is typing\" mirror.\n- Error: tinted `role=\"alert\"` row with icon, plain-language explanation, and Retry re-sending the last user message.\n- Focus: visible `focus-visible` ring on the input and send button; keyboard path covers compose → send → retry.\n- Scroll: thread auto-follows new messages; long messages wrap inside the bubble (`break-keep` so CJK text never splits mid-word).\n\n## Variants\n\n- Assistant chat (AI product): waiting/error states are load-bearing — model latency and failures are routine, not exceptional.\n- Elevated input (first-run / hero chat): the input bar is the product's center of gravity — it may claim the lightest surface and the most distinctive radius on screen (e.g. pure white on a warm off-white base), while everything else stays flat. Reserve this emphasis for the input alone (taste ledger T-7).\n- Human-to-human messaging: waiting indicator maps to the other party typing; delivery/read receipts attach to bubbles.\n- Embedded support widget: same anatomy inside a popover/sheet; the thread height bounds to the overlay, never the page.\n\n## Code\n\n```tsx\n<div ref={threadRef} aria-label=\"Conversation\" className=\"flex-1 space-y-3 overflow-y-auto p-4\" role=\"log\" aria-live=\"polite\">\n {messages.map((message) => (\n <div key={message.id} className={message.role === \"user\" ? \"flex justify-end\" : \"flex justify-start\"}>\n <p className={message.role === \"user\"\n ? \"max-w-[85%] break-keep rounded-lg rounded-br-sm bg-primary px-3 py-2 text-sm text-primary-foreground\"\n : \"max-w-[85%] break-keep rounded-lg rounded-bl-sm bg-muted px-3 py-2 text-sm text-foreground\"}>\n <span className=\"sr-only\">{message.role === \"user\" ? \"You: \" : \"Assistant: \"}</span>\n {message.text}\n </p>\n </div>\n ))}\n {status === \"error\" ? (\n <div className=\"flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2\" role=\"alert\">\n <AlertCircleIcon aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0 text-destructive\" />\n <div className=\"min-w-0 flex-1\">\n <p className=\"break-keep text-sm text-foreground\">The assistant could not respond. Your message was not lost.</p>\n <Button variant=\"link\" onClick={onRetry}>Retry</Button>\n </div>\n </div>\n ) : null}\n</div>\n```\n\n## Checks\n\n- Thread is the only scroll container that grows; the input bar stays visible at every viewport height (`h-[…]` or flex column with `flex-1` thread).\n- Waiting and error are rendered in the thread flow, in the assistant position, so state changes appear where attention already is.\n- Error text names what happened and what is preserved; Retry is the single action and re-sends the exact failed message.\n- Send is disabled for empty drafts and while waiting — no double-submit path.\n- New messages are announced via the polite log; focus never jumps when a reply arrives.\n- CJK copy wraps with `word-break: keep-all` (`break-keep`) inside bubbles.\n\n## Anti-patterns\n\n- **Silent failure**: request fails and nothing appears in the thread — the user cannot tell waiting from broken. Render an explicit error entry with Retry.\n- **Happy-path-only thread**: no waiting indicator, so slow replies look like ignored input and users resend duplicates.\n- **Page-level scroll**: the whole page scrolls to follow messages, detaching the input bar and losing surrounding context.\n- **Color-only role separation**: user/assistant bubbles differ only by hue — alignment + shape (asymmetric corner) + sr-only prefix must carry the distinction too.\n\n## Agent notes\n\n- prompt_phrases: \"bounded chat thread with role=log polite live region, typing indicator with reduced-motion fallback, and an in-thread error row with a single Retry\", \"chat input where Enter sends, Shift+Enter breaks, and send disables while waiting\"\n- fallbacks: for a read-only transcript use a plain list (no input, no live region); for threaded discussion with replies use a comment-thread structure, not this panel.\n- canonical guidance: `docs/design-system/principles.md` principles 5 and 7; `docs/design-system/anti-patterns.md` clusters 3, 4, 11."},{"id":"lazy-three-object-scene","name":"Lazy three.js Object Scene","pattern_group":"application-ui","kind":"visual-effect","status":"draft","surface_refs":["websites","commerce"],"tokens_used":["color.semantic.action.primary","color.semantic.surface.muted","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/lazy-three-object-scene.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/lazy-three-object-scene.md","body":"## Intent\n\nA real 3D object (lighting, depth, materials) rendered with three.js via React Three Fiber, loaded strictly behind a dynamic import. Tier ④ in `knowledge/expressive-stack.md` — CSS `perspective` can fake a rotated plane but cannot do lit, shaded geometry. The recipe's load-bearing part is the delivery contract: three.js is a ~155KB gzip fixed cost, so **the lazy boundary is not an optimization, it is the pattern** (judgment rule 4: tier ④ requires lazy-load).\n\n## Anatomy\n\n- Two modules: a light wrapper (this recipe's code_asset) and a heavy implementation module that is the only place importing `three`/`@react-three/fiber`, reached exclusively via `React.lazy(() => import(...))`.\n- `<Suspense>` skeleton for the chunk-loading state.\n- An error boundary around the scene: WebGL/context failure renders a static fallback, never a broken canvas.\n- Material color read from the document's token custom properties at mount.\n- Auto-rotation gated by `prefers-reduced-motion` (checked in JS — render loops are not auto-suppressed by the media query).\n\n## States\n\n- Chunk loading: Suspense skeleton (visible on first visit; instant afterward from cache).\n- Rendering: lit object, slow auto-rotation.\n- Reduced motion: static object, no rotation — scene still renders.\n- WebGL unavailable: boundary fallback with plain text/imagery — content never depends on the scene.\n\n## Variants\n\n- Product/object viewer (add pointer-drag orbit).\n- Brand hero object (this demo).\n- Shader-material variant — same lazy contract, custom GLSL material.\n\n## Code\n\n```tsx\nconst ThreeObjectSceneImpl = lazy(() => import(\"@/components/three-object-scene-impl\"))\n\n<SceneBoundary fallback={staticFallback}>\n <Suspense fallback={<Skeleton />}>\n <ThreeObjectSceneImpl />\n </Suspense>\n</SceneBoundary>\n```\n\n## Checks\n\n- The build emits three.js as a separate chunk; the main bundle's size does not grow beyond noise (measure before/after).\n- No module outside the lazy implementation imports `three` or `@react-three/fiber` — one static import anywhere defeats the split.\n- Reduced motion stops auto-rotation in the render loop (JS check, not CSS).\n- WebGL failure path shows the static fallback (error boundary, not a blank canvas).\n- Material colors derive from token variables — no hex in scene code.\n\n## Anti-patterns\n\n- **Static import creep**: importing three.js types/helpers from the wrapper or shared modules — silently drags the whole library into the main chunk.\n- **3D as page furniture**: mounting scenes in headers/nav rendered on every page — GPU and battery cost for decoration (signature principle 5: manual opt-in).\n- **Ignoring context loss**: no error boundary means a driver hiccup leaves a dead black canvas.\n- **Unconditional auto-rotate**: continuous camera/object motion without a reduced-motion gate is a WCAG 2.3.3 risk (vestibular triggers).\n\n## Agent notes\n\n- prompt_phrases: \"React.lazy three.js scene with Suspense skeleton, WebGL error boundary, token-derived material, reduced-motion rotation gate\"\n- fallbacks: keep a DOM/text equivalent of any information the scene carries; the scene itself must always be droppable.\n- canonical guidance: `knowledge/expressive-stack.md` tier ④ rows + 판정 절차 3·4; bundle/lazy sources in `research/2026-07-17-expressive-stack-genealogy.md` (React 통합·번들 전략)."},{"id":"showcase-card","name":"Showcase Card","pattern_group":"application-ui","kind":"block","status":"draft","surface_refs":["websites","saas-dashboards"],"tokens_used":["color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.border.default","color.semantic.border.accent","color.semantic.text.default","color.semantic.text.muted","dimension.radius.md","dimension.space.4","dimension.space.2","typography.scale.xl","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/showcase-card.tsx","component_refs":[],"term_refs":["card","hero"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-07","source_path":"recipes/application-ui/showcase-card.md","body":"## Intent\n\nA showcase card is a self-contained shell that proves an interaction capability rather than describing it: a header (icon, title, one-line copy) sits above a bounded demo region that runs a live, interactive product-interface behavior. The demo is the content — the header only orients the reader before they touch it. This is the recurring shell behind the Askewly Design landing atlas (`docs/plans/2026-07-05-showcase-atlas-upgrade.md`), used once per capability (physics interaction, cursor field, agent-ready canvas, etc.).\n\n## Anatomy\n\n```\n<article> -- outer card frame, rounded, bordered, elevated on hover\n <header row>\n <icon> -- line-art glyph, fixed box\n <title + copy> -- h3 + one-line supporting sentence\n <demo region> -- bordered sub-surface, fixed/min height, overflow-hidden\n <interactive canvas/controls> -- the actual capability (pointer, drag, physics, toggle, etc.)\n```\n\nThe demo region is visually subordinate (muted surface, inset border) so the header always reads as the \"label\" and the demo as the \"proof.\"\n\n## States\n\n- **idle**: card at rest, demo region shows a settled/neutral frame (not a blank one) so the capability is legible even before interaction.\n- **hover**: outer card lifts slightly and border shifts to the accent tone; signals the card is inspectable, independent of whether the inner demo also has its own hover behavior.\n- **demo active (pointer entered / interaction started)**: pointer, drag, or toggle input inside the demo region changes visible state (position, filter, layout, selection) in real time. This state is the actual deliverable of the recipe — a card without it is not a showcase card, it is a static tile.\n- **reduced-motion fallback**: when `prefers-reduced-motion: reduce` is set, continuous/looping motion (auto-playing progress, physics drift, shader loops) must resolve to a single stable frame that still shows the relationship the motion demonstrates (e.g., final layout state instead of a mid-animation frame). Discrete interaction (pointer-driven force, drag) may keep responding to direct input even under reduced motion, since it is user-initiated, not autoplaying.\n\n## Variants\n\n- **pointer/force-field demo** — continuous physics or field response to cursor position (e.g. `MatterPhysicsDemo`), no discrete controls needed.\n- **stateful control demo** — explicit buttons/toggles drive state (e.g. material mode, treatment filter, mobile nav step) instead of ambient pointer tracking.\n- **wide/span variant** — card spans multiple grid columns for demos that need horizontal room (image treatment strip); anatomy is unchanged, only the outer grid span differs.\n\n## Code\n\n```tsx\nfunction AtlasCard({ item }: { item: (typeof atlasItems)[number] }) {\n return (\n <article\n className={cn(\n \"group flex h-full min-w-0 flex-col overflow-hidden rounded-md border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-askewly-lavender hover:shadow-[0_18px_50px_rgba(15,23,42,0.08)]\",\n item.layout,\n )}\n >\n <div className={cn(\"grid gap-5 p-6 sm:grid-cols-[5.75rem_minmax(0,1fr)]\")}>\n <div className=\"flex items-start justify-center sm:justify-start\">\n <LineArtIcon id={item.id} />\n </div>\n <div className=\"min-w-0\">\n <h3 className=\"text-2xl font-semibold tracking-normal text-slate-950\">{item.title}</h3>\n <p className=\"mt-2 text-sm leading-6 text-slate-600\">{item.copy}</p>\n </div>\n </div>\n <div className=\"flex flex-1 flex-col border-t border-slate-200 bg-slate-50 p-5\">\n <AtlasDemo id={item.id} />\n </div>\n </article>\n )\n}\n```\n\n`bg-white` / `bg-slate-50` / `border-slate-200` map to `color.semantic.surface.raised` / `color.semantic.surface.muted` / `color.semantic.border.default`; `hover:border-askewly-lavender` maps to `color.semantic.border.accent`. Full demo implementation lives in `code_asset` (`AtlasDemo`, `MatterPhysicsDemo`, and sibling demo functions).\n\n## Checks\n\n- Header renders icon + title + one-line copy without the demo region clipping or pushing the header off-screen at the card's narrowest supported width.\n- Demo region responds to its documented input (pointer move, drag, click/toggle) with a visible state change — verified interactively (Chrome smoke), not just build/lint.\n- With `prefers-reduced-motion: reduce` simulated, any autoplaying/looping element inside the demo region settles to one stable, legible frame instead of freezing mid-motion or continuing to animate.\n- Card hover state (lift + border accent) is visible independent of whether the pointer is inside the demo sub-region.\n- `npm run build` and `npm run lint` pass for `examples/ui-vocabulary-site`.\n\n## Anti-patterns\n\n- **Isolated toy**: a demo that animates but maps to no real product-interface capability (no analogue in websites/SaaS/dashboards/commerce) — the showcase plan's skeptic perspective flags this explicitly; every card must justify itself as a reusable interaction, not decoration.\n- **No stable frame**: motion that never has a coherent \"paused\" or settled composition — if a screenshot at any random instant looks broken or mid-collision, the demo fails the idle/reduced-motion requirement.\n- **Perpetual motion with no exit**: a loop that runs forever with no way to reach a resting state (no completion, no pause) reads as a screensaver, not a product state machine; reduced-motion users have no valid fallback frame to land on.\n- **Card nested in card**: stacking a second bordered/elevated \"card\" chrome inside the demo region duplicates the outer article's framing and confuses which surface is the actual interactive content; the demo region itself is the inner boundary — it should not re-introduce its own card shell.\n\n## Agent notes\n\n- `prompt_phrases`: \"add a showcase card for <capability>\", \"wrap this interactive demo in the showcase card shell\", \"give this demo an idle/hover/reduced-motion state\".\n- `fallbacks`: if the capability has no natural continuous motion (e.g. a discrete toggle demo), the \"demo active\" state can be satisfied by state change on click/toggle alone — a reduced-motion fallback is only required when the demo has autoplaying or looping motion in the first place.\n- When implementing a new card, start from `AtlasCard` (outer shell) and add a new branch to `AtlasDemo` (or a dedicated component like `MatterPhysicsDemo`) rather than inventing a new outer card wrapper."},{"id":"spring-drag-snap-card","name":"Spring Drag Snap-back Card","pattern_group":"application-ui","kind":"motion-pattern","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/spring-drag-snap-card.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/spring-drag-snap-card.md","body":"## Intent\n\nA draggable element that springs back to origin on release, demonstrating the property that makes springs the production default for drag/reorder/dismiss interactions: **interruptibility**. Grab the card mid-return and the spring re-targets from its current velocity — a duration/easing tween would snap or restart. Tier ② — physics integration runs in JS each frame; rendering stays on CSS transform.\n\n## Anatomy\n\n- A bounded field (the drag constraint) and a draggable card inside it.\n- Spring-driven return to origin on release (`stiffness`/`damping` as the feel knobs — no durations).\n- Slight scale-up while dragging as the \"held\" affordance; `touch-none select-none` so the gesture owns the pointer.\n- Reduced-motion branch: return becomes effectively instant (very stiff spring), gesture still works.\n\n## States\n\n- Rest / Held (scale cue, grabbing cursor) / Released mid-field (spring return) / Re-grabbed mid-return (spring re-targets, no snap).\n- Reduced motion: drag works, return is immediate.\n\n## Variants\n\n- Snap-back demo (this recipe) — the teaching form.\n- Dismiss threshold: past a distance, release triggers an action instead of return (pair with an explicit non-gesture alternative — anti-patterns cluster 3).\n- Reorder: spring settles items into their new slots.\n\n## Code\n\n```tsx\n<motion.div\n drag\n dragConstraints={fieldRef}\n dragElastic={0.2}\n dragSnapToOrigin\n dragTransition={{ bounceStiffness: 380, bounceDamping: 24 }}\n whileDrag={{ scale: 1.04 }}\n/>\n```\n\n## Checks\n\n- Return is interruptible: re-grabbing mid-flight continues from current position/velocity without a jump.\n- Springs are tuned with stiffness/damping, not durations — one shared tuning across a product's drag surfaces.\n- When drag triggers real actions (dismiss/reorder), a keyboard/button alternative exists (gesture is never the only path).\n- `prefers-reduced-motion` removes the bounce, not the function.\n\n## Anti-patterns\n\n- **Tween return**: animating the release with `duration`/`ease` — snaps on re-grab and feels dead; the exact failure springs exist to fix.\n- **Unconstrained drag**: no `dragConstraints`, letting the card leave its context and cover unrelated UI.\n- **Gesture-only action**: drag-to-dismiss with no visible alternative control.\n- **Per-instance spring tuning**: every card with different stiffness/damping reads as inconsistency, not physics.\n\n## Agent notes\n\n- prompt_phrases: \"spring snap-back drag with interruptible return and reduced-motion instant fallback\", \"stiffness/damping-tuned drag release, no duration tweens\"\n- fallbacks: on devices without precise pointers the same gesture works via touch; if drag must trigger actions, add explicit buttons for the same actions.\n- canonical guidance: `knowledge/expressive-stack.md` decision row \"스프링·관성 있는 UI 모션\"(②); `docs/design-system/anti-patterns.md` cluster 3 (accessible interaction paths)."},{"id":"staggered-entrance-group","name":"Staggered Entrance Group","pattern_group":"application-ui","kind":"motion-pattern","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.border.default","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/staggered-entrance-group.tsx","component_refs":["button"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/staggered-entrance-group.md","body":"## Intent\n\nA staggered entrance offsets the start times of sibling items so a list, card grid, or dashboard reads as one coherent arrival instead of a simultaneous pop. Choreography principle from `knowledge/expressive-stack.md`: tier-agnostic — static lists can do it with CSS `animation-delay`; use tier ② (Motion `staggerChildren`) when item count is dynamic or entrances can be interrupted. Unlike magnetic hover, restrained stagger is a production default, not an experimental touch.\n\n## Anatomy\n\n- A parent that owns the choreography (`staggerChildren` interval) — children never self-schedule.\n- Per-item entrance of `opacity` + small `y` rise, spring-driven.\n- Tight interval (20–60ms) — rhythm, not a parade.\n- Sequencing order: stable structure first (headers), then content, important actions last — the eye lands where the next decision is.\n- Reduced-motion branch: everything immediately visible, no movement, no delays.\n\n## States\n\n- Entering: items cascade with offset starts.\n- Settled: identical to a never-animated layout — entrance leaves no residue.\n- Reduced motion: instant, complete render.\n- Re-run (filter/tab change): re-trigger only when the *set* changes, not on every data refresh.\n\n## Variants\n\n- List/feed entrance (this demo).\n- Card grid arrival (row-major order).\n- Dashboard widgets (structure → data → primary action).\n\n## Code\n\n```tsx\n<motion.ul\n initial={reduceMotion ? false : \"hidden\"}\n animate=\"show\"\n variants={{ show: { transition: { staggerChildren: 0.05 } } }}\n>\n {items.map((label) => (\n <motion.li\n key={label}\n variants={{ hidden: { opacity: 0, y: 10 }, show: { opacity: 1, y: 0 } }}\n transition={{ type: \"spring\", stiffness: 420, damping: 32 }}\n />\n ))}\n</motion.ul>\n```\n\n## Checks\n\n- Interval ≤60ms and total choreography under ~400ms for a typical list — content is never held hostage by its own entrance.\n- Only `opacity`/`transform` animate; layout is stable from first frame (no reflow as items land).\n- `prefers-reduced-motion` renders the complete list instantly.\n- Stagger runs on mount/set-change only — scrolling or data refresh does not replay it.\n\n## Anti-patterns\n\n- **Parade stagger**: 150ms+ intervals turn a 6-item list into a 1-second wait — choreography becomes latency.\n- **Everything staggers**: applying entrance to every section of a page makes the product feel gated; one choreographed group per view.\n- **Content gating**: hiding items until their turn in a way that survives animation failure — the settled state must be reachable even if the animation never runs.\n- **Random delays**: per-item random offsets read as jank, not rhythm — the interval is constant by design.\n\n## Agent notes\n\n- prompt_phrases: \"staggerChildren entrance with 50ms interval, spring items, reduced-motion instant render\", \"structure-first sequencing: headers, content rows, then primary actions\"\n- fallbacks: static known-length lists can use pure CSS `animation-delay` (tier ①) — escalate to Motion only for dynamic counts or interruptible entrances.\n- canonical guidance: `knowledge/expressive-stack.md` decision row \"다요소 등장 안무(stagger)\"; choreography sources in the genealogy research doc (Carbon/Material)."},{"id":"cart-drawer","name":"Cart Drawer","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.overlay","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","color.semantic.action.destructive","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/cart-drawer.tsx","component_refs":["popover","button"],"term_refs":["cart-summary","cart-summary-bar","quantity-stepper"],"source_refs":["tailwind-plus-ecommerce"],"last_verified":"2026-07-12","source_path":"recipes/commerce/cart-drawer.md","body":"## Intent\n\nA cart drawer keeps the browsing context intact while confirming what a user has added. It opens as a slide-over panel, shows each item with enough detail to double-check the choice, and moves the user toward checkout without a full page navigation. It intentionally shows only a subtotal — a full cost breakdown belongs to `checkout-order-summary`.\n\n## Anatomy\n\n- Trigger: a cart icon or button anywhere in the shell that opens the drawer without leaving the current page.\n- Panel: a slide-over surface anchored to one edge (right on desktop/mobile by default), with a scrim behind it.\n- Item rows: thumbnail, title, variant, per-item quantity control, remove action, and line price.\n- Subtotal: sum of visible line items only — no shipping/tax estimate here.\n- Primary action: a checkout button that repeats the subtotal.\n- Secondary action: a continue-shopping affordance that closes the drawer without discarding the cart.\n- Empty state: a message plus continue-shopping action when there are no items.\n\n## States\n\n- Empty: no items, subtotal hidden, only the continue-shopping action shown.\n- Populated: one or more items, subtotal and checkout action visible.\n- Quantity at minimum: decrease control disabled at quantity 1 rather than allowing an accidental removal via stepping to zero.\n- Item removed: row leaves the list and subtotal recalculates immediately.\n- Open/closed: panel slides in from the edge and returns focus to the trigger on close (inherited from the underlying overlay primitive).\n\n## Variants\n\n- Right-edge slide-over (default) for desktop and tablet.\n- Bottom-edge variant for narrow mobile viewports where a right-edge panel would feel cramped.\n- Free/zero-item variant that still renders the empty state rather than an empty list with a $0.00 subtotal.\n\n## Code\n\n```tsx\nexport function CartDrawer({ open, items, currency = \"USD\", trigger, onOpenChange, onQuantityChange, onRemove, onCheckout, onContinueShopping }: CartDrawerProps) {\n const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)\n const isEmpty = items.length === 0\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetTrigger asChild>{trigger}</SheetTrigger>\n <SheetContent aria-describedby={undefined} className=\"w-full sm:max-w-md\">\n <SheetHeader><SheetTitle>Your cart</SheetTitle></SheetHeader>\n {isEmpty ? (\n <EmptyCart onContinueShopping={onContinueShopping} />\n ) : (\n <CartItemList items={items} currency={currency} onQuantityChange={onQuantityChange} onRemove={onRemove} />\n )}\n {!isEmpty ? (\n <SheetFooter className=\"gap-3 border-t\">\n <SubtotalRow subtotal={subtotal} currency={currency} />\n <Button className=\"w-full\" onClick={onCheckout}>Checkout · {formatMoney(subtotal, currency)}</Button>\n <Button className=\"w-full\" variant=\"ghost\" onClick={onContinueShopping}>Continue shopping</Button>\n </SheetFooter>\n ) : null}\n </SheetContent>\n </Sheet>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/cart-drawer.tsx`. Built on the `Sheet` overlay primitive (`examples/ui-vocabulary-site/src/components/ui/sheet.tsx`) the same way `topbar-search.tsx` builds on `popover`.\n\n## Checks\n\n- Trigger opens the drawer without a route change; the underlying grid/page stays mounted.\n- Subtotal only sums visible line items — it never silently includes shipping or tax.\n- Decreasing quantity below 1 is blocked by the control, not by allowing 0 and hiding the row.\n- Removing the last item shows the empty state, not an empty list with a stale subtotal.\n- Closing the drawer (checkout, continue shopping, scrim click, Escape) returns focus to the trigger.\n\n## Anti-patterns\n\n- **Hidden fees in the drawer**: showing a total that already includes shipping/tax invites a mismatch with the real checkout total — keep the drawer to a subtotal only.\n- **Silent quantity-to-zero removal**: letting the stepper go to 0 and disappear the row without an explicit remove action surprises users who meant to just lower the count.\n- **Drawer as full checkout**: cramming address/payment fields into the drawer defeats its purpose as a fast confirm-and-continue surface; route to checkout instead.\n- **Stale subtotal**: updating item quantity or removing an item without recalculating the visible subtotal in the same render.\n\n## Agent notes\n\n- prompt_phrases: \"장바구니 아이콘을 누르면 오른쪽에서 열리는 cart drawer를 만들어줘\", \"수량 조절과 삭제, subtotal, checkout 버튼이 있는 슬라이드형 장바구니를 넣어줘\"\n- fallbacks: if the overlay primitive library lacks a side-anchored sheet, a `dialog` positioned flush to one edge with a manual slide transition is an acceptable substitute — do not fall back to a full-page cart for this recipe's use case.\n- component composition: reuses the same overlay-primitive relationship documented in `recipes/overlays/popover.md`'s Agent notes — assemble from the existing overlay primitive rather than re-implementing open/close/dismiss/focus-return logic."},{"id":"category-product-grid","name":"Category Product Grid","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/category-product-grid.tsx","component_refs":[],"term_refs":["product-card","filter-bar","sort-control"],"source_refs":["tailwind-plus-ecommerce"],"last_verified":"2026-07-12","source_path":"recipes/commerce/category-product-grid.md","body":"## Intent\n\nA category product grid assembles many `product-card` cells into the higher-order list structure a category or search-results page actually needs: a filter/sort bar above the grid, a loading skeleton that matches the final column count, and an empty state that offers a way out instead of a dead end. The card itself stays a separate term — this recipe is the layout and state machine around it.\n\n## Anatomy\n\n- Result count: a line above the grid stating how many items matched (or \"Loading results…\" while fetching).\n- Sort control: a single sort dropdown, right-aligned above the grid.\n- Grid container: a responsive column count (2 on narrow viewports, up to 4 on wide desktop).\n- Repeated `product-card` cells filling the grid.\n- Loading skeleton: placeholder cells matching the final column count and card shape, not a generic spinner.\n- Empty/zero-result state: message plus a reset-filters action, not just \"no results\".\n\n## States\n\n- Loading: skeleton grid shown at the same column count the real grid will use, so the layout doesn't jump on load.\n- Populated: result count, sort control, and full grid of cards.\n- Empty (zero results): no cards, a short explanation, and a reset-filters button — never an empty grid with no path forward.\n- Sort changed: result count and grid update together; scroll position is preserved rather than jumping back to the top.\n\n## Variants\n\n- 3-column grid for denser card content (larger imagery, more per-card metadata).\n- 4-column grid (default) for standard product cards.\n- Infinite-scroll load trigger appended below the grid instead of numbered pagination, for long category lists.\n\n## Code\n\n```tsx\nexport function CategoryProductGrid({ products, loading, sort, onSortChange, onResetFilters, columns = 4 }: CategoryProductGridProps) {\n if (loading) {\n return <SkeletonGrid columns={columns} />\n }\n if (products.length === 0) {\n return (\n <EmptyState message=\"No products match these filters\" onResetFilters={onResetFilters} />\n )\n }\n return (\n <section aria-label=\"Product results\">\n <ResultsHeader count={products.length} sort={sort} onSortChange={onSortChange} />\n <div className={gridColumnsClass(columns)}>\n {products.map((product) => <ProductCardCell key={product.id} product={product} />)}\n </div>\n </section>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/category-product-grid.tsx`. Built on the `select` primitive for sort and `skeleton` primitive for the loading grid.\n\n## Checks\n\n- The loading skeleton renders the same column count as the real grid — no layout shift on load.\n- Zero results always show a reset-filters action, never a silent empty grid.\n- Changing sort does not reset scroll position back to the top of the page.\n- Each grid cell renders a full `product-card` (image, title, price) — this recipe does not redefine the card's own anatomy.\n\n## Anti-patterns\n\n- **Generic spinner instead of skeleton**: a centered spinner during load causes a layout jump once real cards arrive; use a skeleton grid at the target column count instead.\n- **Dead-end empty state**: showing only \"No results\" with no reset action strands the user with the filters that produced zero matches.\n- **Column count drift**: loading skeleton at a different column count than the populated grid, causing a visible reflow.\n- **Card logic duplicated into the grid**: reimplementing product-card's own image/price/rating layout inline instead of composing the existing card.\n\n## Agent notes\n\n- prompt_phrases: \"카테고리 페이지에 필터/정렬 바와 상품 카드 그리드를 만들어줘\", \"결과 없을 때 필터 초기화 버튼이 있는 빈 상태를 넣어줘\"\n- fallbacks: if infinite scroll isn't available, numbered pagination below the grid is an acceptable substitute — keep the same loading-skeleton and empty-state behavior either way.\n- component composition: compose from the existing `product-card` term rather than inlining card markup; this recipe owns the grid/filter/loading/empty shell only."},{"id":"checkout-order-summary","name":"Checkout Order Summary","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites","mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","color.semantic.action.destructive","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.scale.lg","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/checkout-order-summary.tsx","component_refs":["button"],"term_refs":["cart-summary","checkout-step","checkout-progress-header","payment-method-card","order-status"],"source_refs":["tailwind-plus-ecommerce"],"last_verified":"2026-07-10","source_path":"recipes/commerce/checkout-order-summary.md","body":"## Intent\n\nA checkout order summary keeps the exact purchase commitment visible while the user provides delivery and payment details. Products, quantities, discounts, shipping, tax, total, fulfillment method, and final action stay consistent across steps and remain editable before confirmation.\n\n## Anatomy\n\n- Progress context: named checkout step and a clear return path.\n- Item summary: product identity, selected options, quantity, price, and edit/remove controls.\n- Cost breakdown: subtotal, discounts, shipping, tax, and total with currency consistency.\n- Fulfillment and payment facts: selected address/method with explicit change actions.\n- Final action: verb names the consequence and is disabled until required facts are valid.\n\n## States\n\n- Incomplete details with field-level errors and preserved item summary.\n- Recalculation after quantity, discount, shipping, or fulfillment changes.\n- Submitting state that prevents duplicate confirmation while keeping the total visible.\n- Payment failure with retry/change-method actions and no loss of entered details.\n- Confirmed state with order status, reference, and next-step expectations.\n\n## Variants\n\n- Desktop split layout with sticky summary beside the form.\n- Mobile linear flow with a collapsible but always reachable total.\n- Pickup checkout with location/time facts instead of shipping address.\n- Zero-charge or fully discounted order that still explains what will be authorized.\n\n## Code\n\n```tsx\nexport function CheckoutOrderSummary({ order, onConfirm, onEdit }: CheckoutOrderSummaryProps) {\n const total = order.subtotal - order.discount + order.shipping + order.tax\n const canConfirm = order.address != null && order.paymentMethod != null && !order.submitting\n\n return (\n <aside className=\"w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground\" aria-labelledby=\"checkout-summary-title\">\n <div className=\"flex items-center justify-between gap-4\">\n <h2 id=\"checkout-summary-title\" className=\"text-lg font-medium\">\n Order summary\n </h2>\n <Button size=\"sm\" type=\"button\" variant=\"ghost\" onClick={onEdit}>\n Edit cart\n </Button>\n </div>\n\n <ul className=\"mt-6 divide-y\">\n {order.items.map((item) => (\n <li key={item.id} className=\"flex justify-between gap-4 py-4\">\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-medium\">{item.name}</p>\n <p className=\"mt-0.5 text-xs text-muted-foreground\">{item.option} · Qty {item.quantity}</p>\n </div>\n <p className=\"shrink-0 text-sm font-medium tabular-nums\">{formatMoney(item.total, order.currency)}</p>\n </li>\n ))}\n </ul>\n\n <dl className=\"space-y-2 border-t pt-4 text-sm\">\n <div className=\"flex justify-between border-t pt-3 text-base font-medium\">\n <dt>Total</dt>\n <dd className=\"tabular-nums\">{formatMoney(total, order.currency)}</dd>\n </div>\n </dl>\n\n <Button className=\"mt-6 w-full\" disabled={!canConfirm} type=\"button\" onClick={onConfirm}>\n {order.submitting ? \"Placing order\" : `Place order · ${formatMoney(total, order.currency)}`}\n </Button>\n </aside>\n )\n}\n```\n\n## Checks\n\n- Total is derived from displayed line items and every adjustment uses one currency/rounding rule.\n- Edit actions return to the relevant fact without clearing unrelated delivery or payment input.\n- Final button names the consequence and repeats the committed total.\n- Submitting blocks duplicate orders; failure preserves the cart and entered details.\n- Mobile keeps total and confirmation reachable without hiding required terms or fees.\n- Pickup, shipping, and zero-charge flows explain their distinct authorization/fulfillment behavior.\n\n## Anti-patterns\n\n- **Surprise total**: tax, shipping, or fees appear only after the confirmation action.\n- **Detached summary**: line items change but the visible total remains stale.\n- **Generic Continue button**: the final action does not distinguish navigation from placing an order.\n- **Failure resets checkout**: a payment error clears address, fulfillment, or cart state.\n- **Mobile summary trap**: collapsed totals hide fees and require an unclear gesture to review the commitment.\n\n## Agent notes\n\n- prompt_phrases: \"checkout summary with editable facts, explicit fee breakdown, and duplicate-submit guard\", \"mobile order commitment with visible total and payment recovery\"\n- fallbacks: for a one-item free claim, keep a compact confirmation summary rather than removing the commitment step.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 4, 5, and 7."},{"id":"product-detail-purchase-stack","name":"Product Detail Purchase Stack","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.scale.lg","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/product-detail-purchase-stack.tsx","component_refs":[],"term_refs":["product-option-sheet","quantity-stepper"],"source_refs":["baymard-product-page-ux"],"last_verified":"2026-07-12","source_path":"recipes/commerce/product-detail-purchase-stack.md","body":"## Intent\n\nThe product detail purchase stack is the desktop-default vertical block that binds title, price, variant selection, quantity, and the add-to-cart CTA into one continuously visible unit. Baymard's product-page research recommends exposing variant choices (size/color) as visible buttons rather than hiding them behind a dropdown, and surfacing the estimated total plus a return-policy link right next to the CTA rather than deferring cost/policy facts to a later step. `product-option-sheet` is the mobile bottom-sheet variant of this same decision set — this recipe is the inline desktop default, not a replacement for it.\n\n## Anatomy\n\n- Title and rating summary link.\n- Price, with an optional unit price line (e.g. price per 100g) beneath it.\n- Variant selectors rendered as a visible button group per axis (color, size) — not a hidden `<select>`.\n- Quantity stepper (reuses the `quantity-stepper` term).\n- Stock/availability state surfaced per variant, not just for the product as a whole.\n- Primary add-to-cart CTA that repeats the estimated total.\n- Return-policy link placed directly beneath the CTA.\n\n## States\n\n- Default: a valid variant pre-selected where possible, CTA enabled.\n- Variant unavailable: that option renders disabled with a visible reason (e.g. \"Out of stock\") rather than disappearing from the button group.\n- No variant selected yet (when no sane default exists): CTA disabled until a required axis is chosen.\n- Quantity at minimum: decrease control disabled at 1, matching `quantity-stepper`'s own floor behavior.\n- Updated total: changing variant or quantity recalculates the CTA's repeated total in the same interaction.\n\n## Variants\n\n- Single-axis variant selection (color only, or size only) — the button group renders for just that one axis.\n- Two-axis variant selection (color and size together) — two separate button groups, each with its own label.\n- No variants (single SKU product) — the variant section is omitted entirely rather than shown empty.\n\n## Code\n\n```tsx\nexport function ProductDetailPurchaseStack({ title, ratingSummary, price, variantOptions, selectedVariant, onSelectVariant, quantity, onQuantityChange, estimatedTotal, onAddToCart }: ProductDetailPurchaseStackProps) {\n return (\n <div>\n <h1>{title}</h1>\n <p>{ratingSummary}</p>\n <p>{formatMoney(price)}</p>\n <div>\n {variantOptions.map((option) => (\n <button key={option.value} aria-pressed={option.value === selectedVariant} disabled={!option.available} onClick={() => onSelectVariant(option.value)}>\n {option.label}\n </button>\n ))}\n </div>\n <QuantityStepper quantity={quantity} onChange={onQuantityChange} />\n <Button onClick={onAddToCart}>Add to cart · {formatMoney(estimatedTotal)}</Button>\n <a href=\"#return-policy\">Return policy</a>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/product-detail-purchase-stack.tsx`.\n\n## Checks\n\n- Variant options render as buttons, never a `<select>`/dropdown.\n- An out-of-stock variant stays visible and disabled with a reason, rather than being removed from the list.\n- The CTA's repeated total updates in the same interaction as a variant or quantity change — no stale total.\n- A return-policy link is present directly beneath the CTA, not only on a separate page.\n- Quantity control shares the same floor-at-1 behavior as the standalone `quantity-stepper` term.\n\n## Anti-patterns\n\n- **Variant dropdown**: hiding size/color behind a `<select>` forces an extra click to see all options and hides stock state per option — Baymard flags this as a recurring product-page friction point.\n- **Silent stock-outs**: removing an unavailable variant from the button group instead of showing it disabled with a reason confuses users comparing options.\n- **Total revealed only after Add to cart**: not repeating the estimated total on the CTA itself defers a cost fact the user should see before committing.\n- **Return policy buried**: linking to return policy only from a footer or separate page, far from the purchase decision itself.\n\n## Agent notes\n\n- prompt_phrases: \"사이즈 색상을 버튼으로 고르는 product detail purchase stack을 만들어줘\", \"담기 버튼에 예상 총액과 반품 정책 링크를 함께 보여줘\"\n- fallbacks: if a unit price isn't applicable to the product, omit that line entirely rather than showing an empty placeholder.\n- component composition: reuses `quantity-stepper` for the quantity control and stays inline/desktop-default — for a mobile bottom-sheet presentation of the same variant/quantity/CTA decision set, see `product-option-sheet`."},{"id":"promo-banner-system","name":"Promo Banner System","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/promo-banner-system.tsx","component_refs":[],"term_refs":["incentive-trust-strip"],"source_refs":["tailwind-plus-ecommerce"],"last_verified":"2026-07-12","source_path":"recipes/commerce/promo-banner-system.md","body":"## Intent\n\nA promo banner system is one banner shape that carries different urgency-driven offer copy — a discount rate, a limited-time sale, a countdown — depending on where it's placed (top-of-site vs in-page). It exists specifically for time-limited, dismissible promotional messaging. It is **not** the same thing as the `incentive-trust-strip` term: that term shows fixed reassurance signals (free shipping, returns, secure checkout) that don't expire and aren't urgency-driven, and it is not user-dismissible in the same session-scoped way this recipe is.\n\n## Anatomy\n\n- Banner container: full-width at the top of the site, or scoped to a section/page.\n- Primary offer copy: the discount, sale, or promotion message.\n- Optional countdown: remaining time to the offer's end, exposed to screen readers as text (not just a visual timer).\n- CTA: links to the relevant category or product set the promotion applies to.\n- Dismiss action: closes the banner for the current session only — it reappears next session rather than being permanently hidden.\n\n## States\n\n- Visible (default): offer copy, optional countdown, CTA, and dismiss control all shown.\n- Dismissed: banner is removed from the layout for the remainder of the session.\n- Countdown expired: the banner should be swapped or removed by the caller — this recipe does not auto-hide on expiry, since the countdown value is owned by the caller.\n- No countdown: banner renders offer copy and CTA only, omitting the timer region entirely rather than leaving an empty gap.\n\n## Variants\n\n- Top-of-site placement: full-width strip above the main site chrome, typically persistent across pages until dismissed.\n- In-page placement: bordered, section-scoped banner (e.g. inside a category page) rather than spanning the full viewport width.\n- Countdown variant: adds a live remaining-time region for flash sales or limited windows.\n- Evergreen variant: same shape without a countdown, for promotions without a hard deadline.\n\n## Code\n\n```tsx\nexport function PromoBannerSystem({ copy, ctaLabel, onCtaClick, dismissed, onDismiss, countdownLabel, placement = \"top-of-site\" }: PromoBannerSystemProps) {\n if (dismissed) return null\n return (\n <div className={placement === \"in-page\" ? \"rounded-md border\" : \"border-b\"} role=\"region\" aria-label=\"Promotion\">\n <p>\n {copy}\n {countdownLabel ? <span role=\"timer\">{countdownLabel}</span> : null}\n </p>\n <Button variant=\"link\" onClick={onCtaClick}>{ctaLabel}</Button>\n <button aria-label=\"Dismiss promotion\" onClick={onDismiss}>×</button>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/promo-banner-system.tsx`.\n\n## Checks\n\n- Dismissing the banner hides it only for the current session — it is not a permanent preference change.\n- A countdown, when present, is exposed as readable text via `role=\"timer\"`, not conveyed by animation alone.\n- Top-of-site and in-page variants share the same component and prop surface; only layout/border styling differs.\n- The banner never claims to be `incentive-trust-strip` content (free shipping/returns/security) — offer/urgency copy only.\n\n## Anti-patterns\n\n- **Permanent dismissal**: persisting the dismiss choice indefinitely removes a legitimate future promotion from a returning visitor.\n- **Countdown with no text fallback**: a purely visual ticking timer is invisible to screen reader users; the remaining time must be readable text.\n- **Confusing with the trust strip**: reusing this recipe to show \"free shipping over $50\" duplicates `incentive-trust-strip`'s job and blurs the urgency-vs-reassurance distinction for users.\n- **CTA without a target**: a banner that dismisses but has no CTA linking to the relevant category/products wastes the placement.\n\n## Agent notes\n\n- prompt_phrases: \"상단에 카운트다운이 있는 세일 배너를 넣어줘, 닫으면 이번 세션에서만 사라지게 해줘\", \"상품 상세 안에 섹션 범위 프로모션 배너를 넣어줘\"\n- fallbacks: if a live countdown isn't available, drop to the evergreen variant rather than showing a static, misleading time value.\n- related: distinct from `incentive-trust-strip` — that term is fixed reassurance signals near cart/checkout, this recipe is time-limited promotional urgency messaging anywhere on the site."},{"id":"shipping-method-selector","name":"Shipping Method Selector","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.border.focus","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/shipping-method-selector.tsx","component_refs":[],"term_refs":["checkout-step","checkout-progress-header","payment-method-card","cart-summary"],"source_refs":["baymard-checkout-flow-ux"],"last_verified":"2026-07-12","source_path":"recipes/commerce/shipping-method-selector.md","body":"## Intent\n\nA shipping method selector lets a checkout user compare delivery/pickup options by cost and timing in one glance, and binds the choice directly to the order total. Baymard's checkout-flow research treats a total that changes unexpectedly after this step as a trust problem — so selecting a method must recalculate the visible total immediately, not just at final confirmation.\n\n## Anatomy\n\n- Option list: one row per method, structured as a radio group (single choice).\n- Per-row facts: carrier/store name, ETA, and price shown together, not in separate sections.\n- Selected state: visually distinct row (border/background) plus the checked radio control.\n- Pickup variant: rows for in-store pickup carry a location/time label instead of a carrier name.\n- Total binding: the selection is wired to whatever component displays the order total (`cart-summary` / `checkout-order-summary`), which must update the moment selection changes.\n\n## States\n\n- Default: one method pre-selected (commonly the cheapest or fastest, per product policy) so the total is always current.\n- Selected: chosen row highlighted; radio indicator filled.\n- Free shipping: price renders as \"Free\" rather than \"$0.00\" to read as a benefit, not a placeholder.\n- Pickup selected: no delivery ETA copy; store/pickup-window copy instead.\n- Disabled/unavailable method: row present but not selectable, with a short reason (e.g. address outside delivery zone) — not silently hidden, so the user understands why an option is missing.\n\n## Variants\n\n- Delivery-only list (no pickup row) for sellers without physical locations.\n- Mixed delivery + pickup list, pickup row(s) grouped at the end or start per product convention.\n- Single-method checkout: skip the selector entirely and show the one method as a fixed line instead of a one-item radio group.\n\n## Code\n\n```tsx\nexport function ShippingMethodSelector({ methods, selectedId, currency = \"USD\", onChange }: ShippingMethodSelectorProps) {\n return (\n <RadioGroup value={selectedId} onValueChange={onChange}>\n {methods.map((method) => {\n const selected = method.id === selectedId\n return (\n <label key={method.id} className={cn(\"flex items-start justify-between gap-4 rounded-md border p-3\", selected && \"border-ring bg-accent/50\")}>\n <span className=\"flex items-start gap-3\">\n <RadioGroupItem value={method.id} />\n <span className=\"flex flex-col gap-0.5\">\n <span className=\"text-sm font-medium\">{method.name}</span>\n <span className=\"text-xs text-muted-foreground\">{method.carrier} · {method.eta}</span>\n </span>\n </span>\n <span className=\"text-sm font-medium tabular-nums\">{formatMoney(method.price, currency)}</span>\n </label>\n )\n })}\n </RadioGroup>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/shipping-method-selector.tsx`. Built on the `radio-group` primitive (`examples/ui-vocabulary-site/src/components/ui/radio-group.tsx`).\n\n## Checks\n\n- Exactly one method can be selected at a time (native radio-group semantics, not independent checkboxes).\n- Changing selection recalculates the bound order total in the same interaction — no separate \"apply\" step.\n- Free shipping renders as \"Free\", not \"$0.00\".\n- Pickup rows never show a delivery ETA string.\n- Disabled/unavailable methods remain visible with a reason rather than disappearing.\n\n## Anti-patterns\n\n- **Cost revealed only at confirmation**: showing every method as \"calculated at next step\" instead of a real price per row reproduces the surprise-total problem this recipe exists to avoid.\n- **Split facts**: putting price in one column and ETA in another disconnected block forces the user to cross-reference rows instead of comparing at a glance.\n- **Checkbox group instead of radio group**: shipping method is a single choice; checkboxes imply multiple methods can apply simultaneously.\n- **Silent total lag**: updating the selected radio without immediately recalculating the visible order total shown elsewhere on the page.\n\n## Agent notes\n\n- prompt_phrases: \"배송/픽업 방식별 도착일과 가격을 비교하는 shipping method selector를 만들어줘\", \"선택하면 바로 총액이 바뀌는 배송 옵션 라디오 그룹을 checkout에 넣어줘\"\n- fallbacks: if carrier-level ETA data isn't available, show a date range instead of a carrier name, but never drop the price-per-row structure.\n- related: pairs with `cart-summary`/`checkout-order-summary` for the total this selector feeds; does not replace `payment-method-card`, which is a separate selection for how the order is paid, not how it is delivered."},{"id":"audit-log-filterable-export-feed","name":"Audit Log Filterable Export Feed","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/audit-log-filterable-export-feed.tsx","component_refs":["button"],"term_refs":["audit-log","activity-feed"],"source_refs":["linear-workspace-audit-log"],"last_verified":"2026-07-12","source_path":"recipes/data-display/audit-log-filterable-export-feed.md","body":"## Intent\n\nAn audit log filterable export feed implements the `audit-log` term's actor/action/target/timestamp record as a filterable, exportable operational surface: filters scope by actor, action type, and date range, and export always applies to the currently active filter rather than silently dumping the full unfiltered table. Unlike `activity-feed`, which reads as a lightweight social/collaboration stream, this recipe is a forensic/compliance record — the column order (actor, action, target, timestamp) stays fixed regardless of filter state, and metadata expands on demand rather than always rendering inline.\n\n## Anatomy\n\n- Filter bar: actor, action type, and date range controls.\n- Row: actor, action verb, target entity, timestamp, with metadata available via expand-on-demand.\n- Export action: scoped to the current filter (CSV/JSON), never a silent full-table export.\n- Empty state: distinguishes \"no events match this filter\" from \"no events have occurred yet.\"\n\n## States\n\n- Populated with active filter: rows reflect only the current actor/action/date scope; the export label reflects that scope (e.g. \"Export filtered\").\n- No events yet: empty state explains the log is genuinely empty, not filtered down to nothing.\n- No matching filter: empty state explains the filter excluded everything and invites resetting it — distinct copy from the \"no events yet\" case.\n- Row expanded: metadata renders inline for that row only, collapsing back on toggle.\n\n## Variants\n\n- Full filter bar + export (default, this recipe) for compliance/forensic review screens.\n- Read-only feed without export, for surfaces where export permission is restricted to a subset of operators.\n- Compact variant collapsing the date-range control into a single \"last 24h/7d/30d\" preset selector for lighter-weight admin panels.\n\n## Code\n\n```tsx\nexport function AuditLogFilterableExportFeed({ entries, actionFilter, onActionFilterChange, hasMatchingFilterButNoEvents, onExport }: AuditLogFilterableExportFeedProps) {\n return (\n <section className=\"flex flex-col gap-3 rounded-md border bg-card p-4 text-card-foreground\">\n {/* filter bar (actor/action/date), export button scoped to filter, actor/action/target/timestamp rows, two distinct empty states */}\n </section>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/audit-log-filterable-export-feed.tsx`.\n\n## Checks\n\n- Export always reflects the currently active filter scope; it never silently exports the full unfiltered table.\n- The \"no events yet\" and \"no matching filter\" empty states use distinct copy, not one generic empty message.\n- Column order (actor, action, target, timestamp) stays fixed across all filter states.\n- Metadata is not rendered inline by default — it requires an explicit expand action per row.\n- Filtering does not remove or reorder columns, only the row set.\n\n## Anti-patterns\n\n- **Export ignores active filter**: exporting the full table while a filter is applied silently gives the operator more (or different) data than what they reviewed on screen.\n- **One generic empty state**: collapsing \"no events yet\" and \"no matching filter\" into the same message forces the operator to guess whether the filter or the data is the problem.\n- **Always-expanded metadata**: rendering full metadata inline for every row turns a scannable log into a wall of text, defeating the feed's forensic scanning purpose.\n- **Treating this as `activity-feed`**: writing collaboration-style narrative copy (\"Alice updated the doc\") instead of structured actor/action/target/timestamp fields loses the compliance-grade precision this recipe exists for.\n\n## Agent notes\n\n- prompt_phrases: \"actor, action, target, timestamp를 필터링하고 필터 범위로 export할 수 있는 audit log를 만들어줘\", \"감사 로그를 액션 타입과 날짜 범위로 필터링하고 CSV로 내보내는 화면을 넣어줘\"\n- fallbacks: if export permission is restricted, a read-only variant without the export button is an acceptable reduced variant — do not fabricate a disabled export button as a placeholder.\n- component composition: implements the same term (`audit-log`) already in the dictionary; distinguishes itself from `activity-feed` by forensic/compliance intent rather than social/collaboration intent, per the term's own `anti_use` note."},{"id":"bulk-action-toolbar-selection-model","name":"Bulk Action Toolbar with Selection Model","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.action.destructive","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/bulk-action-toolbar.tsx","component_refs":["interactive-data-table","button"],"term_refs":["bulk-action-bar","table","checkbox"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-12","source_path":"recipes/data-display/bulk-action-toolbar-selection-model.md","body":"## Intent\n\nA bulk action toolbar turns a table's per-row selection into a single place to act on many rows at once. It appears only once a selection exists, always states the selected count out loud, and offers an explicit way to go beyond the current page — selecting every row that matches the active filter, not just the ones currently rendered. It builds directly on `interactive-data-table`'s selection state rather than defining a second, competing selection model.\n\n## Anatomy\n\n- Selected count: a badge or label stating how many rows are selected.\n- Clear selection: always-visible action that resets selection to none.\n- Select-all-matching-filter: appears only when every visible row is selected and more rows exist behind the current filter; offers to extend selection to all matches, not just the current page.\n- Bulk actions: primary/secondary buttons for the available operations (e.g. archive, export); destructive actions (e.g. delete) are visually distinct from non-destructive ones.\n- Placement: docked at the top or bottom of the table region so it stays reachable while scrolling.\n\n## States\n\n- Hidden: no rows selected — the toolbar renders nothing rather than an empty bar.\n- Partial selection: some rows selected; count reflects the exact number, select-all-matching is not offered.\n- All-visible selected: every rendered row is selected; if the table's total (post-filter) count is larger, the select-all-matching affordance appears.\n- All-matching selected: user has extended selection beyond the visible page; count reflects the full matching total.\n- Destructive action pending: a destructive bulk action (e.g. delete) routes through `destructive-confirmation` before executing — it never fires directly from the toolbar.\n\n## Variants\n\n- Sticky/floating bar anchored to the table (default) for desktop and tablet data operations.\n- Compact variant with icon-only actions for dense internal-tool tables where horizontal space is scarce.\n- Read-only variant with only \"export selected\" when no destructive or mutating action applies.\n\n## Code\n\n```tsx\nexport function BulkActionToolbar({ selectedCount, visibleCount, totalMatchingCount, onClearSelection, onSelectAllMatching, actions }: BulkActionToolbarProps) {\n if (selectedCount === 0) return null\n const allVisibleSelected = selectedCount === visibleCount\n const canSelectAllMatching = allVisibleSelected && typeof totalMatchingCount === \"number\" && totalMatchingCount > selectedCount\n\n return (\n <div className=\"flex flex-wrap items-center justify-between gap-3 rounded-md border bg-card p-2 text-card-foreground\">\n <div className=\"flex flex-wrap items-center gap-2 text-sm\">\n <Badge variant=\"secondary\">{selectedCount} selected</Badge>\n <Button size=\"sm\" variant=\"ghost\" onClick={onClearSelection}>Clear</Button>\n {canSelectAllMatching ? (\n <Button size=\"sm\" variant=\"link\" onClick={onSelectAllMatching}>Select all {totalMatchingCount} matching filter</Button>\n ) : null}\n </div>\n <div className=\"flex flex-wrap items-center gap-2\">\n {actions.map((action) => (\n <Button key={action.id} size=\"sm\" variant={action.variant === \"destructive\" ? \"destructive\" : \"outline\"} onClick={action.onAction}>\n {action.label}\n </Button>\n ))}\n </div>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/bulk-action-toolbar.tsx`. Consumes the same row/selection shape as `interactive-data-table` — it does not own selection state, it renders based on it.\n\n## Checks\n\n- Toolbar renders nothing when selectedCount is 0 (no empty bar with disabled buttons).\n- Selected count is always numerically accurate, including after select-all-matching-filter expands the selection.\n- Select-all-matching-filter only appears when every visible row is already selected and the total exceeds the visible count.\n- Destructive bulk actions require a confirmation step before executing.\n- Clear selection resets to zero and re-hides the toolbar.\n\n## Anti-patterns\n\n- **Always-visible empty toolbar**: showing a disabled bulk-action bar with 0 selected wastes space and implies actions exist when they don't.\n- **Silent full-selection lie**: labeling a \"select all\" checkbox as selecting everything when it only selects the current page misleads users operating on large filtered sets.\n- **Undifferentiated destructive action**: styling delete the same as archive/export invites accidental data loss.\n- **Selection state duplicated in the toolbar**: keeping a second copy of selected IDs inside the toolbar component instead of reading the table's selection model causes drift between the two.\n\n## Agent notes\n\n- prompt_phrases: \"테이블에서 행을 선택하면 상단에 선택 개수와 일괄 작업 버튼이 나타나는 bulk action toolbar를 만들어줘\", \"필터에 맞는 전체 선택 옵션이 있는 bulk action toolbar를 테이블 위에 추가해줘\"\n- fallbacks: if a floating/sticky toolbar isn't feasible, an inline row directly above the table header is an acceptable substitute — do not hide bulk actions inside a per-row menu once more than one row is selected.\n- component composition: assembles on top of `interactive-data-table`'s selection and filter state — do not fork a parallel selection model for this recipe."},{"id":"interactive-data-table","name":"Interactive Data Table","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["saas-dashboards","internal-tools","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/interactive-data-table.tsx","component_refs":["button"],"term_refs":["table","data-grid","data-table-toolbar","table-density-control","table-row-actions"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-10","source_path":"recipes/data-display/interactive-data-table.md","body":"## Intent\n\nAn interactive data table supports scanning and acting on structured records while preserving column meaning. Selection, sorting, filtering, density, and row actions are added only when the user task needs them; the table remains readable without those controls.\n\n## Anatomy\n\n- Toolbar: query, filters, bulk actions, and result count when applicable.\n- Header: stable column labels and optional sort controls.\n- Body: one record per row with a durable row key.\n- Selection and actions: explicit checkbox state, bulk summary, and row-level menu.\n- Responsive fallback: stacked priority fields or a deliberate reduced column set.\n\n## States\n\n- Default and hover for scanning.\n- Selected rows with observable count and reversible bulk state.\n- Sorted column with direction announced in the header.\n- Empty results with filter-reset guidance.\n- Loading, error, and narrow-screen fallbacks that retain column meaning.\n\n## Variants\n\n- Read-only comparison table.\n- Selectable operations table with bulk actions.\n- Dense table for high-frequency operator work.\n- Responsive stacked records when horizontal comparison is secondary.\n\n## Code\n\n```tsx\nexport function InteractiveDataTable({ rows }: InteractiveDataTableProps) {\n const [selected, setSelected] = useState<string[]>([])\n\n const toggle = (id: string) =>\n setSelected((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]))\n\n const toggleAll = () =>\n setSelected((current) => (current.length === rows.length ? [] : rows.map((row) => row.id)))\n\n const allSelected = rows.length > 0 && selected.length === rows.length\n\n return (\n <section className=\"overflow-hidden rounded-lg border bg-card text-card-foreground\">\n <header className=\"flex items-center justify-between gap-4 border-b p-4\">\n <p className=\"text-sm text-muted-foreground\">{rows.length} members</p>\n {selected.length > 0 ? (\n <Button size=\"sm\" type=\"button\" variant=\"outline\" onClick={() => setSelected([])}>\n Archive {selected.length}\n </Button>\n ) : null}\n </header>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"w-10\">\n <Checkbox aria-label=\"Select all rows\" checked={allSelected} onCheckedChange={toggleAll} />\n </TableHead>\n <TableHead>Name</TableHead>\n <TableHead>Role</TableHead>\n <TableHead>Status</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.id} data-state={selected.includes(row.id) ? \"selected\" : undefined}>\n <TableCell>\n <Checkbox aria-label={`Select ${row.name}`} checked={selected.includes(row.id)} onCheckedChange={() => toggle(row.id)} />\n </TableCell>\n <TableCell className=\"font-medium\">{row.name}</TableCell>\n <TableCell className=\"text-muted-foreground\">{row.role}</TableCell>\n <TableCell className=\"text-muted-foreground\">{row.status}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </section>\n )\n}\n```\n\n## Checks\n\n- Headers use table semantics and sorting exposes direction to assistive technology.\n- Row keys and selection use stable IDs, not visual positions.\n- Empty filtered results explain the filter and offer a reset action.\n- Narrow screens preserve priority fields or stack records without unlabeled values.\n- Bulk actions appear only when selection is non-empty and show the affected count.\n- Interactive row height never drops below the touch-target minimum (44px / `dimension.size.touch-target-min`) even at the densest setting — density comes from spacing and type, not from shrinking hit areas (taste ledger T-5, Linear measured).\n\n## Anti-patterns\n\n- **Table-shaped div grid**: visual columns have no header relationships for assistive technology.\n- **Position-based selection**: sorting changes which record a checked index represents.\n- **Every feature enabled**: filters, density, pagination, and bulk actions add noise without a user task.\n- **Horizontal scroll as the only mobile plan**: primary record identity and action disappear off-screen.\n\n## Agent notes\n\n- prompt_phrases: \"semantic selectable data table with stable IDs and mobile fallback\", \"operator table with explicit bulk action state\"\n- fallbacks: use a list when records have few comparable fields or each row is primarily a navigation target.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 2, and 5."},{"id":"permission-matrix-editor-grid","name":"Permission Matrix Editor Grid","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","color.semantic.action.destructive","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/permission-matrix-editor-grid.tsx","component_refs":["button"],"term_refs":["permission-matrix-editor"],"source_refs":["retool-user-permissions-template"],"last_verified":"2026-07-12","source_path":"recipes/data-display/permission-matrix-editor-grid.md","body":"## Intent\n\nA permission matrix editor grid implements the `permission-matrix-editor` term's role-by-permission table as an editable, high-density admin surface: both axes stay readable while scrolling a large matrix, explicit grants are visually distinct from inherited ones, and a batch of checkbox edits stays pending behind a diff step instead of writing to the backend on every click. This is the interaction shape Retool-style admin templates and workspace role-settings screens repeat once a permission table grows past a handful of rows and columns.\n\n## Anatomy\n\n- Sticky row header: permission/module label, one per row.\n- Sticky column header: role name, one per column.\n- Per-cell control: a checkbox with three visual states — granted, inherited, denied.\n- Dangerous-permission highlight: a badge on rows like \"delete all\" or \"billing admin\" that carries elevated risk.\n- Bulk toggle: an entire row or column can be set at once (not modeled per-cell only).\n- Diff banner: appears once any cell changes, states the pending change count, and offers commit or discard before anything is saved.\n\n## States\n\n- Clean: no pending changes — the diff banner is absent, not present-but-disabled.\n- Pending edits: one or more cells changed since the last commit; the banner shows the exact count.\n- Inherited cell: shown as granted but visually marked \"inherited\" rather than indistinguishable from an explicit grant.\n- Dangerous permission row: carries a persistent risk badge regardless of grant state, so risk is visible even before scanning individual cells.\n- Committed: pending state clears and the banner disappears after a successful save.\n\n## Variants\n\n- Full matrix with bulk row/column toggle (default, this recipe) for workspace-wide role administration.\n- Read-only audit variant that renders the same grid without checkboxes, for reviewing current grants without edit risk.\n- Single-role column view when only one role's permissions are being reviewed, dropping the multi-column sticky-header need.\n\n## Code\n\n```tsx\nexport function PermissionMatrixEditorGrid({ roles, permissions, cellState, pendingChangeCount, onToggleCell, onCommit, onDiscard }: PermissionMatrixEditorGridProps) {\n return (\n <section className=\"flex flex-col gap-2 overflow-hidden rounded-md border bg-card text-card-foreground\">\n {pendingChangeCount > 0 ? (\n <div className=\"flex items-center justify-between gap-3 border-b bg-muted/50 px-4 py-2 text-sm\">\n <span>{pendingChangeCount} unsaved permission change{pendingChangeCount === 1 ? \"\" : \"s\"}</span>\n <div className=\"flex gap-2\">\n <Button size=\"sm\" variant=\"ghost\" onClick={onDiscard}>Discard</Button>\n <Button size=\"sm\" onClick={onCommit}>Save changes</Button>\n </div>\n </div>\n ) : null}\n <div className=\"overflow-auto\">\n <Table>\n {/* sticky permission-row header, sticky role-column header, per-cell checkbox with granted/inherited/denied state */}\n </Table>\n </div>\n </section>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/permission-matrix-editor-grid.tsx`.\n\n## Checks\n\n- No pending change renders no diff banner (not a disabled/empty one).\n- Toggling a cell never writes immediately — it only updates local pending state until commit.\n- Inherited grants are visually distinguishable from explicit ones, not just implied by tooltip.\n- Dangerous-permission rows carry their risk badge in every state, not only when changed.\n- Discard reverts every pending cell back to the last committed state, not just the most recent toggle.\n\n## Anti-patterns\n\n- **Silent auto-save per checkbox**: writing each toggle immediately removes the operator's chance to review a batch of changes before they take effect on live access control.\n- **Inherited and explicit grants rendered identically**: an operator cannot tell whether revoking a cell actually removes access or just masks an inherited grant that persists elsewhere.\n- **Risk badge only on hover or expand**: burying the dangerous-permission signal behind an interaction defeats its purpose — it must be scannable in the default row view.\n- **Unbounded horizontal table with no sticky headers**: past a handful of roles, losing the permission-row label while scrolling right makes every cell ambiguous.\n\n## Agent notes\n\n- prompt_phrases: \"역할을 열로 권한을 행으로 배치하고 저장 전 diff를 보여주는 permission matrix editor grid를 만들어줘\", \"위험한 권한에 경고 배지가 있는 편집 가능한 권한 매트릭스를 넣어줘\"\n- fallbacks: if the role/permission count is small (a handful of each), a simpler per-role permission-prompt list is an acceptable reduced variant — do not force the sticky dual-axis grid on a table that fits on one screen without scrolling.\n- component composition: builds on the same term (`permission-matrix-editor`) that already defines the concept; this recipe is the grid-interaction structure (sticky headers, diff-before-commit), not a new concept."},{"id":"pull-to-refresh-list-pattern","name":"Pull to Refresh List Pattern","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/pull-to-refresh-list-pattern.tsx","component_refs":[],"term_refs":["pull-to-refresh","pull-to-refresh-indicator"],"source_refs":["material-m3-components"],"last_verified":"2026-07-12","source_path":"recipes/data-display/pull-to-refresh-list-pattern.md","body":"## Intent\n\nThe full drag-triggered refresh state machine for a mobile list: `pulling` → `threshold-crossed` → `refreshing` → `settled`/`cancelled`. `pull-to-refresh` names the gesture concept and `pull-to-refresh-indicator` names the visual indicator alone; this recipe is the composed implementation contract — the threshold-crossing judgment, the cancelable drag-back, and the rule that the indicator stays pinned while refreshing rather than scrolling away with the list.\n\n## Anatomy\n\n- List viewport: the scrollable container the gesture is scoped to.\n- Drag-offset indicator: a spinner/icon area above the list content whose height and rotation track the current pull offset.\n- Threshold line: an invisible offset value (not a rendered line) that separates \"will snap back\" from \"will refresh.\"\n- Refreshing spinner: pinned to the top of the viewport while `refreshing` is true — never scrolled off-screen by list content.\n- Released list content: the list itself, offset by the same amount as the indicator so the two move together during the drag.\n\n## States\n\n- **Idle**: no pull offset, not at drag start.\n- **Pulling**: pointer is dragging down from scroll-top, offset > 0 but below threshold. Releasing here snaps back to idle — no refresh fires.\n- **Threshold-crossed**: offset has passed `THRESHOLD_PX`. Releasing here commits to a refresh.\n- **Refreshing**: indicator pinned at the threshold height, spinning; the underlying async refresh is in flight. The list cannot be dragged again until this resolves.\n- **Settled**: refresh resolved, offset animates back to 0, new content (if any) is visible at the top.\n\n## Variants\n\n- Gesture is scoped to \"loose vertical drag-down at scroll-top only\" — it must not trigger during any other scroll direction or when the list isn't already at its top.\n- Desktop/web equivalents should prefer an explicit refresh button instead of this gesture (`pull-to-refresh`'s own `anti_use`) — this recipe is mobile/touch-scoped.\n\n## Code\n\n```tsx\nasync function onPointerUp() {\n if (phase === \"threshold-crossed\") {\n setPhase(\"refreshing\")\n setPullOffset(THRESHOLD_PX)\n await onRefresh()\n setPhase(\"idle\")\n setPullOffset(0)\n return\n }\n // Cancelled drag: released before crossing the threshold snaps back.\n setPhase(\"idle\")\n setPullOffset(0)\n}\n```\n\nFull pointer-tracking, scroll-top gating, and the colocated `PullToRefreshListPatternDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- Releasing before the threshold always snaps back without calling the refresh handler.\n- While `phase === \"refreshing\"`, the indicator stays pinned at a fixed height — it must not be pushed off-screen by the list's own scroll.\n- The gesture only starts when the list is scrolled to its top (`atScrollTop`) — a mid-list drag must not be interpreted as a refresh pull.\n- `aria-live=\"polite\"` on the indicator region so screen readers get a refresh announcement without needing to see the animation.\n\n## Anti-patterns\n\n- **Refresh firing before the threshold**: committing to a refresh on any nonzero pull offset (instead of only after crossing `THRESHOLD_PX`) makes accidental small drags trigger unwanted network calls.\n- **Indicator scrolling away while refreshing**: letting the list's own scroll carry the spinner off-screen during `refreshing` violates the M3 guidance this recipe encodes (\"Don't scroll the loading indicator off-screen\") and leaves the user unsure whether a refresh is still in progress.\n- **No cancel path**: only supporting \"drag past threshold and release\" with no way to abort a pull-in-progress strands the user in a stuck partial-pull state; a release before threshold must always resolve back to idle.\n- **Gesture not scoped to scroll-top**: triggering pull-to-refresh from anywhere in the list (not just when already scrolled to the top) conflicts with normal vertical scrolling.\n\n## Agent notes\n\n- `prompt_phrases`: \"피드 목록에 pull-to-refresh 상태 기계를 전부 구현해줘\", \"threshold를 넘기기 전에 놓으면 스냅백되게 해줘\", \"refreshing 중에는 스피너가 스크롤로 밀려나지 않게 고정해줘\"\n- If only the static visual (not the full drag state machine) is needed, use `pull-to-refresh-indicator` directly instead of this recipe.\n- Fallback: if pointer events are unavailable, a native `touchstart`/`touchmove`/`touchend` handler set can replace the pointer handlers as long as the same threshold/pin/cancel rules are preserved."},{"id":"stat-summary-grid","name":"Stat Summary Grid","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["saas-dashboards","internal-tools","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.scale.xl","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/stat-summary-grid.tsx","component_refs":[],"term_refs":["stat-list","dashboard-grid","status-indicator"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-10","source_path":"recipes/data-display/stat-summary-grid.md","body":"## Intent\n\nA stat summary grid gives a small set of operational measures a consistent hierarchy: label, current value, and a contextual trend or status. It supports quick orientation at the top of a real dashboard; it is not a license to fabricate metrics for decorative proof.\n\n## Anatomy\n\n- Section label or time range that defines the measurement context.\n- Peer stat cells with label, value, and optional trend/status.\n- Shared alignment and number formatting for comparison.\n- Optional link to the detailed report, separate from the value itself.\n\n## States\n\n- Stable value with no trend when comparison is unavailable.\n- Positive, negative, or neutral trend with text as well as color.\n- Loading placeholder that preserves final dimensions.\n- Unavailable data shown as an explicit message, not a misleading zero.\n\n## Variants\n\n- Bordered cells in an application dashboard.\n- Unframed summary band above a table.\n- Compact status list for an operations overview.\n- Dark monitoring surface using the same semantic hierarchy.\n\n## Code\n\n```tsx\nexport function StatSummaryGrid({ title, rangeLabel, stats, reportHref }: StatSummaryGridProps) {\n return (\n <section aria-labelledby=\"stat-summary-title\">\n <div className=\"flex items-end justify-between gap-4\">\n <div>\n <h2 id=\"stat-summary-title\" className=\"text-base font-medium text-foreground\">{title}</h2>\n <p className=\"text-sm text-muted-foreground\">{rangeLabel}</p>\n </div>\n {reportHref ? <a className=\"text-sm font-medium text-primary hover:underline\" href={reportHref}>Open report</a> : null}\n </div>\n <dl className=\"mt-6 grid grid-cols-1 overflow-hidden rounded-lg border sm:grid-cols-3\">\n {stats.map((stat) => (\n <div key={stat.label} className=\"border-b p-6 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0\">\n <dt className=\"text-sm text-muted-foreground\">{stat.label}</dt>\n <dd className=\"mt-2 text-3xl font-medium text-foreground tabular-nums\">{stat.value}</dd>\n {stat.trend ? <p className=\"mt-1 text-sm text-muted-foreground\">{stat.trend}</p> : null}\n </div>\n ))}\n </dl>\n </section>\n )\n}\n```\n\n## Checks\n\n- Every value names its unit, time range, and comparison context where relevant.\n- Trend meaning is conveyed in text, not color alone.\n- Unavailable data is distinct from a valid zero.\n- Values align and wrap without pushing peer cells out of the grid.\n- Metrics come from the named product workflow rather than generic dashboard filler.\n- Emphasis within a series uses a neutral base plus a single accent point (with a text label alongside the color), never coloring every bar/cell — the data-surface form of \"accent as signal\" (taste ledger T-14).\n\n## Anti-patterns\n\n- **Fake proof metrics**: arbitrary revenue, users, or conversion numbers decorate a hero with no data source or product meaning.\n- **Color-only trend**: red/green arrows provide no semantic label.\n- **Card wall**: every number sits in a heavy independent card, weakening comparison.\n- **Mixed time windows**: peer values compare daily, monthly, and lifetime measures without labels.\n\n## Agent notes\n\n- prompt_phrases: \"operational stat grid with named time range and text trends\", \"compact dashboard summary without generic KPI decoration\"\n- fallbacks: use a status list when values are categorical rather than numeric.\n- canonical guidance: `docs/design-system/principles.md` principles 2 and 4."},{"id":"swipe-action-row-pattern","name":"Swipe Action Row (Leading/Trailing)","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.action.destructive","color.semantic.accent.base","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/swipe-action-row-pattern.tsx","component_refs":[],"term_refs":["swipe-action-row","swipe-to-delete","swipe-action"],"source_refs":["apple-hig-components"],"last_verified":"2026-07-12","source_path":"recipes/data-display/swipe-action-row-pattern.md","body":"## Intent\n\nA list row implementation of the vocabulary's `swipe-action-row` term: one row that can reveal either a leading action (e.g. mark done) or a trailing, destructive-toned action (e.g. delete), instead of only supporting a single delete-only direction like `swipe-to-delete`, or a single generic direction like `swipe-action`. This recipe is the concrete two-sided contract those three related terms describe from different angles.\n\n## Anatomy\n\n- Row content: the primary tappable label/content, flex-1, always present.\n- Leading action slot: appears to the left of the content only while `revealed === \"leading\"` — non-destructive tone (accent), fixed width.\n- Trailing action slot: appears to the right of the content only while `revealed === \"trailing\"` — destructive tone, fixed width.\n- Closed-state affordance: two small tap-only icon buttons (not a swipe) shown when nothing is revealed, one per direction, so both actions stay reachable without a drag gesture at all.\n\n## States\n\n- **Closed** (`revealed === \"none\"`): row content full-width; the two tap-only reveal icons are visible at the trailing edge.\n- **Leading revealed**: leading action button visible and focusable; tapping row content again returns to closed.\n- **Trailing revealed**: trailing (destructive) action button visible; same reset-on-content-tap behavior.\n- Both leading and trailing use the same reveal/dismiss state machine — only one side can be revealed on a given row at a time.\n\n## Variants\n\n- Full-swipe commit (not implemented in the code asset): a real swipe gesture can additionally support committing an action immediately at the end of a fast full-width swipe, rather than only revealing the button. Treat that as an enhancement layered on top of the reveal/tap contract here, not a replacement for it.\n- Leading-only or trailing-only rows: omit the unused slot and its reveal icon entirely rather than rendering an empty slot — don't imply an action exists where none does.\n\n## Code\n\n```tsx\n{revealed === \"trailing\" ? (\n <button\n type=\"button\"\n onClick={onTrailingAction}\n aria-label=\"Delete\"\n className=\"flex w-16 shrink-0 items-center justify-center bg-destructive text-white\"\n >\n <TrashIcon className=\"size-4\" aria-hidden=\"true\" />\n </button>\n) : null}\n```\n\nFull row (leading slot, content, trailing slot, and the two tap-only reveal icons) and the colocated `SwipeActionRowPatternDemo` live in `code_asset`.\n\n## Checks\n\n- Every revealed action has a non-drag path to reach it — the code asset's tap-only reveal icons in the closed state satisfy this; a real gesture-driven implementation must keep an equivalent tap/long-press/more-menu alternative.\n- Trailing/destructive action uses a distinct tone (`color.semantic.action.destructive`) from the leading action (`color.semantic.accent.base`), so the two sides read as different in kind, not just position.\n- Tapping the row content while an action is revealed dismisses it back to closed, rather than triggering the action — accidental content taps must not fire a destructive action.\n- Each revealed action button has an `aria-label` describing the action, not just an icon.\n\n## Anti-patterns\n\n- **Swipe as the only path to an action**: shipping a version where the leading/trailing actions can only be reached by a physical swipe gesture strands keyboard, switch-control, and low-dexterity users — always keep a tap-only alternative (icon button, long-press menu, or an explicit \"more\" affordance).\n- **Same tone for both sides**: styling the leading and trailing actions identically removes the \"this side is safe, this side is destructive\" signal the color contrast provides.\n- **Auto-committing on reveal**: firing the action as soon as the button becomes visible (instead of waiting for an explicit tap on it) turns a two-step reveal-then-confirm interaction into a single accidental-trigger-prone one.\n\n## Agent notes\n\n- `prompt_phrases`: \"리스트 행을 스와이프하면 왼쪽엔 완료 오른쪽엔 삭제가 나오는 swipe action row를 만들어줘\", \"삭제 액션은 빨간색으로 구분해줘\", \"스와이프 못하는 사용자를 위해 탭으로도 액션을 열 수 있게 해줘\"\n- If only a single destructive action per row is needed (no leading/mark-done action), `swipe-to-delete` is the narrower term to reference — this recipe still works for that case with the leading slot simply omitted.\n- Fallback: if a drag/pointer-tracking library isn't available, the tap-only reveal icons implemented here are an acceptable baseline, not a fallback to remove once dragging works — keep both."},{"id":"api-reference-three-column-layout","name":"API Reference Three-Column Layout","pattern_group":"docs","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/api-reference-layout.tsx","component_refs":["article-documentation-layout"],"term_refs":["badge"],"source_refs":["stripe-api-reference-docs"],"last_verified":"2026-07-12","source_path":"recipes/docs/api-reference-three-column-layout.md","body":"## Intent\n\nAn API reference page needs a reading path that is different from a prose article: the reader is scanning parameter names and types on the left while running or copying a request on the right. Splitting the page into a left explanation column and a right sticky code/response column lets both happen without scrolling back and forth.\n\n## Anatomy\n\n- Endpoint header: HTTP method badge + path, above both columns.\n- Left column: one row per parameter — name, type, required/optional badge, description; nested object parameters indent under their parent.\n- Right column: language/tool tabs (curl/node/python, etc.) over a request code sample, sticky while the left column scrolls.\n- Response panel: JSON response sample stacked below the request sample in the same sticky column.\n\n## States\n\n- Default: two columns side by side on wide viewports, right column sticky within the viewport.\n- Narrow viewport: columns stack, right column loses sticky positioning and follows normal document flow.\n- Nested parameter: child rows render indented under the parent row, same row shape.\n- No parameters: left column shows only the endpoint header and a short description, no empty param-list chrome.\n\n## Variants\n\n- Read-only reference: no request execution, samples are static text (this recipe's default).\n- Paired with `interactive-api-playground` term: the right column's code sample becomes an executable form instead of static text — a distinct, heavier composition, not a variant to fake here.\n\n## Code\n\n```tsx\nfunction ApiReferenceLayout({ method, path, params, samples, response }: ApiReferenceLayoutProps) {\n return (\n <div className=\"grid w-full max-w-6xl gap-8 lg:grid-cols-[minmax(0,1fr)_26rem]\">\n <div className=\"min-w-0 flex flex-col gap-6\">\n <div className=\"flex items-center gap-2\">\n <Badge className=\"font-mono uppercase\">{method}</Badge>\n <code className=\"text-sm\">{path}</code>\n </div>\n <div className=\"flex flex-col gap-5\">\n {params.map((param) => <ParamRow key={param.name} param={param} />)}\n </div>\n </div>\n <aside className=\"lg:sticky lg:top-6 lg:h-fit\">\n <Tabs defaultValue={samples[0]?.language}>\n <TabsList>{samples.map((s) => <TabsTrigger key={s.language} value={s.language}>{s.label}</TabsTrigger>)}</TabsList>\n {samples.map((s) => (\n <TabsContent key={s.language} value={s.language}>\n <pre className=\"overflow-x-auto rounded-md bg-foreground p-4 text-background\"><code className=\"text-xs\">{s.code}</code></pre>\n </TabsContent>\n ))}\n </Tabs>\n </aside>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/api-reference-layout.tsx`. Built on the `tabs` and `badge` shadcn primitives (`examples/ui-vocabulary-site/src/components/ui/tabs.tsx`, `.../ui/badge.tsx`).\n\n## Checks\n\n- Right column's `overflow-x-auto` never widens the page on narrow viewports; the code block scrolls within its own boundary.\n- Required/optional badges are present on every top-level and nested parameter row.\n- Nested parameters remain visually subordinate (indent + border) to their parent row, not flattened into the same list level.\n- Sticky positioning only applies at `lg` and above; below that the right column reads in normal document order after the left column.\n\n## Anti-patterns\n\n- **Sticky column captures scroll on mobile**: applying the sticky/fixed right-column behavior below the `lg` breakpoint traps scroll or overlaps the left column.\n- **Flattened nested params**: rendering a nested object's fields at the same indent level as their parent loses the object grouping the reader needs.\n- **Missing required/optional signal**: omitting the badge forces the reader to infer requiredness from prose instead of scanning it.\n- **Request sample without a response sample**: showing only the call and never the shape of what comes back leaves the reference half-finished.\n\n## Agent notes\n\n- prompt_phrases: \"왼쪽에 파라미터 설명, 오른쪽에 sticky 코드 샘플이 있는 API 레퍼런스 레이아웃을 만들어줘\", \"엔드포인트 파라미터 목록과 언어별 요청 예시를 나란히 보여주는 문서 페이지를 만들어줘\"\n- fallbacks: if only one language sample exists, still use the `Tabs` shell with a single tab rather than switching to a bare `<pre>` block, so a second language can be added without restructuring.\n- related: distinct from `article-documentation-layout`, whose right rail is a static on-this-page label list, not an executable code/response panel."},{"id":"article-documentation-layout","name":"Article Documentation Layout","pattern_group":"docs","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.4","dimension.space.8","dimension.space.12","typography.scale.sm","typography.scale.xl","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/article-documentation-layout.tsx","component_refs":[],"term_refs":["breadcrumb","typography","content-section","anchor-nav"],"source_refs":[],"last_verified":"2026-07-10","source_path":"recipes/docs/article-documentation-layout.md","body":"## Intent\n\nA documentation article layout turns a long implementation rule into a predictable reading path: breadcrumb and title establish context, a short lead states the decision the article helps make, ordered sections carry definitions and examples, and an on-this-page rail exposes the structure without competing with the body.\n\n## Anatomy\n\n- Context header: breadcrumb, article title, and one-paragraph lead.\n- Main column: readable line length with ordered sections and optional code blocks.\n- Supporting rail: section labels only; it never duplicates full body copy.\n- Related terms: a final comparison surface that keeps nearby vocabulary discoverable.\n\n## States\n\n- Default: article and rail are visible on wide screens.\n- Narrow viewport: the rail disappears and the article becomes one column without horizontal overflow.\n- Long code: the code block scrolls within its own boundary.\n- Missing optional data: previews, API rows, examples, or related terms collapse without empty shells.\n\n## Variants\n\n- Setup article: conceptual guidance and ordered next steps.\n- Foundation article: tokens, usage, light/dark behavior, and agent constraints.\n- Element article: interactive preview, state contract, API, and accessibility details.\n- Category article: shared judgment rules plus a related-term catalog.\n\n## Code\n\n```tsx\nfunction DocumentationArticle({ page }: { page: DocsArticlePageData }) {\n return (\n <article className=\"mx-auto grid w-full max-w-[76rem] gap-10 xl:grid-cols-[minmax(0,1fr)_16rem]\">\n <div className=\"min-w-0 max-w-4xl\">\n <p className=\"text-xs font-semibold uppercase text-muted-foreground\">\n {page.breadcrumb}\n </p>\n <h1 className=\"mt-5 text-5xl text-foreground\">{page.title}</h1>\n <p className=\"mt-8 max-w-3xl leading-7 text-muted-foreground\">{page.lead}</p>\n <div className=\"mt-14 flex flex-col gap-14\">\n {page.sections.map((section) => (\n <section key={section.title} className=\"flex flex-col gap-6\">\n <h2 className=\"text-xl font-semibold text-foreground\">{section.title}</h2>\n {section.body.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}\n {section.code && <pre className=\"overflow-x-auto bg-foreground text-background\"><code>{section.code}</code></pre>}\n </section>\n ))}\n </div>\n </div>\n <aside className=\"hidden border-l pl-6 xl:block\" aria-label=\"On this page\">\n {page.onThisPage.map((item) => <span key={item}>{item}</span>)}\n </aside>\n </article>\n )\n}\n```\n\n## Checks\n\n- The lead names the decision or task, not merely the topic.\n- Heading order remains semantic and does not skip levels for styling.\n- At narrow width the rail is absent, the main column is `min-w-0`, and code scrolls locally.\n- Empty optional sections leave no placeholder chrome.\n- Light and dark surfaces preserve text, muted text, border, and code contrast.\n\n## Anti-patterns\n\n- **Planning memo as public copy**: internal status, implementation history, or TODO language replaces a reader-facing explanation.\n- **Navigation duplicates the article**: the rail repeats paragraphs instead of exposing a short outline.\n- **Unbounded code block**: a long command or URL widens the entire page on mobile.\n- **Heading paint without hierarchy**: arbitrary font sizes replace semantic heading order.\n\n## Agent notes\n\n- prompt_phrases: \"documentation article with a readable main column and responsive outline\", \"decision-first docs page with related vocabulary\"\n- fallbacks: omit the rail when there are fewer than three sections; preserve the article order.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 5, and 7.\n- scroll-spy rail: `anchor-nav` (term) already covers a section-link rail with an active-section indicator; wiring the `onThisPage` rail here to highlight on scroll (IntersectionObserver-based) is an `anchor-nav`-style extension of this rail, not a separate recipe."},{"id":"docs-changelog-category-filter-page","name":"Docs Changelog Page With Category Filter Chips And Pagination","pattern_group":"docs","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/docs-changelog-category-filter-page.tsx","component_refs":[],"term_refs":["docs-changelog-page","release-note-card"],"source_refs":["stripe-changelog-docs"],"last_verified":"2026-07-12","source_path":"recipes/docs/docs-changelog-category-filter-page.md","body":"## Intent\n\n`docs-changelog-page` already names a date-grouped `release-note-card` list as a page; this recipe is the concrete implementation with the two controls that make a long changelog navigable — a category filter chip row (All/GA/Preview-style categories) that narrows which cards show, and pagination so the page doesn't grow unbounded. Filtering and pagination share one state contract: changing the category resets which date groups are visible, and a date group with zero remaining cards after filtering disappears rather than rendering with an empty heading.\n\n## Anatomy\n\n- Category filter row: chip-style toggle buttons (All + one per category), one active at a time.\n- Date-grouped sections: a date header followed by that date's `release-note-card` entries.\n- Release note card: version label, tag badges, change summary — matches the `release-note-card` term's own anatomy.\n- Pagination footer: page indicator plus previous/next controls, shown only when there is more than one page.\n\n## States\n\n- Default (All category, page 1): every date group with at least one release renders.\n- Category selected: date groups whose releases don't match the category are hidden entirely, not shown with a \"no releases\" placeholder inside an otherwise-empty group.\n- No releases match the selected category across every group: a single page-level empty message replaces the group list.\n- Last page reached: the \"Next\" control is disabled (`aria-disabled`), not hidden, so the control's position stays stable.\n\n## Variants\n\n- Single-page changelog (few releases): pagination footer is omitted entirely rather than rendered disabled, since `pageCount <= 1`.\n- Search-augmented variant: a search field ahead of the category chips — not implemented here; this recipe covers the category+pagination baseline only.\n\n## Code\n\n```tsx\n<div role=\"group\" aria-label=\"Filter changelog by category\">\n {categories.map((c) => (\n <button key={c.id} aria-pressed={c.id === activeCategoryId} onClick={() => onCategoryChange(c.id)}>\n <Badge variant={c.id === activeCategoryId ? \"default\" : \"outline\"}>{c.label}</Badge>\n </button>\n ))}\n</div>\n{visibleGroups.map((group) => (\n <section key={group.date}>\n <h2>{group.date}</h2>\n {group.releases.map((r) => (\n <article key={r.id}>\n <span>{r.version}</span>\n {r.tags.map((t) => <Badge key={t} variant=\"secondary\">{t}</Badge>)}\n <p>{r.summary}</p>\n </article>\n ))}\n </section>\n))}\n{pageCount > 1 ? <Pagination>{/* previous / page indicator / next */}</Pagination> : null}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/docs-changelog-category-filter-page.tsx`. Built on the `badge` and `pagination` shadcn primitives.\n\n## Checks\n\n- Hidden date groups (zero releases after filtering) do not render their date header either — no empty section chrome.\n- Only one category chip shows the active (`default` variant) state at a time.\n- Pagination is entirely absent, not just visually inert, when `pageCount <= 1`.\n- Previous/Next controls stay in the same layout position whether enabled or `aria-disabled`.\n\n## Anti-patterns\n\n- **Empty date headers**: rendering a date section with no cards underneath it after a category filter removes all of that date's releases.\n- **Category chips as a single-select dropdown**: collapsing the chip row into a `Select`, losing the at-a-glance multi-option scan a changelog reader relies on.\n- **Pagination appended instead of replacing**: a \"load more\" that keeps appending pages to a single infinite list instead of this recipe's page-replaces-page contract (either is valid UX, but they are different recipes — do not mix load-more state into a page-number pager).\n- **Filter and pagination state drifting apart**: not resetting to page 1 when the category changes, leaving the reader on a page number that may not exist for the new filtered set.\n\n## Agent notes\n\n- prompt_phrases: \"카테고리 필터 chip과 페이지네이션이 있는 changelog 페이지를 만들어줘\", \"release note를 날짜별로 묶고 카테고리로 필터링할 수 있는 체인지로그를 만들어줘\"\n- fallbacks: if no `tags` exist for a release, omit the tag badge row rather than rendering an empty badge group.\n- related: implements the existing `docs-changelog-page` term and reuses `release-note-card`'s card anatomy directly — no new term needed for either."},{"id":"docs-code-block-with-tabs-and-copy","name":"Docs Code Block With Language Tabs And Copy","pattern_group":"docs","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/docs-code-block.tsx","component_refs":["button"],"term_refs":["copy-button"],"source_refs":["nextra-docs-theme"],"last_verified":"2026-07-12","source_path":"recipes/docs/docs-code-block-with-tabs-and-copy.md","body":"## Intent\n\nDocumentation readers often need the same instruction in more than one equivalent form (npm/yarn/pnpm, or curl/node/python) and want to copy it without hand-selecting text. Tabs switch between equivalent code variants in place, and a fixed copy button turns the block into a one-click action instead of a select-and-copy chore.\n\n## Anatomy\n\n- Tab header: one tab per language/tool variant, switching the code body below without changing surrounding prose.\n- Copy button: pinned to the top-right corner of the code body, above the code, not inline with it.\n- Code body: monospace text with local horizontal scroll — never widens the parent column.\n- Copy confirmation: the button's icon/state changes briefly after a successful copy, then reverts.\n\n## States\n\n- Default: first variant tab active, copy button in its resting icon state.\n- Copied: button shows a confirmation icon for a short duration, then returns to the copy icon.\n- Long line: code scrolls horizontally within the block; the block's own width still respects the parent column.\n- Single variant: tab header still renders (with one tab) rather than collapsing to a bare code block, so a second variant can be added later without restructuring.\n\n## Variants\n\n- Package manager tabs (npm/yarn/pnpm/bun) for install commands.\n- Language tabs (curl/node/python/etc.) for API usage samples.\n- Optional line-highlight/diff styling layered on top of the same tab+copy shell (not implemented in the base `code_asset` — a token-safe extension point, not a new recipe).\n\n## Code\n\n```tsx\nfunction DocsCodeBlock({ variants }: DocsCodeBlockProps) {\n const [copied, setCopied] = useState(false)\n async function handleCopy(code: string) {\n await navigator.clipboard.writeText(code)\n setCopied(true)\n window.setTimeout(() => setCopied(false), 1500)\n }\n return (\n <Tabs defaultValue={variants[0]?.id}>\n <TabsList variant=\"line\">\n {variants.map((v) => <TabsTrigger key={v.id} value={v.id}>{v.label}</TabsTrigger>)}\n </TabsList>\n {variants.map((v) => (\n <TabsContent key={v.id} value={v.id} className=\"relative\">\n <Button aria-label=\"Copy code\" className=\"absolute right-2 top-2\" size=\"icon\" variant=\"ghost\" onClick={() => handleCopy(v.code)}>\n {copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />}\n </Button>\n <pre className=\"overflow-x-auto rounded-md bg-foreground p-4 pr-10 text-background\"><code className=\"text-xs\">{v.code}</code></pre>\n </TabsContent>\n ))}\n </Tabs>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/docs-code-block.tsx`. Built on the `tabs` and `button` shadcn primitives (`examples/ui-vocabulary-site/src/components/ui/tabs.tsx`, `.../ui/button.tsx`).\n\n## Checks\n\n- Copy button sits above the code text (`pr-10` reserves space) so it never overlaps a long line's visible characters.\n- The code block's `overflow-x-auto` is scoped to the block itself, not the page.\n- Copy confirmation state reverts automatically; it does not require a second click to reset.\n- Tab switching swaps only the code body, not the surrounding heading/prose.\n\n## Anti-patterns\n\n- **Copy button inline with code**: placing the button inside the text flow instead of pinned to a corner breaks selection and reading of the code itself.\n- **No confirmation after copy**: a copy button that gives no visible feedback leaves the reader unsure whether the click worked.\n- **Single global scrollbar**: letting a long code line scroll the whole page horizontally instead of scoping `overflow-x-auto` to the `<pre>` block.\n- **Tabs without equivalent content**: using tabs to switch between genuinely different (non-equivalent) instructions, which belongs in separate sections, not a tab set implying interchangeability.\n\n## Agent notes\n\n- prompt_phrases: \"npm yarn pnpm 탭과 복사 버튼이 있는 문서 코드 블록을 만들어줘\", \"여러 언어 탭 전환과 복사 버튼이 있는 코드 스니펫을 문서 페이지에 넣어줘\"\n- fallbacks: if clipboard write fails (unsupported browser/context), keep the button visible but silently no-op rather than throwing; do not block rendering the code block.\n- related: pairs with `copy-button` (the underlying action) and can sit inside `api-reference-three-column-layout`'s right column when the reference needs one code sample per language."},{"id":"actionable-toast","name":"Actionable Toast","pattern_group":"feedback","kind":"component","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards","commerce","internal-tools"],"tokens_used":["color.semantic.surface.overlay","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/actionable-toast.tsx","component_refs":["button"],"term_refs":["toast","toast-stack","undo-toast","alert"],"source_refs":[],"last_verified":"2026-07-10","source_path":"recipes/feedback/actionable-toast.md","body":"## Intent\n\nAn actionable toast confirms a recent low-risk operation and may offer one short recovery action such as Undo or Retry. It does not request a decision, carry long instructions, or hide a persistent error that belongs next to the affected content.\n\n## Anatomy\n\n- Concise outcome title using the user's action language.\n- Optional one-line detail that identifies the affected object.\n- At most one action, plus a dismiss control when the toast does not expire quickly.\n- Live-region announcement that mirrors the visible outcome without stealing focus.\n\n## States\n\n- Success confirmation with automatic dismissal.\n- Failure with Retry when the operation is safe to repeat.\n- Undo window that remains long enough to perceive and activate.\n- Stacked notifications with a bounded visible count.\n- Dismissed state that clears timers and removes the live announcement.\n\n## Variants\n\n- Passive confirmation with no action.\n- Undo toast for reversible deletion, archive, or move.\n- Retry toast for transient network failure.\n- Persistent alert instead of toast when the state remains relevant on the page.\n\n## Code\n\n```tsx\nexport function ActionableToast({ toast, onDismiss, onUndo }: ActionableToastProps) {\n useEffect(() => {\n const timer = window.setTimeout(onDismiss, toast.duration)\n return () => window.clearTimeout(timer)\n }, [onDismiss, toast.duration])\n\n return (\n <div\n className=\"flex w-[min(23rem,calc(100vw-2rem))] items-start gap-3 rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg\"\n role=\"status\"\n aria-live=\"polite\"\n >\n <div className=\"min-w-0 flex-1\">\n <p className=\"text-sm font-medium\">{toast.title}</p>\n {toast.body ? <p className=\"mt-1 text-sm text-muted-foreground\">{toast.body}</p> : null}\n </div>\n {toast.undoable ? (\n <Button className=\"shrink-0\" size=\"sm\" type=\"button\" variant=\"link\" onClick={onUndo}>\n Undo\n </Button>\n ) : null}\n <button aria-label=\"Dismiss notification\" className=\"shrink-0 text-muted-foreground transition hover:text-foreground\" type=\"button\" onClick={onDismiss}>\n <XIcon aria-hidden=\"true\" className=\"size-4\" />\n </button>\n </div>\n )\n}\n```\n\n## Checks\n\n- Message states what happened and names the affected object when ambiguity is possible.\n- `role=status` or an equivalent polite live region announces the result without moving focus.\n- Undo/Retry is the only action; consequential choices use an inline surface or dialog.\n- Timer cleanup prevents dismissed toasts from reappearing or updating unmounted state.\n- A stack limits visible notifications and preserves newest/oldest ordering deliberately.\n\n## Anti-patterns\n\n- **Toast as error storage**: a blocking or persistent failure disappears before the user can act.\n- **Action pile**: multiple buttons turn a transient notification into a hidden dialog.\n- **Color-only outcome**: success and failure differ only by green or red.\n- **Focus theft**: showing the toast moves keyboard focus away from the task.\n\n## Agent notes\n\n- prompt_phrases: \"polite actionable toast with one undo action and timer cleanup\", \"observable save result without focus theft\"\n- fallbacks: use an inline alert when the message remains relevant to a form, section, or unresolved error.\n- canonical guidance: `docs/design-system/principles.md` principles 5 and 7."},{"id":"recoverable-empty-state","name":"Recoverable Empty State","pattern_group":"feedback","kind":"block","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards","commerce","internal-tools"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.scale.lg","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/recoverable-empty-state.tsx","component_refs":["button"],"term_refs":["empty-state","empty-search-result","empty-filter-state","error-state"],"source_refs":[],"last_verified":"2026-07-10","source_path":"recipes/feedback/recoverable-empty-state.md","body":"## Intent\n\nA recoverable empty state explains why expected content is absent and gives the user the most direct next action. It distinguishes first-use emptiness, no search results, filtered-out content, permission limits, and load failures instead of showing one generic illustration for every cause.\n\n## Anatomy\n\n- Cause-specific title that describes the current state.\n- One or two sentences explaining what changed or what will appear here.\n- Primary recovery action such as Create, Clear filters, Retry, or Request access.\n- Optional secondary guidance only when it helps the user choose a different path.\n\n## States\n\n- First use: no records exist yet; creation is primary.\n- Search empty: query is visible and editing/clearing it is primary.\n- Filter empty: active constraints are visible and reset is primary.\n- Error empty: failure is named and Retry/manual fallback is available.\n- Permission empty: access boundary and request/return path are explicit.\n\n## Variants\n\n- Inline table/list empty row for a scoped result set.\n- Full-panel first-use state for a new workspace.\n- Search recovery with related suggestions.\n- Offline or failure state with retry and cached-content fallback.\n\n## Code\n\n```tsx\nconst COPY: Record<EmptyStateKind, { title: (query?: string) => string; body: string; action: string }> = {\n firstUse: { title: () => \"No projects yet\", body: \"Create the first project for this workspace.\", action: \"Create project\" },\n search: { title: (query) => `No results for \"${query}\"`, body: \"Try a broader term or clear the query.\", action: \"Clear search\" },\n filtered: { title: () => \"No matching records\", body: \"The current filters exclude every record.\", action: \"Clear filters\" },\n error: { title: () => \"Could not load records\", body: \"Your changes are safe. Try loading this view again.\", action: \"Retry\" },\n}\n\nexport function RecoverableEmptyState({ kind, query, onRecover }: RecoverableEmptyStateProps) {\n const copy = COPY[kind]\n\n return (\n <section className=\"grid min-h-64 place-items-center rounded-lg border bg-muted/30 p-8 text-center\">\n <div className=\"max-w-md\">\n <InboxIcon aria-hidden=\"true\" className=\"mx-auto size-10 text-muted-foreground\" />\n <h2 className=\"mt-4 text-lg font-medium text-foreground\">{copy.title(query)}</h2>\n <p className=\"mt-2 text-sm leading-6 text-muted-foreground\">{copy.body}</p>\n <Button className=\"mt-6\" type=\"button\" onClick={onRecover}>\n {copy.action}\n </Button>\n </div>\n </section>\n )\n}\n```\n\n## Checks\n\n- The empty-state kind matches the actual cause; valid zero data is not mislabeled as an error.\n- Search/filter states repeat the active query or constraint and make reset observable.\n- Recovery action is real, enabled, and returns to the same task context.\n- Illustration remains subordinate to the title and action and has no redundant accessible label.\n- Retry failure remains visible with an alternate path rather than looping silently.\n\n## Anti-patterns\n\n- **One generic empty illustration**: first use, no results, permissions, and errors receive identical copy.\n- **Dead-end apology**: the message explains absence but offers no recovery action.\n- **Create action for filter emptiness**: users add duplicate data instead of clearing a constraint.\n- **Decorative takeover**: a large illustration pushes the cause and action below the viewport.\n\n## Agent notes\n\n- prompt_phrases: \"cause-specific empty state with a working recovery action\", \"filtered results empty state that exposes and clears constraints\"\n- fallbacks: use a compact inline row when only a table subsection is empty.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 4, and 5."},{"id":"advanced-filter-builder-condition-groups","name":"Advanced Filter Builder with Condition Groups","pattern_group":"forms","kind":"form-pattern","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/advanced-filter-builder.tsx","component_refs":["button"],"term_refs":["advanced-filter-builder","select","text-field"],"source_refs":["appmaster-io-blog"],"last_verified":"2026-07-12","source_path":"recipes/forms/advanced-filter-builder-condition-groups.md","body":"## Intent\n\nAn advanced filter builder with condition groups implements the `advanced-filter-builder` term's field-operator-value contract at composition scale: instead of one flat list of conditions, conditions nest inside groups, and groups combine with a top-level AND/OR join. This is the structure Retool-style internal admin tools repeat when a single filter row can't express \"status is active AND (region is US OR region is EU)\".\n\n## Anatomy\n\n- Field select: which column/attribute the condition targets.\n- Operator select: equals, contains, greater than, etc. — the available set follows the selected field's type.\n- Value input: adapts to the field's type (text, number, date) rather than always being a plain text box.\n- Condition join: AND/OR selector between conditions inside the same group.\n- Group join: a separate AND/OR selector between groups, visually distinguished from the in-group join.\n- Add condition / add group: explicit actions to grow the structure.\n- Remove condition: per-row action to shrink a group.\n- Save as view: optional action connecting the built filter to `saved-view-tabs`.\n\n## States\n\n- Single group, single condition: the simplest case — no join selectors are shown yet.\n- Multiple conditions, one group: an in-group AND/OR selector appears between conditions.\n- Multiple groups: a group-level AND/OR selector appears between groups, styled distinctly from the in-group join so the two nesting levels stay visually separable.\n- Field changed mid-condition: the operator and value input reset to match the new field's type rather than keeping a stale operator/value combination.\n- Empty condition: a newly added condition with no field chosen yet still renders a full field/operator/value row, prompting completion rather than hiding controls.\n\n## Variants\n\n- Inline builder embedded directly above a data table (default, this recipe).\n- Panel/drawer variant when the condition list is long enough to compete with the data view — compose with `filter-panel` instead of inlining.\n- Read-only summary variant that renders the built expression as text/chips once complete, useful for confirming a saved view.\n\n## Code\n\n```tsx\nexport function AdvancedFilterBuilder({ fields, groups, groupJoin, onGroupJoinChange, onConditionChange, onGroupJoinToggle, onAddCondition, onRemoveCondition, onAddGroup, onSaveAsView }: AdvancedFilterBuilderProps) {\n const fieldType = (fieldValue: string) => fields.find((field) => field.value === fieldValue)?.type ?? \"text\"\n\n return (\n <div className=\"flex flex-col gap-3 rounded-md border bg-card p-4 text-card-foreground\">\n {groups.map((group, groupIndex) => (\n <div key={group.id} className=\"flex flex-col gap-2\">\n {groupIndex > 0 ? (\n <Select value={groupJoin} onValueChange={(value) => onGroupJoinChange(value as \"and\" | \"or\")}>\n <SelectTrigger size=\"sm\" aria-label=\"Join groups with\"><SelectValue /></SelectTrigger>\n <SelectContent><SelectItem value=\"and\">AND</SelectItem><SelectItem value=\"or\">OR</SelectItem></SelectContent>\n </Select>\n ) : null}\n <div className=\"flex flex-col gap-2 rounded-md border border-dashed p-3\">\n {group.conditions.map((condition, conditionIndex) => (\n <div key={condition.id} className=\"flex flex-wrap items-center gap-2\">\n {/* field select, operator select, type-aware value input, remove action */}\n </div>\n ))}\n <Button size=\"sm\" variant=\"ghost\" onClick={() => onAddCondition(group.id)}>+ Add condition</Button>\n </div>\n </div>\n ))}\n <Button size=\"sm\" variant=\"outline\" onClick={onAddGroup}>+ Add group</Button>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/advanced-filter-builder.tsx`. Built on the existing `select`/`text-field` input primitives, not a bespoke query-string editor.\n\n## Checks\n\n- Changing a condition's field resets its operator/value to a valid combination for the new field's type rather than leaving a stale mismatch.\n- The in-group join selector and the between-group join selector are visually distinguishable from each other.\n- Adding a group always starts with at least one empty condition row, never an empty group with no conditions.\n- Removing the last condition in a group removes the group itself rather than leaving an empty shell.\n- Value input type (text/number/date) matches the selected field's declared type.\n\n## Anti-patterns\n\n- **Exposed query string**: rendering the built filter as raw SQL/query-language text for the user to hand-edit defeats the purpose of a structured builder — keep the structure the primary editing surface.\n- **Flat conditions with no grouping**: when the underlying data model supports nested AND/OR, forcing everything into one flat list silently drops expressiveness the user needs.\n- **Operator list ignoring field type**: showing \"greater than\" for a text field or \"contains\" for a date field confuses users and produces invalid queries.\n- **Untouched empty groups**: allowing a save action while a group has zero conditions produces a filter that silently matches everything or nothing depending on the join semantics.\n\n## Agent notes\n\n- prompt_phrases: \"필드, 연산자, 값을 조합하고 AND/OR로 그룹까지 묶을 수 있는 advanced filter builder를 만들어줘\", \"조건을 여러 그룹으로 나누고 그룹끼리도 AND/OR로 연결되는 필터 빌더를 넣어줘\"\n- fallbacks: if nested groups are out of scope, a single flat AND-only condition list is an acceptable reduced variant — but do not silently drop the OR case; state the limitation to the user instead.\n- component composition: reuses `select`/`text-field` input primitives directly — assemble from those rather than building custom dropdown/input controls for this recipe."},{"id":"button","name":"Button","pattern_group":"forms","kind":"component","status":"draft","surface_refs":["websites","saas-dashboards","components-primitives"],"tokens_used":["color.component.button.bg","color.component.button.text","color.semantic.action.primary","color.semantic.border.default","dimension.radius.sm","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/ui/button.tsx","component_refs":[],"term_refs":["button","primary-button","secondary-button"],"source_refs":[],"last_verified":"2026-07-07","source_path":"recipes/forms/button.md","body":"## Intent\n\nA button lets a user trigger a single, immediate action (submit, save, confirm, cancel). It communicates the relative importance of an action through fill and contrast rather than size — primary actions get a solid fill, secondary actions get an outline.\n\n## Anatomy\n\n- Container: fixed height, horizontal padding, rounded corners.\n- Label: short verb-first text (e.g. \"Save\", \"Cancel\").\n- Optional leading/trailing icon.\n- Optional loading indicator replacing or alongside the label.\n\n## States\n\n- Default\n- Hover (opacity shift on primary, subtle bg shift on secondary/ghost/outline)\n- Focus-visible (ring)\n- Disabled (reduced opacity, pointer-events off)\n- Active/pressed\n\n## Variants\n\n- `default` (primary): solid fill using `color.component.button.bg`, text using `color.component.button.text`.\n- `secondary` / `outline`: transparent background, 1px border using `color.semantic.border.default`, text follows surface foreground.\n- `destructive`: reserved for irreversible actions — do not substitute for primary emphasis.\n- `ghost` / `link`: lowest emphasis, no container fill or border.\n\nSizes follow a fixed scale (`sm`, `default`, `lg`, icon-only variants) — do not introduce ad hoc heights per screen.\n\n## Code\n\n```tsx\nconst buttonVariants = cva(\n \"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n {\n variants: {\n variant: {\n default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n destructive:\n \"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20\",\n outline:\n \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground\",\n secondary:\n \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n ghost:\n \"hover:bg-accent hover:text-accent-foreground\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n size: {\n default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n sm: \"h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5\",\n lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n icon: \"size-9\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n)\n```\n\nComponent-level spec (Askewly Design landing usage, see `DESIGN.md` §7 Components > Button): height 40, padding-x 24, radius `sm`; primary is a solid `color.component.button.bg` fill with `color.component.button.text` label, hover reduces opacity to 0.9; secondary is transparent with a 1px `color.semantic.border.default` border.\n\n## Checks\n\n- Text has sufficient contrast against the fill (component tier resolves through `action.primary` → `text.on-accent`, both already contrast-checked at the semantic tier).\n- Focus-visible ring is present and not suppressed.\n- Disabled state is visually distinct and non-interactive (`pointer-events-none`, reduced opacity).\n- Only one primary (solid) button visible per immediate decision context.\n- Hit target meets minimum touch size on `mobile-apps`/touch surfaces even when using compact sizes.\n- Label leads with an actionable verb ({verb}+{noun}, common actions like Save/Cancel exempt) and predicts the outcome; no filler verbs (View/Go/Read) or articles (taste ledger T-17, Polaris diff adopted).\n\n## Anti-patterns\n\n- **Oversized rounded pill buttons.** `DESIGN.md` fixes radius at `sm` (4px); full-pill radii read as generic template output, not as this system's shape language.\n- **Heavy drop shadows on buttons.** Elevation here comes from borders and surface tint, not shadow — a shadowed button breaks the flat, bordered aesthetic (`DESIGN.md` §6, §8).\n- **Every button styled as primary.** Solid-fill overuse erases hierarchy; reserve the primary fill for the one dominant action and use secondary/outline/ghost for everything else.\n- **One-note gradient fills on the button background.** Gradients are called out directly as a page-level anti-pattern and apply equally at component scale.\n\n## Agent notes\n\n- `prompt_phrases`: \"저장 버튼을 오른쪽 아래에 넣어줘\", \"가장 중요한 행동은 primary button으로 강조해줘\", \"취소는 secondary button으로 넣어줘\".\n- Fallback when no variant is specified: use `default` (primary) for the single most important action on the screen, `outline`/`secondary` for everything else, and reserve `destructive` strictly for irreversible actions.\n- When asked for a \"big rounded CTA button,\" push back toward the fixed radius/height spec above rather than inventing a new shape."},{"id":"data-import-wizard-validation-preview-steps","name":"Data Import Wizard Validation Preview Steps","pattern_group":"forms","kind":"form-pattern","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.action.destructive","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/data-import-wizard-validation-preview-steps.tsx","component_refs":["button"],"term_refs":["data-import-wizard-validation-preview","wizard"],"source_refs":["retool-csv-import-flow-blog"],"last_verified":"2026-07-12","source_path":"recipes/forms/data-import-wizard-validation-preview-steps.md","body":"## Intent\n\nA data import wizard validation preview steps recipe implements the `data-import-wizard-validation-preview` term's upload-to-summary contract as five discrete steps: upload, map, validate, preview, confirm. Each step is its own screen rather than a single scrolling form, per-row validation errors surface inline in the preview table instead of only as an aggregate count, and a failing row can be fixed or explicitly skipped without aborting the whole import. This is the structure Retool/Linear/Stripe-style admin CSV import flows repeat.\n\n## Anatomy\n\n- Step indicator: upload / map / validate / preview / confirm, with the current step visually distinct from completed and upcoming ones.\n- Column mapping table: source column paired with target field, with type coercion implied by the target field's type.\n- Row-level validation: each preview row shows its own error inline, with an explicit fix-or-skip choice rather than a single blocking error banner.\n- Preview table: shows the first N rows post-mapping, labeled with target schema column names, not the raw source column names.\n- Summary screen: imported / skipped / failed counts once the import completes.\n\n## States\n\n- Upload: no mapping or preview yet, only the file input and step indicator.\n- Map: column mapping table is editable; no row-level errors are shown yet.\n- Validate/Preview: preview rows render with per-row error state where applicable; error count is visible as a scannable badge.\n- Row error: shows an inline message plus both \"fix\" and \"skip row\" actions — never only an abort-the-whole-import path.\n- Confirm: final summary counts render after the import completes; the step indicator marks all prior steps done.\n\n## Variants\n\n- Full five-step wizard (default, this recipe) for first-time or infrequent bulk imports.\n- Collapsed three-step variant (upload, validate+preview combined, confirm) when column mapping is fixed/non-configurable for a given import type.\n- Re-run variant that pre-fills mapping from the previous successful import, skipping straight to validate.\n\n## Code\n\n```tsx\nexport function DataImportWizardValidationPreviewSteps({ step, mappings, previewRows, summary, onFixRow, onSkipRow, onContinue }: DataImportWizardStepsProps) {\n const errorCount = previewRows.filter((row) => row.error).length\n return (\n <section className=\"flex flex-col gap-3 rounded-md border bg-card p-4 text-card-foreground\">\n {/* step indicator, column mapping table (map step), preview table with per-row fix/skip (validate/preview step), summary counts (confirm step) */}\n </section>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/data-import-wizard-validation-preview-steps.tsx`.\n\n## Checks\n\n- Every preview row with an error shows both a fix action and a skip-row action, not only an abort option.\n- The preview table's column labels match the target schema, not the raw uploaded column names.\n- The step indicator always reflects the actual current step — a user cannot land on \"preview\" while still seeing \"map\" highlighted.\n- The confirm summary's three counts (imported/skipped/failed) always sum to the total row count submitted.\n- Changing a column mapping after validation invalidates the stale preview rather than silently keeping outdated validation results.\n\n## Anti-patterns\n\n- **Single scrolling form for all steps**: collapsing upload/map/validate/preview/confirm into one long form loses the step indicator's orientation value and makes partial progress unclear.\n- **Aggregate-only error count**: showing \"12 rows failed\" with no per-row detail forces the user to guess which records need fixing.\n- **Abort-only on row error**: forcing the user to cancel the entire import because of one bad row wastes the successfully mapped/validated rows.\n- **Preview table using source column names**: showing raw uploaded headers instead of target field labels makes it hard to confirm the mapping actually did what was intended.\n\n## Agent notes\n\n- prompt_phrases: \"업로드, 매핑, 검증, 미리보기, 확인 단계로 나뉜 data import wizard를 만들어줘\", \"행 단위 오류에 고치기와 건너뛰기 선택지가 있는 CSV 임포트 미리보기를 넣어줘\"\n- fallbacks: if per-row fix-in-place isn't feasible, skip-row plus a downloadable error report is an acceptable reduced variant — do not silently drop failed rows from the summary count.\n- component composition: implements the same term (`data-import-wizard-validation-preview`) already in the dictionary; this recipe is the step-by-step structural realization, reusing the generic `wizard` step-indicator pattern rather than inventing a new one."},{"id":"mobile-signup-field-stack","name":"Mobile Signup Field Stack","pattern_group":"forms","kind":"form-pattern","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.border.focus","color.semantic.action.destructive"],"code_asset":"examples/ui-vocabulary-site/src/components/mobile-signup-field-stack.tsx","component_refs":[],"term_refs":["mobile-form-field-stack"],"source_refs":["apple-hig-components"],"last_verified":"2026-07-12","source_path":"recipes/forms/mobile-signup-field-stack.md","body":"## Intent\n\n`mobile-form-field-stack` names the structural constraint (48×48 touch targets, next-field keyboard action) but not the field-level implementation. This recipe fixes the per-field keyboard type dispatch (email/numeric/secure), the logical tab order driven by the keyboard's own return key, and blur-time (not keystroke-time) validation for a stacked signup/login form.\n\n## Anatomy\n\n- Stacked label-input-helper units: one label, one input, one helper/error line per field, stacked vertically.\n- Secure text field for password: masked input with `autoComplete=\"new-password\"`, distinct from plain text fields.\n- Per-field keyboard type: `inputMode`/`type` set per field purpose (email keyboard for email, default for name, masked for password).\n- Next-field return key action: `enterKeyHint=\"next\"` on every field except the last, `\"done\"` on the last.\n- Submit CTA: pinned at the end of the stack, a full-width button separate from the last field's return key action.\n\n## States\n\n- **Untouched**: no helper/error shown beyond the field's static hint (e.g. password's minimum-length note).\n- **Focused**: visible focus ring (`focus:ring-2`), current field highlighted so the active position in the stack is unambiguous.\n- **Touched + invalid**: error replaces the helper text only after the field has been blurred at least once — never on every keystroke.\n- **Touched + valid**: helper text (if any) reappears, no error state.\n\n## Variants\n\n- Field set is illustrative (name/email/password) — swap in the actual fields needed, keeping one keyboard-type decision and one validation-timing decision per field.\n- Submit CTA can be `pinned` (fixed above the keyboard) or `inline` (part of the scrolling stack, as implemented) depending on form length; both are valid per the parent term.\n\n## Code\n\n```tsx\n<input\n id={field.id}\n type={field.type}\n inputMode={field.inputMode}\n enterKeyHint={field.enterKeyHint}\n autoComplete={field.type === \"password\" ? \"new-password\" : field.id}\n onBlur={() => setTouched((current) => ({ ...current, [field.id]: true }))}\n onKeyDown={(event) => {\n if (event.key === \"Enter\" && field.enterKeyHint === \"next\") {\n event.preventDefault()\n focusNext(index)\n }\n }}\n className=\"h-11 min-h-11 rounded-md border px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring\"\n/>\n```\n\nFull field definitions, focus-ref wiring, and the colocated `MobileSignupFieldStackDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- Every field is at least 44×44 (`h-11 min-h-11`, ~44px) per the parent term's touch-target floor.\n- Password field uses a masked `type=\"password\"` input with `autoComplete=\"new-password\"`, not a plain text field.\n- Pressing the keyboard's return/next action on a non-final field moves focus to the next field without requiring a tap.\n- Validation errors only appear after a field has been blurred at least once (`touched`), never while the user is still actively typing into it.\n\n## Anti-patterns\n\n- **Wrong keyboard type per field**: using the default text keyboard for an email field (instead of `inputMode=\"email\"`) forces users to manually switch keyboards to type `@`.\n- **Keystroke-time validation**: showing an error on every keystroke (e.g. \"too short\" while the user is still typing the password) is noisy and reads as broken rather than helpful — validate on blur.\n- **Swipe-only field navigation**: relying on the user to manually tap each next field, or requiring a swipe gesture to move between fields, ignores the keyboard's own next/return action that most users already expect.\n- **Sub-44px touch targets**: shrinking field height below the 44×44 floor to fit more fields on screen trades density for tap accuracy, contradicting the parent term's core constraint.\n\n## Agent notes\n\n- `prompt_phrases`: \"모바일 가입 폼에 이름/이메일/비밀번호를 세로로 쌓아줘\", \"이메일 필드는 이메일 키보드가 뜨게 해줘\", \"다음 필드로 넘어가는 키보드 액션을 연결해줘\", \"비밀번호는 8자 이상 검증을 blur 시점에만 보여줘\"\n- If the form only has a single field, use a plain `text-field` instead of this stack — the term's own `anti_use` already says one field doesn't need the stack contract.\n- Fallback: if a UI kit doesn't expose `enterKeyHint`, at minimum wire the `Enter`/`Return` keydown to advance focus (as implemented) so keyboard-driven progression still works."},{"id":"responsive-content-grid","name":"Responsive Content Grid","pattern_group":"layout","kind":"block","status":"draft","surface_refs":["websites","saas-dashboards","commerce","documentation"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.4","dimension.space.8","dimension.space.12","dimension.radius.md","typography.scale.sm","typography.scale.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/responsive-content-grid.tsx","component_refs":[],"term_refs":["grid","responsive-stack","responsive-layout","container"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-10","source_path":"recipes/layout/responsive-content-grid.md","body":"## Intent\n\nA responsive content grid arranges peer items into as many columns as the content can support, then collapses without changing their reading order. It is a layout contract for cards, examples, products, or records, not a decorative checkerboard.\n\n## Anatomy\n\n- Outer container: a content-driven maximum width and page padding.\n- Grid: one source order, explicit gap, and breakpoint-based column count.\n- Item boundary: `min-w-0` plus a consistent internal spacing contract.\n- Content priority: heading and primary information remain visible before optional media or metadata.\n\n## States\n\n- Mobile: one column with no clipped actions or horizontal scroll.\n- Intermediate: two columns after each item can preserve its minimum useful width.\n- Wide: three or four columns only when content remains readable.\n- Sparse data: the final row stays left aligned; empty slots are not rendered as placeholders.\n\n## Variants\n\n- Equal cards for peer choices.\n- Dense records with a smaller gap and stronger row scanning.\n- Featured item spanning columns when its information priority is genuinely higher.\n- Media grid with a fixed aspect ratio and text below.\n\n## Code\n\n```tsx\nexport function ResponsiveContentGrid({ items }: ResponsiveContentGridProps) {\n return (\n <section className=\"mx-auto w-full max-w-5xl\">\n <div className=\"grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3\">\n {items.map((item) => (\n <article key={item.id} className=\"min-w-0 rounded-lg border bg-card p-6 text-card-foreground\">\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"min-w-0\">\n <h3 className=\"truncate text-base font-medium\">{item.title}</h3>\n <p className=\"mt-2 text-sm leading-6 text-muted-foreground\">{item.description}</p>\n </div>\n {item.status ? <Badge variant=\"secondary\">{item.status}</Badge> : null}\n </div>\n {item.actions?.length ? (\n <div className=\"mt-6 flex flex-wrap gap-2\">\n {item.actions.map((action) => (\n <Button key={action.label} size=\"sm\" type=\"button\" variant=\"outline\" onClick={action.run}>\n {action.label}\n </Button>\n ))}\n </div>\n ) : null}\n </article>\n ))}\n </div>\n </section>\n )\n}\n```\n\n## Checks\n\n- Breakpoints follow the point where content no longer fits, not device names alone.\n- DOM order remains the reading and keyboard order at every width.\n- Each item and text wrapper has `min-w-0` where truncation or overflow is possible.\n- At mobile width the page has zero horizontal overflow and primary actions remain visible.\n- Empty results use an explicit empty state instead of an empty grid frame.\n\n## Anti-patterns\n\n- **Desktop grid squeezed smaller**: three columns persist until text and actions clip.\n- **Visual reordering**: CSS order creates a different keyboard and screen-reader sequence.\n- **Card mosaic without information priority**: random spans make peer items appear unequal.\n- **Nested frame repetition**: every item contains several more bordered cards instead of a clear internal hierarchy.\n\n## Agent notes\n\n- prompt_phrases: \"content-driven responsive grid with stable source order\", \"one-to-three column product grid with mobile overflow check\"\n- fallbacks: use a vertical list when comparison depends on aligned columns or rows.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 2, and 5."},{"id":"sidebar-application-shell","name":"Sidebar Application Shell","pattern_group":"layout","kind":"block","status":"draft","surface_refs":["saas-dashboards","internal-tools","documentation"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/sidebar-application-shell.tsx","component_refs":[],"term_refs":["app-shell","sidebar-nav","collapsible-sidebar","sidebar-dashboard-layout"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-10","source_path":"recipes/layout/sidebar-application-shell.md","body":"## Intent\n\nA sidebar application shell gives repeated product navigation a stable rail while keeping the task surface flexible. It is appropriate for dashboards, internal tools, and documentation products where users move among peer work areas many times per session.\n\n## Anatomy\n\n- Sidebar rail: brand or workspace identity, primary navigation, and optional account controls.\n- Active item: one unmistakable selected state, not a second primary CTA.\n- Main surface: `minmax(0, 1fr)` content that owns its own max width and overflow.\n- Mobile trigger: opens the same navigation model as a temporary sheet or drawer.\n\n## States\n\n- Expanded: labels and icons are visible.\n- Collapsed: icons retain accessible names and active state.\n- Mobile closed/open: navigation leaves the content flow and restores focus on dismiss.\n- Long navigation: the rail scrolls independently while account actions remain reachable.\n\n## Variants\n\n- Light rail with bordered content.\n- Dark rail with semantic text contrast.\n- Compact icon rail for dense operator tools.\n- Sidebar with a top header when global actions must remain separate from section navigation.\n\n## Code\n\n```tsx\nexport function SidebarApplicationShell({ items, activeId, onSelect, children }: SidebarApplicationShellProps) {\n const [expanded, setExpanded] = useState(true)\n\n return (\n <div className=\"grid min-h-[28rem] overflow-hidden rounded-lg border bg-background lg:grid-cols-[auto_minmax(0,1fr)]\">\n <aside className={cn(\"flex flex-col gap-1 border-b bg-card p-2 lg:border-b-0 lg:border-r\", expanded ? \"lg:w-56\" : \"lg:w-16\")}>\n <button\n aria-expanded={expanded}\n aria-label={expanded ? \"Collapse sidebar\" : \"Expand sidebar\"}\n className=\"mb-1 inline-flex size-9 items-center justify-center self-start rounded-md text-muted-foreground transition hover:bg-accent hover:text-accent-foreground\"\n type=\"button\"\n onClick={() => setExpanded((value) => !value)}\n >\n <PanelLeftIcon aria-hidden=\"true\" className=\"size-4\" />\n </button>\n <nav aria-label=\"Workspace\" className=\"flex flex-col gap-1\">\n {items.map((item) => {\n const isActive = item.id === activeId\n const Icon = item.icon\n return (\n <button\n key={item.id}\n aria-current={isActive ? \"page\" : undefined}\n aria-label={expanded ? undefined : item.label}\n className={cn(\"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition\", isActive ? \"bg-muted text-foreground\" : \"text-muted-foreground hover:bg-accent/50 hover:text-foreground\")}\n type=\"button\"\n onClick={() => onSelect(item.id)}\n >\n <Icon aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n {expanded ? <span className=\"truncate\">{item.label}</span> : null}\n </button>\n )\n })}\n </nav>\n </aside>\n <main className=\"min-w-0 overflow-y-auto bg-background p-6\">{children}</main>\n </div>\n )\n}\n```\n\n## Checks\n\n- The rail represents repeated peer navigation, not one page's local filters.\n- Active state is exposed with `aria-current` and a visible semantic surface change.\n- Collapse controls preserve accessible names and do not strand keyboard focus.\n- Main content uses `min-w-0`; tables and code cannot force the rail off-screen.\n- Mobile behavior uses a dismissible overlay or sheet and restores focus to its trigger.\n\n## Anti-patterns\n\n- **Permanent desktop rail squeezed onto mobile**: labels and content become unusably narrow instead of switching to a temporary surface.\n- **Icon-only mystery navigation**: collapsed items lose accessible labels and tooltips.\n- **Two independent scroll pages**: body and sidebar scroll unpredictably without a deliberate sticky/overflow model.\n- **Every item looks active**: filled rows erase the current-location signal.\n\n## Agent notes\n\n- prompt_phrases: \"responsive application shell with collapsible sidebar and stable content column\", \"operator dashboard rail with explicit active state\"\n- fallbacks: use a topbar when there are fewer than four peer destinations or navigation is infrequent.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 2, and 5."},{"id":"grain-texture-overlay","name":"Grain Texture Overlay","pattern_group":"marketing","kind":"visual-effect","status":"draft","surface_refs":["websites","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/grain-texture-overlay.tsx","component_refs":["mesh-gradient-surface"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/marketing/grain-texture-overlay.md","body":"## Intent\n\nA grain texture overlay layers subtle procedural noise above a gradient or brand surface — it removes visible banding on smooth gradients and adds analog film texture. Tier ① (SVG): CSS `filter` alone cannot generate procedural noise, so an SVG `feTurbulence` tile embedded as a data URI does the work — still zero JS, zero requests, zero dependencies.\n\n## Anatomy\n\n- A noise tile: inline SVG data URI with `feTurbulence type=\"fractalNoise\"` + desaturating `feColorMatrix`, sized ~180px and tiled by `background-repeat`.\n- An absolutely-positioned overlay div above the surface, `mix-blend-soft-light`, opacity as the strength knob (keep ≤0.3).\n- `aria-hidden=\"true\"` and `pointer-events: none` — the layer is pure decoration and must never intercept interaction or reach assistive tech.\n- The host container clips the overlay (`overflow-hidden`).\n\n## States\n\n- Static only — grain that animates (\"TV static\") is a different, attention-grabbing effect and an experimental touch requiring manual opt-in (signature principle 5).\n- Dark mode: soft-light blending adapts to the underlying tokens; verify strength doesn't muddy dark surfaces (grain reads stronger on dark).\n\n## Variants\n\n- Banding fix over `mesh-gradient-surface` (the default pairing).\n- Brand texture over solid accent sections.\n- Photo treatment: consistent grain across an image set (aligns with the site's Image Treatment showcase).\n\n## Code\n\n```tsx\nconst GRAIN_SVG = encodeURIComponent(\n '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"180\" height=\"180\"><filter id=\"n\"><feTurbulence type=\"fractalNoise\" baseFrequency=\"0.9\" numOctaves=\"2\" stitchTiles=\"stitch\"/><feColorMatrix type=\"saturate\" values=\"0\"/></filter><rect width=\"100%\" height=\"100%\" filter=\"url(#n)\"/></svg>',\n)\n\n<div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-0 mix-blend-soft-light\"\n style={{ backgroundImage: `url(\"data:image/svg+xml,${GRAIN_SVG}\")`, opacity: strength }}\n/>\n```\n\n## Checks\n\n- Overlay is `aria-hidden` + `pointer-events: none` — no interaction path or screen-reader noise.\n- Strength stays subtle (≤0.3) and identical across surfaces in one product.\n- Noise tile uses `stitchTiles=\"stitch\"` so the repeat seam is invisible.\n- Text over the grained surface still passes contrast (grain lowers effective contrast slightly).\n\n## Anti-patterns\n\n- **Grain as loading noise**: animating the noise tile — becomes a distraction and a `prefers-reduced-motion` violation.\n- **Heavy grain**: opacity above ~0.4 reads as dirt, not texture, and visibly degrades text contrast.\n- **PNG noise asset**: shipping a raster noise image when the SVG data URI is smaller, resolution-independent, and request-free.\n- **Interactive layer on top**: placing the overlay above buttons without `pointer-events: none` silently kills clicks — the failure is invisible in a screenshot.\n\n## Agent notes\n\n- prompt_phrases: \"feTurbulence grain overlay as data URI with soft-light blending to fix gradient banding\", \"aria-hidden pointer-events-none film grain layer over a token-derived surface\"\n- fallbacks: if SVG filters are unavailable (vanishingly rare), the overlay simply doesn't render — the surface below remains fully functional.\n- canonical guidance: `knowledge/expressive-stack.md` tier ① decision row \"그레인/필름 노이즈 텍스처\"."},{"id":"landing-hero","name":"Landing Hero","pattern_group":"marketing","kind":"block","status":"draft","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.action.primary","color.semantic.text.on-accent","color.semantic.border.default","dimension.radius.xl","typography.font.sans","typography.scale.xl","typography.scale.2xl"],"code_asset":"examples/ui-vocabulary-site/src/components/landing-hero.tsx","component_refs":["topbar-command-search"],"term_refs":["command-palette","autocomplete"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-07","source_path":"recipes/marketing/landing-hero.md","body":"## Intent\n\nThe first-viewport block for a public product/design-system homepage: a centered product name or direct promise, one line of supporting copy naming the two things the product actually is, two calls to action, and a single restrained proof surface underneath (here, a live search field) instead of a wall of decorative cards. It exists to convert a first-time visitor in one scroll-length without resorting to a split hero (text left / mockup right) or a fake analytics dashboard — both of which `DESIGN.md` §8 explicitly bans for this product's landing page.\n\n## Anatomy\n\n- Section root: full-bleed `surface.base` background, generous top/bottom padding, `overflow-hidden` to host any ambient background field without clipping content oddly.\n- Optional ambient layer (`FloatingField` in the current asset): a pointer-reactive decorative field sitting behind content at a lower stacking order — never a blob/orb gradient (banned, §8).\n- `h1`: centered, large clamp-based type scale, single line of product name or promise. Uses `typography.scale.2xl` as the floor of its clamp range.\n- Subcopy `p`: centered, max-width constrained (`max-w-2xl`) so line length stays readable, one sentence naming both the public site and the agent-facing system.\n- CTA row: two buttons, `flex-col` on mobile → `flex-row` on `sm:`, gap between them.\n - Primary: solid `color.semantic.action.primary` fill, `color.semantic.text.on-accent` text, trailing icon.\n - Secondary: outline only, `color.semantic.border.default`, transparent fill.\n- Proof surface: exactly one below the CTA row — in the current asset this is a live search input with a suggestion popover (`component_refs: topbar-command-search`), not a decorative dashboard mockup.\n\n## States\n\n- **Default (Centered)**: title, subcopy, two CTAs, and the search proof surface stacked and centered — the shipped state.\n- **Search proof surface focused**: input expands/opens a suggestion popover beneath it (delegates to the `topbar-command-search`-family state machine: collapsed/open-empty/open-typed/keyboard nav). The hero block itself does not own this state, it only hosts the component.\n- **Reduced motion**: any ambient pointer-reactive field must respect `prefers-reduced-motion` and render static or fully disabled.\n- **Responsive reflow**: at narrow widths the CTA row stacks vertically and the `h1` clamp shrinks; copy must never truncate or get clipped by the section's `overflow-hidden`.\n\n## Variants\n\n- **Centered** (current, shipped): title → subcopy → CTA row → single proof surface, all centered on one column. Use when the product has one clear promise and one clear next action (search or explore).\n- **Proof**: swaps the plain search field for the \"Hero Proof Surface\" spec in `DESIGN.md` §7 — a compact design-system workspace preview (left nav / central preview / right inspector, labels like Docs, Patterns, Showcase, Resources, Pro) shown *below* the centered title instead of beside it. Still one column, still centered — never split left-text/right-mockup (banned).\n- **Compact**: same content order, reduced vertical padding and a smaller `h1` clamp ceiling, for use as a secondary/nested hero (e.g. inside a sub-page) where the full first-viewport treatment would be excessive.\n\n## Code\n\n```tsx\n<section className=\"relative isolate overflow-hidden bg-white px-4 pb-10 pt-14 md:px-8 md:pb-14 md:pt-20 lg:px-10\">\n <div className=\"pointer-events-none absolute inset-0 -z-10 bg-white\" />\n <FloatingField />\n <div className=\"relative z-30 mx-auto flex max-w-[1180px] min-w-0 flex-col items-center\">\n <h1 className=\"mt-4 text-center text-[clamp(3.5rem,16vw,9.5rem)] font-semibold leading-[0.9] tracking-normal text-slate-950\">\n Askewly Design\n </h1>\n <p className=\"mt-7 w-full max-w-2xl text-center text-base leading-7 text-slate-600 sm:text-lg sm:leading-8 md:text-xl\">\n A visual library and agent-ready system for designing better product interfaces.\n </p>\n\n <div className=\"mt-7 flex flex-col gap-3 sm:flex-row\">\n <Button\n className=\"h-11 rounded-full bg-askewly-violet px-6 text-white hover:bg-askewly-violet/90\"\n type=\"button\"\n onClick={() => onNavigate({ page: \"plus\", filter: \"nav:plus-application-ui\" })}\n >\n Get Started\n <ArrowRight aria-hidden=\"true\" className=\"size-4\" />\n </Button>\n <Button\n className=\"h-11 rounded-full border-slate-300 bg-white px-6 text-slate-950 hover:bg-slate-50\"\n variant=\"outline\"\n type=\"button\"\n onClick={() => onNavigate({ page: \"docs\", filter: \"nav:docs-getting-started-setup\" })}\n >\n Open Docs\n </Button>\n </div>\n\n <HeroSearch filter={filter} terms={terms} onNavigate={onNavigate} onSearch={onSearch} />\n </div>\n</section>\n```\n\nFull hero markup, `FloatingField`, and the `HeroSearch` proof-surface component live in `code_asset`; this excerpt is the structural contract (title → subcopy → CTA pair → single proof surface), not the full implementation.\n\n## Checks\n\n- Known deviation (2026-07-07, found by the recipe-consumption demo): the shipped hero code uses pill-radius CTAs, contradicting the `button` recipe spec (radius `sm`) and DESIGN.md §8. New implementations must follow the `button` recipe; the shipped hero is queued for reconciliation in the product-surface pass.\n- Exactly one `h1` in the hero, centered, no split-column layout with a mockup/card on the opposite side.\n- Exactly two CTAs, one primary (solid fill) and one secondary (outline) — not more, not a single lonely button.\n- Subcopy mentions both the public reference site and the agent-facing design system, not just one.\n- Below the CTAs there is exactly one proof surface (search field, or the Proof-variant workspace preview) — never multiple unrelated decorative cards.\n- Ambient background field (if present) has a `motion-reduce` fallback and does not read as a purple-dominated page (§8) on its own.\n- When the product has a real UI, the proof surface prefers an actual product screen (real data, real states) over illustration or abstract mockups — the UI proves itself (taste ledger T-9, Codex measured).\n- CTA buttons have visible hover/active/focus-visible states; focus ring must be keyboard-reachable.\n- At narrow viewports, title and subcopy reflow without clipping or horizontal scroll from the section's `overflow-hidden`.\n\n## Anti-patterns\n\n- **Split hero**: text on one side, product screenshot/card mockup on the other. `DESIGN.md` §8 bans this explicitly for this product — the hero must stay single-column and centered.\n- **Decorative blob/orb backgrounds**: gradient blobs or orb shapes behind the title are banned (§8); use a restrained ambient field (e.g. a pointer-reactive dot/line field) or nothing.\n- **Purple-dominated page**: leaning on `color.semantic.action.primary` (the violet) as a large background wash instead of a small accent on the primary CTA only — the hero background stays `surface.base`, not tinted.\n- **Fake metrics dashboard as the proof surface**: substituting the search field or workspace preview with a generic SaaS analytics mockup (charts, fabricated KPI numbers) is banned unless the page's selected mode is explicitly Dashboard (§7 Hero Proof Surface, §8).\n\n## Agent notes\n\n- `prompt_phrases`: \"중앙 정렬 hero에 제품 약속과 CTA 2개, 그 아래 proof surface 하나만 넣어줘\", \"split hero 말고 한 칼럼으로, 배경에 blob 쓰지 말고\", \"Proof 버전으로 바꿔서 검색창 대신 좌측 nav/중앙 preview/우측 inspector 워크스페이스 미리보기 보여줘\"\n- Fallback: if the host page has no `terms`-shaped search dataset to power a live proof surface, fall back to the Proof variant's static workspace preview (left nav / central preview / right inspector) rather than leaving the CTA row as the last element — the block's contract requires one proof surface below the CTAs.\n- If the product needs a two-column layout with a real product screenshot, that is a different, currently-unbanned-elsewhere pattern outside this recipe's contract — do not force it into `landing-hero`, which is scoped to the centered/no-split direction confirmed in `DESIGN.md` §7-§8."},{"id":"magnetic-hover-button","name":"Magnetic Hover Button","pattern_group":"marketing","kind":"motion-pattern","status":"draft","surface_refs":["websites"],"tokens_used":["color.semantic.action.primary","color.semantic.text.on-accent","color.semantic.border.focus","color.semantic.surface.muted","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/magnetic-hover-button.tsx","component_refs":["button"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/marketing/magnetic-hover-button.md","body":"## Intent\n\nA magnetic hover button leans toward the cursor while hovered and springs back on leave. Tier ② in `knowledge/expressive-stack.md`: CSS `:hover` cannot know where the cursor is, so JS reads pointer coordinates and a spring drives a CSS transform. **This is an experimental touch (signature principle 5): showcase and marketing CTAs on explicit request only — never a product-wide button default.**\n\n## Anatomy\n\n- A regular, fully functional button — the magnetism is additive decoration on top of an intact button contract.\n- Pointer-move handler mapping cursor offset from center to a small translate (≤12px pull; attraction, not chase).\n- Spring-smoothed motion values so entry, movement, and snap-back are continuous and interruptible.\n- Reduced-motion branch that disables the lean entirely; keyboard focus/activation identical with or without magnetism.\n\n## States\n\n- Rest: plain button, token styling.\n- Hover: leans toward cursor, spring-smoothed.\n- Leave: springs back to origin (no teleport).\n- Focus-visible: standard ring; magnetism does not react to keyboard focus (no fake pointer).\n- Reduced motion: static button, all function preserved.\n\n## Variants\n\n- Single hero CTA (the canonical use).\n- Icon button magnet in a showcase context.\n- Whole-card magnet — larger surface, smaller pull.\n\n## Code\n\n```tsx\nconst springX = useSpring(x, { stiffness: 320, damping: 22 })\n\nconst handleMove = (event: React.PointerEvent) => {\n if (reduceMotion || !ref.current) return\n const rect = ref.current.getBoundingClientRect()\n x.set(((event.clientX - rect.left) / rect.width - 0.5) * 2 * pull)\n}\n\n<motion.button style={{ x: springX, y: springY }} onPointerMove={handleMove} onPointerLeave={reset} />\n```\n\n## Checks\n\n- Click target and focus ring behave identically to a plain button — the lean never moves the hit area out from under the cursor (pull stays small).\n- `prefers-reduced-motion` disables the lean.\n- Spring returns to exact origin on leave — no residual offset accumulating across hovers.\n- Used sparingly: at most one magnetic element per viewport.\n\n## Anti-patterns\n\n- **Magnetism as the default button**: applying it product-wide turns a signature accent into noise and violates restraint.\n- **Large pull values**: >16px pull makes the button chase the cursor and misalign with its hit area.\n- **Hover-only affordance**: any behavior difference for keyboard users beyond the missing lean — the button must work identically.\n- **Tween instead of spring**: duration-based easing snaps when the cursor re-enters mid-return; the spring's interruptibility is the point.\n\n## Agent notes\n\n- prompt_phrases: \"magnetic CTA with spring-smoothed pointer lean, reduced-motion off, intact keyboard contract\"\n- fallbacks: without pointer input (touch), the component is just a button — no degradation to handle.\n- canonical guidance: `knowledge/expressive-stack.md` decision row \"커서 추종·마그네틱 hover\"(②); signature principle 5 (experimental touches are manual opt-in)."},{"id":"mesh-gradient-surface","name":"Mesh Gradient Surface","pattern_group":"marketing","kind":"visual-effect","status":"draft","surface_refs":["websites","saas-dashboards","commerce"],"tokens_used":["color.semantic.action.primary","color.semantic.accent.base","color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/mesh-gradient-surface.tsx","component_refs":["glass-panel","grain-texture-overlay"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/marketing/mesh-gradient-surface.md","body":"## Intent\n\nA mesh gradient surface gives a hero, brand section, or empty backdrop an organic multi-point color field without imagery. Tier ① (CSS-only) in `knowledge/expressive-stack.md`: several radial-gradient layers stacked on one element approximate a mesh — no JS, no dependency, static by default. Use it where a flat token background feels inert but a photo or 3D scene would be overkill.\n\n## Anatomy\n\n- One container with 3–5 `radial-gradient` background layers, each positioned at a different corner/edge.\n- Every color stop derived from project tokens via `color-mix(in oklch, var(--token) N%, transparent)` — never literal stops, so the surface re-themes with the token system.\n- A base `background` token underneath so uncovered areas stay on-theme in both modes.\n- Foreground content slot; the surface itself is decoration and carries no semantics.\n\n## States\n\n- Static (default): no animation — the field is composition, not motion.\n- Animated (opt-in only): color-stop drift via `@property` registered custom properties; must respect `prefers-reduced-motion` and is an experimental touch (signature principle 5 — manual opt-in).\n- Dark mode: mixes against the dark `background` token automatically because stops are token-derived — verify contrast of overlaid text in both modes.\n\n## Variants\n\n- Hero backdrop (full-bleed, content overlaid).\n- Card/section accent (bounded, rounded, bordered).\n- Backdrop for glass surfaces (pair with `glass-panel` — the blur needs a busy background to be visible).\n- Dark-base aura: on a near-black base, confine the gradient layers to one region (typically the top) as a soft aura and float content cards above it — mood without sacrificing content contrast (taste ledger T-10).\n\n## Code\n\n```tsx\nconst style: CSSProperties = {\n backgroundImage: [\n \"radial-gradient(42% 55% at 18% 22%, color-mix(in oklch, var(--primary) 34%, transparent) 0%, transparent 100%)\",\n \"radial-gradient(50% 60% at 82% 18%, color-mix(in oklch, var(--accent) 65%, transparent) 0%, transparent 100%)\",\n \"radial-gradient(55% 65% at 70% 85%, color-mix(in oklch, var(--primary) 22%, transparent) 0%, transparent 100%)\",\n \"radial-gradient(40% 45% at 12% 88%, color-mix(in oklch, var(--accent) 40%, transparent) 0%, transparent 100%)\",\n ].join(\", \"),\n}\nreturn <div className=\"relative overflow-hidden bg-background\" style={style}>{children}</div>\n```\n\n## Checks\n\n- All gradient stops derive from semantic tokens via `color-mix` — zero literal colors.\n- Text overlaid on the surface passes WCAG contrast in light and dark modes (test at the busiest gradient intersection).\n- The surface renders acceptably with banding on low-bit displays — pair with `grain-texture-overlay` when banding is visible.\n- Container clips its layers (`overflow-hidden`) so gradients never bleed past the radius.\n\n## Anti-patterns\n\n- **Invented palette**: hardcoded pastel/purple stops that ignore the project's tokens — the exact failure the style signature's token-derivation principle exists for.\n- **Decorative blob divs**: extra absolutely-positioned blurred circles to fake the mesh — one element with layered gradients does it cheaper and stays in normal flow.\n- **Tier escalation**: reaching for a WebGL shader when the gradient is static — per the expressive-stack decision table, animated organic noise justifies tier ④, a static field does not.\n- **Unreadable overlay**: dropping body text on the gradient without checking the busiest region's contrast.\n\n## Agent notes\n\n- prompt_phrases: \"token-derived mesh gradient hero backdrop from layered radial-gradients\", \"CSS-only multi-point color field using color-mix over project tokens\"\n- fallbacks: browsers without `color-mix` support fall back to the base background token (layers resolve invalid) — surface degrades to a flat themed background, never breaks.\n- canonical guidance: `knowledge/expressive-stack.md` tier ① + decision table row \"메시/원뿔형 그라디언트 배경\"."},{"id":"scroll-driven-reveal","name":"Scroll-Driven Reveal","pattern_group":"marketing","kind":"motion-pattern","status":"draft","surface_refs":["websites","commerce","documentation"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/scroll-driven-reveal.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/marketing/scroll-driven-reveal.md","body":"## Intent\n\nA scroll-driven reveal fades/raises content in as it enters the scrollport, using CSS view-progress timelines (`animation-timeline: view()`). Tier ① in `knowledge/expressive-stack.md`: for a plain \"appear on entry\" mapping this needs no scroll listeners, no IntersectionObserver, and runs off the main thread. Escalate to tier ② (GSAP ScrollTrigger) only for pinning, scrubbing, or cross-element sequencing — per the decision table.\n\n## Anatomy\n\n- A reveal item whose hidden initial state (`opacity: 0`, small translate) is declared **only inside** `@supports (animation-timeline: view())` — the load-bearing safety: unsupported browsers see content immediately.\n- `animation: <keyframes> both` + `animation-timeline: view()` + `animation-range: entry 0% entry N%` mapping the entry window to animation progress.\n- A `prefers-reduced-motion: reduce` override that disables the animation (content visible, no movement).\n- The nearest scrollport (page or an `overflow` container) drives the timeline automatically.\n\n## States\n\n- Before entry: hidden (supported browsers only).\n- During entry: progress mapped to scroll position — reversible by scrolling back up.\n- Reduced motion: fully visible, no animation.\n- No support (Safari/Firefox partial as of 2026-07): fully visible static content — indistinguishable from \"already revealed\".\n\n## Variants\n\n- Landing section reveal (page scrollport).\n- Bounded feed/list reveal (inner `overflow-y-auto` scrollport).\n- Progress-linked treatments (scale/blur instead of rise) — same timeline, different keyframes.\n\n## Code\n\n```tsx\nconst REVEAL_CSS = `\n@supports (animation-timeline: view()) {\n .sdr-item {\n animation: sdr-reveal both;\n animation-timeline: view();\n animation-range: entry 0% entry 70%;\n }\n @keyframes sdr-reveal {\n from { opacity: 0; transform: translateY(1.25rem); }\n to { opacity: 1; transform: none; }\n }\n}\n@media (prefers-reduced-motion: reduce) {\n .sdr-item { animation: none; }\n}\n`\n```\n\n## Checks\n\n- The hidden initial state lives only under `@supports` — removing timeline support must leave all content visible (reveal failure ≠ content loss).\n- `prefers-reduced-motion` disables movement entirely.\n- Only `opacity`/`transform` animate — no layout properties on a scroll-linked timeline.\n- Reveal distance stays small (≤1.5rem) and the entry range ends well before center — content is readable when the user's eye reaches it.\n\n## Anti-patterns\n\n- **Hidden-by-default without @supports gate**: `opacity: 0` in base styles turns every unsupported browser into a blank page — the single worst failure of this pattern.\n- **JS re-implementation**: adding IntersectionObserver for a plain entry reveal when the CSS timeline covers it — tier escalation without justification.\n- **Reveal everything**: staggering every section element makes the page feel gated; reveal one structural unit per viewport at most.\n- **Scroll-linked layout animation**: animating height/margin on a scroll timeline causes continuous reflow.\n\n## Agent notes\n\n- prompt_phrases: \"CSS view() timeline reveal with @supports-gated hidden state and reduced-motion off-switch\", \"scroll-entry fade-rise without JS listeners\"\n- fallbacks: unsupported browsers get static visible content by construction; if pinning/scrubbing/sequencing is requested, use tier ② GSAP ScrollTrigger instead of stretching this recipe.\n- canonical guidance: `knowledge/expressive-stack.md` decision rows \"스크롤 진행률에 매핑된 애니메이션\"(①) vs \"핀 고정·스크럽\"(②)."},{"id":"bottom-tab-bar","name":"Bottom Tab Bar","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.destructive"],"code_asset":"examples/ui-vocabulary-site/src/components/bottom-tab-bar.tsx","component_refs":[],"term_refs":["tab-bar"],"source_refs":["apple-hig-components"],"last_verified":"2026-07-12","source_path":"recipes/navigation/bottom-tab-bar.md","body":"## Intent\n\nA persistent bar fixed to the bottom of a mobile screen that switches between the app's top-level, equally-important destinations (`tab-bar` in the vocabulary). Unlike `mobile-segmented-tabs`, which switches views inside one screen, this bar changes the whole destination — home, search, activity, profile — and stays visible across all of them.\n\n## Anatomy\n\n- Bar container: full-width row, fixed to the bottom of the screen, sitting above the device's safe-area inset.\n- One button per destination (3-5 total): icon + a short 1-2 word label, always shown together — never icon-only.\n- Active indicator: the active tab's icon switches from outlined to filled and its text/icon color shifts from muted to full-emphasis foreground.\n- Optional badge: a small filled circle with a count, reserved for critical/actionable info (unread count), not decorative.\n\n## States\n\n- **Inactive tab**: outlined icon, muted label/icon color (`text-muted-foreground`).\n- **Active tab**: filled icon (`fill=\"currentColor\"`), full-emphasis color (`text-foreground`), `aria-current=\"page\"` and `aria-selected=\"true\"` for the current destination.\n- **Badge present**: rendered as a small absolutely-positioned circle on the icon; paired with visually-hidden text (`sr-only`, e.g. \"3 unread\") so the count isn't screen-reader-silent.\n- **Tap**: `onTabChange` fires immediately on click/tap — there is no drag or swipe state; switching between destinations is tap-only.\n\n## Variants\n\n- Destination count: keep to 3-5 (HIG). More than that should trigger an information-architecture review (move less-used destinations into a `More` screen or reconsider grouping) rather than reflowing the bar to fit extra tabs.\n- Trailing accessory (not implemented in the code asset): platforms like iOS allow a minimize-on-scroll trailing element (e.g. a mini player) docked beside the tab bar; treat that as a separate composed element, not a tab.\n\n## Code\n\n```tsx\n<button\n type=\"button\"\n role=\"tab\"\n aria-selected={isActive}\n aria-current={isActive ? \"page\" : undefined}\n onClick={() => onTabChange(tab.id)}\n className={cn(\n \"relative flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors\",\n isActive ? \"text-foreground\" : \"text-muted-foreground\"\n )}\n>\n <span className=\"relative\">\n <Icon className=\"size-5\" fill={isActive ? \"currentColor\" : \"none\"} aria-hidden=\"true\" />\n {tab.badgeCount ? (\n <span\n aria-hidden=\"true\"\n className=\"absolute -right-1.5 -top-1.5 flex size-3.5 items-center justify-center rounded-full bg-destructive text-[9px] font-semibold text-white\"\n >\n {tab.badgeCount}\n </span>\n ) : null}\n </span>\n <span>{tab.label}</span>\n</button>\n```\n\nFull tab list, layout, and the colocated `BottomTabBarDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- Every tab has both an icon and a visible text label — never ship icon-only tabs.\n- Active tab is distinguishable by more than color alone (filled vs outlined icon), so it still reads correctly for color-blind users.\n- Badge count has an `sr-only` text equivalent; the badge itself is `aria-hidden`.\n- `role=\"tab\"` + `aria-selected`/`aria-current` are present so assistive tech announces the current destination.\n- Bar respects the bottom safe-area inset on real devices (see `safe-area` term) so it doesn't collide with the home indicator.\n\n## Anti-patterns\n\n- **Icon-only tabs**: dropping the label to save space breaks HIG guidance and hurts discoverability — always pair icon + label.\n- **Using tabs for view-mode switching**: wiring this bar to swap sort/filter modes within the same screen instead of switching top-level destinations conflates it with `mobile-segmented-tabs` — keep tab bar reserved for destination switching only.\n- **Overflowing past 5 destinations**: adding a 6th+ tab instead of restructuring the IA (e.g. a `More` screen or moving a destination into settings) makes the bar cramped and harder to scan.\n- **Swipe-to-switch destinations**: allowing a horizontal swipe on the screen body to change the active tab (instead of requiring an explicit tap on the bar) contradicts the \"tabs switch only by tapping\" rule this recipe encodes.\n\n## Agent notes\n\n- `prompt_phrases`: \"하단에 tab bar를 넣고 홈 검색 설정 탭을 보여줘\", \"선택된 탭은 아이콘과 라벨을 강조해줘\", \"활동 탭에 안 읽은 알림 뱃지를 표시해줘\"\n- If the request needs same-screen view switching (not top-level destination switching), use `mobile-segmented-tabs` instead of adding more tabs here.\n- If destinations exceed 5 or the app also runs on a wide/tablet layout, consider `adaptive-split-view` (sidebar-convertible) rather than cramming more tabs into this bar.\n- Fallback: if no icon set matches the destination set, keep labels mandatory and use any consistent icon set — the label is the accessibility floor, not the icon choice."},{"id":"doc-search-cmdk-grouped-results-panel","name":"Doc Search Cmd-K Grouped Results Panel","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.overlay","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/doc-search-cmdk-grouped-results-panel.tsx","component_refs":["topbar-command-search"],"term_refs":["command-palette","search-suggestions"],"source_refs":["tailwind-plus-documentation-command-palette"],"last_verified":"2026-07-12","source_path":"recipes/navigation/doc-search-cmdk-grouped-results-panel.md","body":"## Intent\n\nA documentation site's Cmd/Ctrl+K search reuses the `command-palette` overlay shell (`topbar-command-search` implements the same input+popover idea inline in a topbar), but a docs search result set spans several kinds of content — pages, guides, API entries — and a flat list makes the reader scan section by section anyway. Grouping results under section headers (Docs/Guides/API) inside the same overlay does that scanning for them, without inventing a second overlay pattern.\n\n## Anatomy\n\n- Trigger: a topbar button showing a keyboard shortcut hint (handled by the caller, not this component — this recipe covers the overlay itself).\n- Overlay: `CommandDialog` (Radix `Dialog` + `cmdk`), with a search input pinned at the top.\n- Result list: one `CommandGroup` per section (e.g. Docs, Guides, API), each with its own heading and rows.\n- Row: label plus an optional one-line description, keyboard-navigable via `cmdk`'s built-in active-row handling.\n- Empty-query state: instead of an empty overlay, shows a \"Recent\" group (past queries) and a \"Suggested\" group (curated docs) — this absorbs the recent/suggested-terms behavior into the same panel rather than a separate recipe.\n- No-match state: a single `CommandEmpty` row when a non-empty query yields zero results in every group.\n\n## States\n\n- Empty query (`query === \"\"`): shows Recent + Suggested groups, no `CommandEmpty` row.\n- Typed query with matches: shows the section groups that have results; a group with zero matching rows is omitted entirely, not rendered with a \"no results\" placeholder.\n- Typed query with zero matches across every group: shows only `CommandEmpty`.\n- Keyboard navigation: arrow keys move the active row across group boundaries, Enter selects it — handled by `cmdk`, not re-implemented here.\n\n## Variants\n\n- Docs-scoped (this recipe): sections are content-type groups (Docs/Guides/API).\n- Ops-scoped variant: `command-palette`'s own description already covers an entity-scoped ops variant with visually separated destructive actions — that is a different scoping axis (entity, not content-type) and stays a `command-palette` usage note rather than a second grouped-results recipe.\n\n## Code\n\n```tsx\n<CommandDialog open={open} title=\"Search documentation\" onOpenChange={onOpenChange}>\n <CommandInput value={query} onValueChange={onQueryChange} placeholder=\"Search docs...\" />\n <CommandList>\n {isEmptyQuery ? (\n <>\n <CommandGroup heading=\"Recent\">{/* recent queries */}</CommandGroup>\n <CommandGroup heading=\"Suggested\">{/* suggested docs */}</CommandGroup>\n </>\n ) : (\n <>\n <CommandEmpty>No results found.</CommandEmpty>\n {groups.map((group) => (\n <CommandGroup key={group.id} heading={group.label}>\n {group.items.map((item) => <CommandItem key={item.id} value={item.label} onSelect={() => onSelect(item.id)} />)}\n </CommandGroup>\n ))}\n </>\n )}\n </CommandList>\n</CommandDialog>\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/doc-search-cmdk-grouped-results-panel.tsx`. Built on the `command` (cmdk) and `dialog` shadcn primitives.\n\n## Checks\n\n- A group with no matching rows for the current query is omitted from the list, not rendered with an empty sub-heading.\n- The Recent/Suggested empty-query content and the grouped-results typed-query content are mutually exclusive — never both visible at once.\n- `CommandEmpty` only renders when every group has zero rows, not per-group.\n- Keyboard active-row state survives moving from one group's last row to the next group's first row.\n\n## Anti-patterns\n\n- **Flat list pretending to be grouped**: rendering section labels as plain text inside one list instead of using `CommandGroup`, which breaks `cmdk`'s per-group keyboard semantics.\n- **Empty overlay on first open**: showing a blank result list before the user types anything, instead of the Recent/Suggested fallback content.\n- **Per-group \"no results\" rows**: rendering an explicit empty message inside every group instead of simply omitting empty groups.\n- **Reimplementing keyboard navigation**: hand-rolling arrow-key/active-index logic on top of `cmdk`'s `Command.List`/`Command.Item`, which already owns that behavior.\n\n## Agent notes\n\n- prompt_phrases: \"문서 사이트에 Cmd K로 여는 검색 패널을 만들어줘, 결과를 Docs/Guides/API로 그룹핑해줘\", \"검색어가 없을 때는 최근 검색어와 추천 문서를 보여주는 문서 검색 오버레이를 만들어줘\"\n- fallbacks: if only one section has results, still render it inside a `CommandGroup` with its heading rather than collapsing to a flat list — a second section can be added later without restructuring.\n- related: distinct from `topbar-command-search`, which renders search inline in the topbar via a `Popover` and is not grouped by content-type; this recipe is a full-overlay `CommandDialog` grouped by section."},{"id":"large-title-collapsing-header","name":"Large Title Collapsing Header","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.text.default","color.semantic.surface.base","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/large-title-collapsing-header.tsx","component_refs":[],"term_refs":["large-title-header","top-app-bar"],"source_refs":["material-m3-components"],"last_verified":"2026-07-12","source_path":"recipes/navigation/large-title-collapsing-header.md","body":"## Intent\n\n`large-title-header` names the visual states (big title / collapsed) but not the transition contract. This recipe fixes the scroll-driven large→small compression itself: the exact threshold behavior, the back button staying a separate always-present tap target distinct from the title transform, and the compact header filling in a background once collapsed so it visually separates from the scrolling content beneath it.\n\n## Anatomy\n\n- Large title text row: the screen's title rendered at a large type scale, left-aligned above the content.\n- Safe-area-top inset: the header sits above the device's safe area, not overlapping it.\n- Back-button leading: a fixed 44×44+ tap target, always present regardless of collapse state — never removed or resized during the transition.\n- Scroll-driven compress transition: a `sticky` header whose title size and background opacity respond to the list's scroll offset, not to any tap/drag gesture on the header itself.\n- Compact header post-collapse: once past the threshold, the header gains a bottom border and background fill so it reads as a distinct bar from the content scrolling underneath it.\n\n## States\n\n- **Expanded**: `scrollTop <= COLLAPSE_THRESHOLD_PX`. Title renders at the large type scale, header background and border are transparent (title appears to sit directly on the content).\n- **Compact**: `scrollTop > COLLAPSE_THRESHOLD_PX`. Title shrinks to the compact type scale, header gains a background fill and bottom border.\n- **Screen-reader active**: the header must not auto-hide on scroll direction the way a `top-app-bar` show/hide bar might — the title-collapse transform stays, but a hide-on-scroll behavior (if added) must be suppressed per the accessibility exception both terms already document.\n\n## Variants\n\n- Passive scroll-linked transform only — the header itself is not a tap or drag target for the compression; only the back button is interactive.\n- This recipe implements title-collapse only; it does not implement the separate scroll-direction show/hide behavior described on `top-app-bar` — combine the two only if the product actually needs both.\n\n## Code\n\n```tsx\n<div\n className={cn(\n \"sticky top-0 z-10 flex shrink-0 items-center gap-2 border-b px-4 transition-colors\",\n isCompact ? \"border-border bg-background py-3\" : \"border-transparent bg-background pb-3 pt-1\"\n )}\n>\n <button type=\"button\" aria-label=\"Back\" className=\"flex size-11 shrink-0 items-center justify-center rounded-full\">\n <ChevronLeftIcon className=\"size-5\" aria-hidden=\"true\" />\n </button>\n <p className={cn(\"font-semibold transition-all\", isCompact ? \"text-base\" : \"text-2xl\")}>Inbox</p>\n</div>\n```\n\nFull scroll-offset tracking and the colocated `LargeTitleCollapsingHeaderDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- Back button stays a constant 44×44+ tap target at every scroll position — it must never shrink alongside the title.\n- Title type scale and header background transition only in response to scroll offset, not to any header tap/drag.\n- Compact state visually separates the header from content (border/background fill) so users can tell where the fixed header ends and scrollable content begins.\n- Verify no auto-hide-on-scroll behavior is added without the screen-reader-active exception carried over from `top-app-bar`.\n\n## Anti-patterns\n\n- **Back button changing size/position across states**: shrinking or moving the back button as part of the title-collapse animation breaks a fixed, predictable tap target users rely on mid-scroll.\n- **Conflating back with sheet dismiss**: reusing this back button to dismiss a bottom sheet or modal (instead of stack navigation) contradicts `large-title-header`'s own distinction between the two actions.\n- **Header opaque at all times**: skipping the expanded/transparent state and always rendering the compact background fill loses the \"large presence at the top of a screen\" effect the term is named for.\n- **Auto-hiding this header on scroll direction without the accessibility exception**: if a hide-on-scroll behavior is layered on top, it must be suppressed when a screen reader is active — do not hide navigation controls from assistive tech users the same way as sighted scroll-based hiding.\n\n## Agent notes\n\n- `prompt_phrases`: \"화면 상단에 스크롤하면 접히는 large title header를 넣어줘\", \"뒤로가기 버튼은 스크롤 상태와 무관하게 항상 같은 크기로 유지해줘\", \"접힌 뒤에는 헤더 배경을 채워서 콘텐츠와 분리해줘\"\n- If the product also needs the bar to hide on scroll-down and reappear on scroll-up (not just shrink), combine with `top-app-bar`'s show/hide rule rather than adding that behavior here.\n- Fallback: if a spring-animation library isn't available, a CSS `transition` on font-size/background (as implemented) is an acceptable baseline — do not skip the transition entirely and snap instantly between states."},{"id":"topbar-command-search","name":"Topbar Command Search","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["websites","saas-dashboards"],"tokens_used":["color.semantic.surface.overlay","color.semantic.border.focus","color.semantic.text.muted","color.semantic.surface.muted","dimension.radius.lg","dimension.radius.xl","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/topbar-search.tsx","component_refs":["popover"],"term_refs":["command-palette","autocomplete"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-07","source_path":"recipes/navigation/topbar-command-search.md","body":"## Intent\n\nA collapsed topbar icon that expands into a full-width search input with a live suggestion popover underneath it. It gives a site or app header a lightweight, command-palette-adjacent entry point without the modal weight of a full command palette (`command-palette`): the field lives inline in the topbar, expands in place, and collapses back to an icon when the user is done. Suggestions blend two behaviors from the vocabulary — starter shortcuts (`group`/`category` suggestions shown before typing) and typed autocomplete (`autocomplete`) ranked as the user types.\n\n## Anatomy\n\n- Root container: fixed-width wrapper that animates `width` between a collapsed icon-button size and an expanded input size (`transition-[width]`, respects `motion-reduce`).\n- Collapsed state: icon-only button (`Search` icon + optional `Ctrl F` hint label on desktop).\n- Expanded state: leading search icon, text input, trailing close (`X`) button.\n- `Popover` (Radix) anchored to the input via `PopoverAnchor`, with `PopoverContent` rendering a scrollable suggestion list below.\n- Each suggestion row: leading `Search` icon, label + description stack, trailing `ArrowRight` icon; highlighted row uses `bg-secondary`.\n- Section label above the list switches between \"바로 찾기\" (starter suggestions, empty query) and \"추천 결과\" (typed results).\n\n## States\n\n- **Closed / collapsed**: `expanded === false`. Only the icon button renders; clicking it sets `expanded = true`, which focuses the input on a short delay (`window.setTimeout(..., 40)`) so the width transition finishes first.\n- **Open, empty query**: `expanded === true` and `draftQuery` is empty. `suggestions` falls back to a fixed `topbarStarterSuggestions` list (site sections such as Hero Sections, Footers, Application Shells). The popover is open (`open = expanded && suggestions.length > 0`) as long as this fallback list is non-empty, so an empty-query state always has content — there is no true \"no results\" state at the empty-query step.\n- **Open, typed query**: `getSearchSuggestions` computes up to 8 ranked matches from `terms` against `draftQuery` and the active `filter`. If a typed query yields zero suggestions, `suggestions.length === 0` forces `open` to `false` — the popover disappears entirely rather than showing an explicit empty state.\n- **Keyboard navigation** (`handleKeyDown`, bound to the input's `onKeyDown`):\n - `ArrowDown` / `ArrowUp`: move `activeIndex` forward/backward through `suggestions` with wraparound (`(current + 1) % suggestions.length`), `preventDefault()`'d so the caret doesn't move.\n - `Enter`: commits the currently active suggestion (`selectSuggestion(suggestions[activeIndex])`) if one exists, otherwise commits the raw typed query (`commitQuery()`).\n - `Escape`: calls `collapse()` — closes the popover, resets `activeIndex` to 0, and re-collapses the topbar to icon width via `onExpandedChange(false)`.\n - Mouse `onMouseEnter` on a row also sets `activeIndex`, so hover and keyboard arrows share the same highlight state.\n- **Outside pointerdown**: a `document` listener collapses the field whenever a pointerdown lands outside the root container while expanded (checked via `rootRef.current?.contains(target)`).\n- **Selecting a suggestion**: `category`/`group` suggestions call `onFilterChange` and clear the query; term suggestions commit the suggestion's `value` as the query. Either path calls `collapse()` afterward.\n- **Focus behavior on the popover itself**: `onOpenAutoFocus` and `onCloseAutoFocus` both call `preventDefault()` — focus is deliberately kept on the input, never stolen by the popover content.\n\n## Variants\n\n- Desktop shows a `Ctrl F` hint label next to the collapsed icon (`hidden md:inline`); mobile hides it.\n- Expanded width differs by breakpoint: `w-[calc(100vw-2rem)]` on mobile vs. fixed `md:w-[22rem]` on desktop, so the field goes near-fullscreen on small viewports instead of overflowing.\n- Popover content width is itself clamped (`w-[min(28rem,calc(100vw-2rem))]`) independent of the trigger width.\n\n## Code\n\n```tsx\nfunction handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if (event.key === \"ArrowDown\" && suggestions.length > 0) {\n event.preventDefault()\n setActiveIndex((current) => (current + 1) % suggestions.length)\n }\n if (event.key === \"ArrowUp\" && suggestions.length > 0) {\n event.preventDefault()\n setActiveIndex((current) => (current - 1 + suggestions.length) % suggestions.length)\n }\n if (event.key === \"Enter\") {\n event.preventDefault()\n const activeSuggestion = suggestions[activeIndex]\n if (activeSuggestion) {\n selectSuggestion(activeSuggestion)\n } else {\n commitQuery()\n }\n }\n if (event.key === \"Escape\") {\n collapse()\n }\n}\n```\n\n```tsx\nuseEffect(() => {\n if (!expanded) {\n return\n }\n\n function closeOnOutsidePointer(event: PointerEvent) {\n const target = event.target as Node | null\n if (target && rootRef.current?.contains(target)) {\n return\n }\n\n collapse()\n }\n\n document.addEventListener(\"pointerdown\", closeOnOutsidePointer)\n\n return () => {\n document.removeEventListener(\"pointerdown\", closeOnOutsidePointer)\n }\n}, [collapse, expanded])\n```\n\nFull implementation, including the starter suggestion list and popover markup, lives in `code_asset`. Suggestion ranking is delegated to `examples/ui-vocabulary-site/src/lib/search-suggestions.ts` (`getSearchSuggestions`), which this recipe treats as an implementation detail of the block, not part of its own contract.\n\n## Checks\n\n- Input has `aria-autocomplete=\"list\"`, `aria-expanded={open}`, and an explicit `aria-label` (Korean copy in the current asset — localize per product).\n- Collapsed and close buttons both have `aria-label` (검색 열기 / 검색 닫기); icons are `aria-hidden`.\n- Active suggestion row is reachable and distinguishable by both keyboard (`activeIndex`) and mouse (`onMouseEnter`) — verify the highlighted state (`bg-secondary`) has sufficient contrast against `hover:bg-muted` rows.\n- Confirm the outside-pointerdown collapse does not fire when clicking inside the popover (popover is inside `rootRef` via anchor, but verify after any DOM restructuring).\n- Confirm `Escape` collapses even when focus is inside the popover list, not just the input.\n- Motion: width transition must respect `motion-reduce:transition-none`.\n\n## Anti-patterns\n\n- **No keyboard path, mouse-only rows**: rendering suggestion rows as plain `<div onClick>` instead of tracking an `activeIndex` reachable via `ArrowUp`/`ArrowDown` and committed via `Enter`. AI-generated search overlays frequently wire only `onClick`, which strands keyboard users and violates the command-palette expectation this pattern borrows from.\n- **No empty-results state for typed queries**: silently closing the popover when `suggestions.length === 0` (as this asset does) is acceptable only if the input itself signals \"no matches\" some other way (e.g. inline text). A common AI mistake is to leave zero-result typing completely silent with no feedback that the query is being processed or matched at all — always give the user *some* observable response to a search with no hits.\n- **Autofocus stealing on open**: letting the popover's default `onOpenAutoFocus` run moves focus off the input onto the first list item, breaking normal typing. This pattern must explicitly `preventDefault()` both `onOpenAutoFocus` and `onCloseAutoFocus`, or typed input gets interrupted the moment suggestions appear.\n- **Outside-click without outside-focus handling**: only listening for `pointerdown` to collapse the field ignores keyboard-only users tabbing away from the component (e.g. Shift+Tab out of the input). A robust version should also collapse on blur when focus moves outside `rootRef`, not just on pointer events.\n\n## Agent notes\n\n- `prompt_phrases`: \"topbar 검색창에 자동완성 붙여줘\", \"헤더 검색 아이콘 눌렀을 때 커맨드 팔레트처럼 확장되게 해줘\", \"검색 결과 없을 때 화면 처리도 해줘\"\n- Fallback: if the host app has no `terms`-shaped dataset to rank against, keep the starter-suggestion fallback (`topbarStarterSuggestions`-equivalent) so the popover never opens empty; wire the ranking function later without changing the keyboard/state contract above.\n- If the product needs a true modal command palette (Cmd+K style, full-screen overlay, always-available global shortcut) rather than an inline topbar expand/collapse, use the `command-palette` term instead — this recipe assumes an anchored, dismissible, topbar-local field, not a global overlay."},{"id":"versioned-docs-switcher-navbar-sidebar-swap","name":"Versioned Docs Switcher With Navbar Dropdown And Sidebar Swap","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/versioned-docs-switcher-navbar-sidebar-swap.tsx","component_refs":[],"term_refs":["versioned-docs-switcher"],"source_refs":["mintlify-docs-navigation"],"last_verified":"2026-07-12","source_path":"recipes/navigation/versioned-docs-switcher-navbar-sidebar-swap.md","body":"## Intent\n\nThe `versioned-docs-switcher` term already names the dropdown-plus-stale-banner concept; this recipe is the actual three-part state contract behind it — picking a version in the navbar dropdown does not just relabel the page, it swaps the sidebar tree's entire data source to that version's structure, and shows a stale-version banner whenever the selected version is not the latest. The dropdown, banner, and sidebar all read from one `activeVersionId` value instead of three independently-set flags.\n\n## Anatomy\n\n- Navbar row: site/docs label plus a version `Select` dropdown, latest version selected by default.\n- Stale banner: conditional strip below the navbar, visible only when the active version is not the latest.\n- Sidebar tree: full nested navigation list, sourced from `sidebarByVersion[activeVersionId]` — a different version renders a structurally different tree, not the same tree with different labels.\n\n## States\n\n- Latest version selected: no stale banner, sidebar shows the latest version's tree.\n- Older version selected: stale banner visible, sidebar swaps to that version's own tree (which may have fewer or differently-organized pages).\n- Version with no matching page for the reader's current location: out of scope for this component — the caller re-maps the current page to its nearest equivalent in the target version before rendering, this component only renders whatever tree it is given.\n\n## Variants\n\n- Dropdown-only (no sidebar swap): a lighter variant where only the banner+dropdown apply and the sidebar stays version-agnostic — not implemented here; if the product never has version-specific navigation structure, `versioned-docs-switcher`'s dropdown+banner alone is enough and this heavier recipe is unnecessary.\n\n## Code\n\n```tsx\n<Select value={activeVersionId} onValueChange={onVersionChange}>\n <SelectTrigger size=\"sm\"><SelectValue /></SelectTrigger>\n <SelectContent>\n {versions.map((v) => <SelectItem key={v.id} value={v.id}>{v.label}{v.isLatest ? \" (latest)\" : \"\"}</SelectItem>)}\n </SelectContent>\n</Select>\n{activeVersion && !activeVersion.isLatest ? (\n <div className=\"flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-sm\">\n <TriangleAlert className=\"size-4\" />\n <span>You are viewing docs for {activeVersion.label}, which is not the latest version.</span>\n </div>\n) : null}\n<nav>{sidebarGroups.map((group) => /* group.pages rendered as links */ null)}</nav>\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/versioned-docs-switcher-navbar-sidebar-swap.tsx`. Built on the `select` shadcn primitive.\n\n## Checks\n\n- Selecting a version updates the sidebar tree, not only the dropdown's displayed value.\n- The stale banner's visibility is derived from `activeVersion.isLatest`, never a separately-tracked boolean that could drift out of sync with the dropdown selection.\n- Sidebar groups render from `sidebarByVersion[activeVersionId]` only — no merging of the previous version's tree with the new one.\n- Latest version never shows the stale banner, even immediately after switching away from and back to it.\n\n## Anti-patterns\n\n- **Dropdown without a data swap**: changing the selected label but leaving the sidebar tree pointing at the previous version's data — the two must read the same `activeVersionId`.\n- **Banner tracked separately from the dropdown**: a standalone \"is this stale\" flag that can fall out of sync with the actual selected version, instead of deriving it from `isLatest` on the currently selected version.\n- **Silent page 404 on switch**: switching version and leaving the reader on a URL that doesn't exist in the new version's tree without any re-map or redirect.\n\n## Agent notes\n\n- prompt_phrases: \"navbar 버전 드롭다운을 고르면 사이드바 전체가 그 버전 구조로 바뀌는 문서 내비게이션을 만들어줘\", \"오래된 버전을 보고 있으면 상단에 경고 배너가 뜨고 사이드바도 그 버전 트리로 바뀌게 해줘\"\n- fallbacks: if a version has no entry in `sidebarByVersion`, render an empty sidebar rather than falling back to another version's tree silently.\n- related: this recipe implements the existing `versioned-docs-switcher` term's dropdown+banner+sidebar-swap description as actual composition; it does not introduce a new term."},{"id":"action-sheet-destructive-confirmation","name":"Action Sheet Destructive Confirmation","pattern_group":"overlays","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.action.destructive","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/action-sheet-destructive-confirmation.tsx","component_refs":[],"term_refs":["action-sheet"],"source_refs":["apple-hig-components"],"last_verified":"2026-07-12","source_path":"recipes/overlays/action-sheet-destructive-confirmation.md","body":"## Intent\n\nAn `action-sheet` (vocabulary term) variant tuned for the common case where one of the listed actions is destructive and irreversible (delete). It separates the destructive row visually (warn-tone text) from the neutral rows, and separates Cancel from the whole action group by a gap, so an upward thumb-sweep from the bottom edge naturally lands on Cancel rather than on the destructive row.\n\n## Anatomy\n\n- Scrim: full-bleed tappable layer behind the sheet; tapping it dismisses without acting (same as tapping Cancel).\n- Action group: a single rounded card containing one row per action, divided by hairlines.\n- Destructive row: same row shape as the others, text colored with `color.semantic.action.destructive` instead of default foreground — no separate container or icon treatment beyond color.\n- Cancel row: its own separate rounded card, placed below the action group with a visible gap — never merged into the same list as the actions.\n\n## States\n\n- **Closed**: sheet not rendered (`open === false`).\n- **Open**: scrim + action group + Cancel row all rendered; `role=\"dialog\"` and `aria-modal=\"true\"` on the sheet region.\n- **Destructive row**: text-only color change (`text-destructive`), no separate background — the only affordance distinguishing it from a neutral action is the warn-tone color plus its position (not the top row).\n\n## Variants\n\n- Action count: this recipe demonstrates 2 neutral + 1 destructive; the same divided-card shape holds for any small number of rows (HIG guidance keeps the list short — long lists should reconsider action sheet as the right pattern).\n- Optional title/message above the action group (not implemented in the code asset) — add a short heading row inside the same card when the destructive action needs a one-line consequence statement (\"This can't be undone\").\n\n## Code\n\n```tsx\n<button\n key={row.id}\n type=\"button\"\n onClick={() => onAction(row.id)}\n className={cn(\n \"flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium\",\n isDestructive ? \"text-destructive\" : \"text-foreground\"\n )}\n>\n <Icon className=\"size-4\" aria-hidden=\"true\" />\n {row.label}\n</button>\n```\n\nFull sheet shell (scrim, action group card, separated Cancel row) and the colocated `ActionSheetDestructiveConfirmationDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- The destructive action's row is text-color-only distinguished (`color.semantic.action.destructive`) — not the sole differentiator relying on position, since color-blind or low-vision users need the position/grouping cue too (Cancel is always last and separated).\n- Cancel is visually separated (its own card, gapped) from the action rows, never the first or a middle row in the same list.\n- Scrim tap dismisses the same way Cancel does — there is no action taken by tapping outside.\n- `role=\"dialog\"` + `aria-modal=\"true\"` are present on the sheet region.\n\n## Anti-patterns\n\n- **Putting Cancel inside the same divided list as the actions**: this removes the visual separation that prevents a fast thumb-swipe from accidentally landing on Cancel expecting it, and also risks landing on the destructive row instead.\n- **Using icon/background color alone on the destructive row**: styling only the icon (not the text) or adding a full destructive-colored background reads as a difference in emphasis (like a primary button) rather than a warning — keep it text-color-only, matching the neutral rows' shape.\n- **Reusing this for a single confirm/cancel decision**: if there's only one yes/no decision (not a list of alternative actions), use `alert`/`dialog` instead of an action sheet — action sheet is for choosing among multiple actions.\n\n## Agent notes\n\n- `prompt_phrases`: \"메시지를 더보기 누르면 삭제가 빨간 글씨인 action sheet가 아래에서 올라오게 해줘\", \"취소 버튼은 액션 목록과 떨어뜨려서 보여줘\", \"삭제 행동은 destructive 색으로 구분해줘\"\n- If the request needs a single confirm/deny decision instead of choosing among several actions, use `dialog`/`alert` instead of this recipe.\n- Fallback: if no destructive semantic color token is themed for a target product, keep the row order/grouping contract (destructive last among actions, Cancel separated) even if the exact color differs — the grouping is the part that must not regress."},{"id":"bottom-sheet-detents","name":"Bottom Sheet Detents","pattern_group":"overlays","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.xl"],"code_asset":"examples/ui-vocabulary-site/src/components/bottom-sheet-detents.tsx","component_refs":[],"term_refs":["mobile-bottom-sheet","standard-bottom-sheet","modal-bottom-sheet"],"source_refs":["material-m3-components"],"last_verified":"2026-07-12","source_path":"recipes/overlays/bottom-sheet-detents.md","body":"## Intent\n\nA bottom sheet that carries two collapsed/expanded height presets (\"detents\") toggled from a drag handle, and can run in either of two structural modes: `standard-bottom-sheet` (no scrim — the screen behind stays visible and interactive) or `modal-bottom-sheet` (a scrim blocks the background until the sheet is dismissed). Both existing vocabulary terms describe one of these modes each; this recipe is the single component contract that implements both, selected via a `variant` prop, so a product doesn't need two separate implementations for what is the same detent-cycling shell.\n\n## Anatomy\n\n- Root: `absolute inset-0` layer scoped to the screen's viewport (not the whole page), so the sheet stays inside the device frame/screen bounds rather than portaling to the document body.\n- Scrim (`modal` variant only): full-bleed tappable layer behind the sheet; tapping it dismisses the sheet. Omitted entirely in `standard` variant.\n- Sheet surface: `rounded-t-2xl` panel pinned to the bottom, height driven by the current detent.\n- Drag handle: a small pill button at the top of the sheet. Doubles as the visual affordance and the tap target that cycles detents — required because not every dismiss/resize input is a drag gesture.\n- Content: scrollable body below the handle.\n\n## States\n\n- **Closed**: sheet not rendered (`open === false`).\n- **Open, collapsed**: `detent === \"collapsed\"`, sheet occupies a smaller height (45% of viewport in the code asset).\n- **Open, expanded**: `detent === \"expanded\"`, sheet occupies a larger height (85%).\n- **Standard variant**: no scrim rendered; background content stays visually present and (in a real screen) interactive.\n- **Modal variant**: scrim rendered (`bg-black/50`), `aria-modal=\"true\"` on the sheet's `role=\"dialog\"`, background interaction is blocked until dismissed.\n\n## Variants\n\n- `variant=\"standard\"` — non-blocking, coexists with the current screen (e.g. a map filter panel).\n- `variant=\"modal\"` — blocking, used for a decision the user must resolve before continuing (e.g. a short confirmation or option picker).\n- On desktop/wide viewports, the vocabulary recommends a side sheet instead of a bottom sheet — this recipe is mobile-viewport-scoped and does not implement that fallback itself.\n\n## Code\n\n```tsx\nfunction cycleDetent() {\n onDetentChange(detent === \"collapsed\" ? \"expanded\" : \"collapsed\")\n}\n\nreturn (\n <div className=\"absolute inset-0 z-10\">\n {variant === \"modal\" ? (\n <button\n type=\"button\"\n aria-label=\"Dismiss sheet\"\n onClick={onClose}\n className=\"absolute inset-0 bg-black/50\"\n />\n ) : null}\n\n <div\n role=\"dialog\"\n aria-modal={variant === \"modal\"}\n className={cn(\n \"absolute inset-x-0 bottom-0 flex flex-col rounded-t-2xl border-t bg-background shadow-xl transition-[height] duration-200\",\n detent === \"expanded\" ? \"h-[85%]\" : \"h-[45%]\"\n )}\n >\n <button type=\"button\" onClick={cycleDetent} aria-label={detent === \"collapsed\" ? \"Expand sheet\" : \"Collapse sheet\"}>\n <span aria-hidden=\"true\" className=\"h-1.5 w-10 rounded-full bg-muted-foreground/40\" />\n </button>\n {/* content */}\n </div>\n </div>\n)\n```\n\nFull component, including the standard/modal open triggers and the colocated `BottomSheetDetentsDemo` (rendered inside `DeviceFrame`), lives in `code_asset`.\n\n## Checks\n\n- Drag handle has an `aria-label` that reflects the next action (\"Expand sheet\" / \"Collapse sheet\"), not just a decorative pill.\n- At least one non-drag way to change detent exists (here: tapping the handle) — do not ship a version where the only way to expand/collapse is a physical drag gesture.\n- `modal` variant sets `aria-modal=\"true\"` and provides a way to dismiss without a drag (scrim tap here; also wire `Escape` if adapting to a real dialog primitive).\n- `standard` variant must not render a scrim — verify background rows remain visually present and are not obscured.\n- Detent height transition respects `motion-reduce` if adapted to use CSS transitions beyond the current `transition-[height]`.\n\n## Anti-patterns\n\n- **Modal without a non-scrim-tap dismiss path**: if adapting this into a real dialog/portal implementation, don't rely on scrim-tap alone — keyboard/assistive-tech users need `Escape` or an explicit close control too.\n- **Standard variant with a scrim**: adding a scrim to the `standard` variant turns it into a modal sheet in disguise and blocks background interaction the term explicitly says should stay available.\n- **Drag-only detent control**: implementing detent cycling purely via `pointermove`/drag tracking with no tap/keyboard alternative strands VoiceOver and switch-control users — the handle must remain a real, labeled button.\n- **Full-page portal escaping the screen bounds**: portaling the sheet to `document.body` (as a generic dialog primitive would) breaks the \"sheet is scoped to this screen\" contract used in a device-framed mobile context; keep it `absolute` within the screen's own relatively-positioned container.\n\n## Agent notes\n\n- `prompt_phrases`: \"필터를 누르면 bottom sheet가 올라오게 해줘\", \"배경을 어둡게 깔고 아래에서 modal bottom sheet가 올라오게 해줘\", \"드래그 핸들로 시트 높이를 늘렸다 줄였다 하게 해줘\"\n- If the product needs the screen behind to stay interactive (e.g. a map with a filter/detail panel), use `variant=\"standard\"`. If it needs to force a decision before continuing, use `variant=\"modal\"`.\n- On a wide/desktop layout, prefer a side sheet over stretching this component — this recipe does not include that responsive fallback.\n- Fallback: if a proper drag/pointer-tracking library is unavailable, tap-to-cycle (as implemented) is an acceptable baseline as long as it remains the case that at least one non-drag path exists — do not regress to drag-only."},{"id":"glass-panel","name":"Glass Panel","pattern_group":"overlays","kind":"visual-treatment","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.border.default","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/glass-panel.tsx","component_refs":["mesh-gradient-surface","popover"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/overlays/glass-panel.md","body":"## Intent\n\nA glass panel keeps foreground content legible over a busy backdrop by blurring what is behind it (`backdrop-filter`), instead of covering it with an opaque fill. Tier ① — the blur is GPU-composited from a CSS declaration, no JS. Use for navigation bars over scrolling content, overlay cards on rich backgrounds, and floating toolbars. It is a *treatment* of a surface, not a component contract: the surface underneath keeps its own semantics.\n\n## Anatomy\n\n- Translucent token-derived fill (`bg-background/60`-class opacity on the surface token) — the opacity is the legibility knob.\n- `backdrop-blur` of medium radius; heavier blur reads as frosted, lighter as tinted.\n- Hairline border at reduced opacity so the panel keeps an edge in both themes.\n- Optional subtle shadow for lift; never heavy drop-shadows (signature dislike: sloppy CSS defaults).\n\n## States\n\n- Over busy backdrop (primary case): blur carries legibility.\n- Over flat backdrop: panel degrades gracefully to a translucent card — acceptable, but glass is unnecessary there.\n- Dark mode: fill derives from the dark surface token; verify the border remains visible against dark backdrops.\n- No-support fallback: browsers without `backdrop-filter` render the translucent fill only — raise fill opacity via `@supports not (backdrop-filter: blur(1px))` when content must stay readable.\n\n## Variants\n\n- Sticky navigation bar over scrolling page content.\n- Floating card over media/gradient hero.\n- Toolbar/HUD over canvas or map surfaces.\n\n## Code\n\n```tsx\nexport function GlassPanel({ children, className }: GlassPanelProps) {\n return (\n <div className={`rounded-lg border border-border/60 bg-background/60 shadow-sm backdrop-blur-md ${className ?? \"\"}`}>\n {children}\n </div>\n )\n}\n```\n\n## Checks\n\n- Text on the panel passes WCAG contrast against the *worst-case* backdrop, not the average one.\n- Fill and border derive from surface/border tokens with opacity — no literal white/black fills.\n- `@supports` fallback path defined when the panel carries essential content.\n- Blur radius is consistent across all glass surfaces in one product (one treatment, not per-component improvisation).\n\n## Anti-patterns\n\n- **Glass as decoration everywhere**: applying blur to surfaces that sit on flat backgrounds — cost without function.\n- **Contrast by text color**: fixing legibility by changing text color per backdrop instead of adjusting fill opacity — breaks token discipline and dark mode.\n- **Opaque imitation**: a solid light-gray fill pretending to be glass — if nothing shows through, use a normal card token.\n- **Blur stacking**: nesting glass panels compounds GPU cost and visual mud — one glass layer per stacking context.\n\n## Agent notes\n\n- prompt_phrases: \"token-derived glass panel with backdrop-blur, hairline border, and @supports opacity fallback\", \"sticky glass navigation bar that stays legible over scrolling content\"\n- fallbacks: no `backdrop-filter` support → raise fill opacity to solid-enough via `@supports not`; content-critical panels may switch to the plain card treatment.\n- canonical guidance: `knowledge/expressive-stack.md` tier ① decision row \"유리 질감(글래스모피즘)·배경 블러\"; contrast rules in `docs/design-system/principles.md`."},{"id":"popover","name":"Popover","pattern_group":"overlays","kind":"component","status":"draft","surface_refs":["websites","saas-dashboards","mobile-apps"],"tokens_used":["color.semantic.surface.overlay","color.semantic.border.default","color.semantic.border.focus","color.semantic.text.default","color.semantic.text.muted","dimension.radius.md","dimension.space.2"],"code_asset":"examples/ui-vocabulary-site/src/components/ui/popover.tsx","component_refs":[],"term_refs":["popover","popover-form","dropdown-menu","tooltip","dialog"],"source_refs":["radix-ui-primitives"],"last_verified":"2026-07-07","source_path":"recipes/overlays/popover.md","body":"## Intent\n\nA small floating panel anchored to a trigger, used for compact interactive content (a form field, a date picker, a short settings block) that does not need the modal weight of a `dialog` or the pure-text weight of a `tooltip`.\n\nThis recipe is a **composition atom**, not a standalone page pattern. It is consumed by higher-order recipes — for example `topbar-command-search` uses this popover as its floating suggestion surface via `PopoverAnchor` + `PopoverContent` around an input. When another recipe lists `component_refs: [popover, ...]`, it means \"assemble your overlay behavior from this recipe\" rather than duplicating open/close/dismiss logic.\n\n## Anatomy\n\n- **Trigger** — the element the popover is anchored to (`PopoverTrigger` or `PopoverAnchor` when the visual anchor differs from the interactive control, e.g. an input field).\n- **Floating panel** — `PopoverContent`, portaled, positioned relative to the trigger with `align`/`sideOffset`.\n- **Content** — free-form (form fields, list, `PopoverHeader`/`PopoverTitle`/`PopoverDescription` for structured copy).\n- **Optional arrow** — not implemented in the current code asset; add via Radix `Popover.Arrow` only if the design calls for it.\n\n## States\n\n- **closed** — panel unmounted (portal not rendered); trigger visible and focusable.\n- **open** — panel mounted, positioned, `data-state=\"open\"` drives the fade/zoom-in animation classes.\n- **focus-within** — focus lives inside the panel (e.g. a form field); the panel must not close while focus moves between its own children.\n- **dismiss** — triggered by `Escape`, an outside pointer/click, or explicit close action. On dismiss, focus must return to the trigger (Radix handles this by default — do not override `onCloseAutoFocus` without re-implementing the focus return).\n\n## Variants\n\n- **Anchor-decoupled trigger** — `PopoverAnchor` wraps a different element than the interactive control that opens the popover (see `topbar-search.tsx`: the anchor is the search input container, not a dedicated button).\n- **Structured content** — `PopoverHeader` + `PopoverTitle` + `PopoverDescription` for a short label/description block instead of raw children.\n- **Controlled open state** — pass `open`/`onOpenChange` to drive visibility from external state (e.g. only open when there are suggestions), rather than relying on uncontrolled trigger clicks.\n\n## Code\n\n```tsx\nfunction PopoverContent({\n className,\n align = \"center\",\n sideOffset = 4,\n ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n return (\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n data-slot=\"popover-content\"\n align={align}\n sideOffset={sideOffset}\n className={cn(\n \"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95\",\n className\n )}\n {...props}\n />\n </PopoverPrimitive.Portal>\n )\n}\n\nfunction PopoverAnchor({\n ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />\n}\n```\n\n`bg-popover` / `text-popover-foreground` / `border` map to `color.semantic.surface.overlay`, `color.semantic.text.default`, `color.semantic.border.default` at the Tailwind theme layer — do not hardcode hex when composing new popover content.\n\n## Checks\n\n- Panel is portaled (not clipped by an `overflow-hidden` ancestor).\n- `Escape` and outside pointerdown both close the panel; verify with keyboard-only navigation.\n- Closing returns focus to the trigger element (tab order resumes where the user left off).\n- Panel repositions/flips when the trigger is near a viewport edge (Radix `Popper` collision handling — do not disable it).\n- Panel does not trap focus like a modal; background content stays operable unless the composing recipe explicitly needs modal behavior (in which case use `dialog`, not `popover`).\n\n## Anti-patterns\n\n- **No focus return on dismiss** — overriding `onCloseAutoFocus`/`onOpenAutoFocus` (as `topbar-search.tsx` does for a specific UX reason) without re-implementing focus return elsewhere leaves keyboard users stranded. Only suppress autofocus when the surrounding component manages focus itself.\n- **Scroll-lock overreach** — locking `document.body` scroll for a popover (copying `dialog`/`drawer` behavior) is unnecessary weight; a popover is non-modal and background content should stay scrollable and interactive.\n- **Unhandled viewport overflow** — hardcoding `align`/`side` without letting Radix's collision detection flip the panel causes it to render off-screen on small viewports or near edges; do not disable collision padding.\n- **Using popover for content that needs a dialog** — stuffing a multi-step form or a destructive confirmation into a popover because \"it's already wired up\" ignores the `anti_use` guidance in `terms.yml` (`popover`): long or high-stakes content belongs in `dialog`.\n\n## Agent notes\n\n- `prompt_phrases`: \"버튼을 누르면 작은 popover가 열리게 해줘\", \"날짜 선택기를 팝오버로 띄워줘\", \"필터 저장 버튼을 누르면 이름을 입력하는 popover form을 열어줘\".\n- If the request is a simple command list, prefer `dropdown-menu` over `popover` (see `terms.yml` `popover` → `related: dropdown-menu`).\n- If the request is hover-only, short, non-interactive text, prefer `tooltip`.\n- If the request needs a compact input/submit flow inside the floating panel, compose `popover` + a form (see `popover-form` term) rather than inventing a new overlay primitive.\n- Fallback when Radix is unavailable: implement with native `popover` attribute / `anchor-name` CSS only if the target browser matrix supports it; otherwise keep Radix as the default dependency."}]
|
|
1
|
+
[{"id":"canvas-particle-field","name":"Canvas Particle Field","pattern_group":"application-ui","kind":"visual-effect","status":"draft","surface_refs":["websites"],"tokens_used":["color.semantic.action.primary","color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/canvas-particle-field.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/canvas-particle-field.md","body":"## Intent\n\nA canvas particle field draws dozens-to-hundreds of drifting dots into a 2D pixel buffer as ambient backdrop texture. Tier ③ in `knowledge/expressive-stack.md`: at this element count, DOM nodes pay layout/paint cost per node while the canvas pays none. An experimental/showcase touch (signature principle 5) — hero and event surfaces on request, never behind forms or data UI.\n\n## Anatomy\n\n- One `<canvas>`, `aria-hidden` + `pointer-events: none` — pure decoration, invisible to assistive tech and input.\n- Dot color read from the element's computed token variables (`--primary`) at mount — no hardcoded colors in the draw loop.\n- Device-pixel-ratio scaling so dots stay crisp on high-DPI screens.\n- A component-owned rAF loop, cancelled on unmount; particle positions wrap at edges.\n- Deterministic pseudo-random seeding (index arithmetic) so renders are stable across mounts.\n\n## States\n\n- Animating (default when motion allowed): slow drift, subtle alpha variation.\n- Reduced motion: **a single static frame is drawn and the loop never starts** — canvas loops are JS-driven and are not auto-suppressed by the media query, so the branch is explicit.\n- Unmounted: rAF cancelled — no leaked frame callbacks.\n- Foreground content: always rendered above the canvas with intact contrast.\n\n## Variants\n\n- Ambient hero backdrop (this demo).\n- Cursor-influenced field: pointer position biases drift — still tier ③, adds a throttled pointermove listener.\n- Density variants: fewer/larger dots for texture, more/smaller for dust.\n- Margin-confined field: particles occupy only the whitespace beside the content axis (e.g. left margin), leaving the reading column fully undecorated — ambient energy without competing with content (taste ledger T-12, Antigravity measured).\n\n## Code\n\n```tsx\nif (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n draw() // one static frame, no loop\n return\n}\nlet frame = requestAnimationFrame(tick)\nreturn () => cancelAnimationFrame(frame)\n```\n\n## Checks\n\n- rAF loop cancelled on unmount (cleanup returns `cancelAnimationFrame`).\n- Reduced-motion branch draws a static frame instead of animating.\n- Canvas never intercepts pointer events or appears in the accessibility tree.\n- Dot color derives from token custom properties — re-themes with dark mode on remount.\n- Text over the field passes contrast; the field's alpha stays low (≤0.75 peak).\n\n## Anti-patterns\n\n- **DOM particles**: rendering each dot as a div — the exact per-node cost this tier exists to avoid.\n- **Unkilled loop**: missing `cancelAnimationFrame` cleanup keeps the loop alive after unmount, wasting CPU on an invisible canvas.\n- **Media-query-only motion gating**: assuming `prefers-reduced-motion` CSS will stop a JS loop — it will not; the branch must be in code.\n- **Interactive canvas**: putting the field above clickable UI without `pointer-events: none`.\n\n## Agent notes\n\n- prompt_phrases: \"aria-hidden canvas particle backdrop with token-derived dot color, rAF cleanup, and explicit reduced-motion static frame\"\n- fallbacks: if canvas is unavailable, render nothing — the surface below is complete without it.\n- canonical guidance: `knowledge/expressive-stack.md` decision row \"수백 개 파티클·커서 트레일\"(③) + 판정 절차 3(③④ 티어 reduced-motion 의무)."},{"id":"chat-conversation-panel","name":"Chat Conversation Panel","pattern_group":"application-ui","kind":"block","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.text.on-accent","color.semantic.action.primary","color.semantic.action.destructive","color.semantic.border.default","color.semantic.border.input","color.semantic.border.focus","dimension.space.2","dimension.space.4","dimension.radius.md","dimension.radius.lg","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/chat-conversation-panel.tsx","component_refs":["button","actionable-toast"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/chat-conversation-panel.md","body":"## Intent\n\nA chat conversation panel turns a request-response exchange with a person or an AI assistant into one continuously readable thread: a bounded, self-scrolling message log above a single always-visible input bar. It exists for turn-taking conversation — not for comment threads (no reply nesting), notification feeds (no input), or one-shot form submission.\n\nDemand source: dogfooding ledger DF-1/DF-2 — two consecutive real chat-UI tasks found no recipe covering this surface.\n\n## Anatomy\n\n- Bounded thread container that owns its own vertical scroll and pins to the newest message; the page never scrolls to follow the conversation.\n- Message bubbles aligned by author (user trailing, assistant leading), each with a visually hidden author prefix for screen readers.\n- A polite live region (`role=\"log\"`, `aria-live=\"polite\"`) around the thread so new turns are announced without stealing focus.\n- Waiting indicator rendered *as a thread entry* in the assistant position — the reply appears where the user is already looking.\n- Error row rendered as a thread entry with an icon + text (never color alone) and a single Retry action; the failed draft is never discarded.\n- Input bar: multiline-capable field + send control. Enter sends, Shift+Enter breaks a line; send stays disabled while the draft is empty or a reply is pending.\n\n## States\n\n- Empty: first-use guidance (\"no messages yet\" + what to ask) — distinct from an error and from a cleared conversation.\n- Waiting: typing indicator with `motion-safe` animation, a `motion-reduce` static fallback, and an `sr-only` \"Assistant is typing\" mirror.\n- Error: tinted `role=\"alert\"` row with icon, plain-language explanation, and Retry re-sending the last user message.\n- Focus: visible `focus-visible` ring on the input and send button; keyboard path covers compose → send → retry.\n- Scroll: thread auto-follows new messages; long messages wrap inside the bubble (`break-keep` so CJK text never splits mid-word).\n\n## Variants\n\n- Assistant chat (AI product): waiting/error states are load-bearing — model latency and failures are routine, not exceptional.\n- Elevated input (first-run / hero chat): the input bar is the product's center of gravity — it may claim the lightest surface and the most distinctive radius on screen (e.g. pure white on a warm off-white base), while everything else stays flat. Reserve this emphasis for the input alone (taste ledger T-7).\n- Human-to-human messaging: waiting indicator maps to the other party typing; delivery/read receipts attach to bubbles.\n- Embedded support widget: same anatomy inside a popover/sheet; the thread height bounds to the overlay, never the page.\n\n## Code\n\n```tsx\n<div ref={threadRef} aria-label=\"Conversation\" className=\"flex-1 space-y-3 overflow-y-auto p-4\" role=\"log\" aria-live=\"polite\">\n {messages.map((message) => (\n <div key={message.id} className={message.role === \"user\" ? \"flex justify-end\" : \"flex justify-start\"}>\n <p className={message.role === \"user\"\n ? \"max-w-[85%] break-keep rounded-lg rounded-br-sm bg-primary px-3 py-2 text-sm text-primary-foreground\"\n : \"max-w-[85%] break-keep rounded-lg rounded-bl-sm bg-muted px-3 py-2 text-sm text-foreground\"}>\n <span className=\"sr-only\">{message.role === \"user\" ? \"You: \" : \"Assistant: \"}</span>\n {message.text}\n </p>\n </div>\n ))}\n {status === \"error\" ? (\n <div className=\"flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2\" role=\"alert\">\n <AlertCircleIcon aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0 text-destructive\" />\n <div className=\"min-w-0 flex-1\">\n <p className=\"break-keep text-sm text-foreground\">The assistant could not respond. Your message was not lost.</p>\n <Button variant=\"link\" onClick={onRetry}>Retry</Button>\n </div>\n </div>\n ) : null}\n</div>\n```\n\n## Checks\n\n- Thread is the only scroll container that grows; the input bar stays visible at every viewport height (`h-[…]` or flex column with `flex-1` thread).\n- Waiting and error are rendered in the thread flow, in the assistant position, so state changes appear where attention already is.\n- Error text names what happened and what is preserved; Retry is the single action and re-sends the exact failed message.\n- Send is disabled for empty drafts and while waiting — no double-submit path.\n- New messages are announced via the polite log; focus never jumps when a reply arrives.\n- CJK copy wraps with `word-break: keep-all` (`break-keep`) inside bubbles.\n\n## Anti-patterns\n\n- **Silent failure**: request fails and nothing appears in the thread — the user cannot tell waiting from broken. Render an explicit error entry with Retry.\n- **Happy-path-only thread**: no waiting indicator, so slow replies look like ignored input and users resend duplicates.\n- **Page-level scroll**: the whole page scrolls to follow messages, detaching the input bar and losing surrounding context.\n- **Color-only role separation**: user/assistant bubbles differ only by hue — alignment + shape (asymmetric corner) + sr-only prefix must carry the distinction too.\n\n## Agent notes\n\n- prompt_phrases: \"bounded chat thread with role=log polite live region, typing indicator with reduced-motion fallback, and an in-thread error row with a single Retry\", \"chat input where Enter sends, Shift+Enter breaks, and send disables while waiting\"\n- fallbacks: for a read-only transcript use a plain list (no input, no live region); for threaded discussion with replies use a comment-thread structure, not this panel.\n- canonical guidance: `docs/design-system/principles.md` principles 5 and 7; `docs/design-system/anti-patterns.md` clusters 3, 4, 11."},{"id":"lazy-three-object-scene","name":"Lazy three.js Object Scene","pattern_group":"application-ui","kind":"visual-effect","status":"draft","surface_refs":["websites","commerce"],"tokens_used":["color.semantic.action.primary","color.semantic.surface.muted","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/lazy-three-object-scene.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/lazy-three-object-scene.md","body":"## Intent\n\nA real 3D object (lighting, depth, materials) rendered with three.js via React Three Fiber, loaded strictly behind a dynamic import. Tier ④ in `knowledge/expressive-stack.md` — CSS `perspective` can fake a rotated plane but cannot do lit, shaded geometry. The recipe's load-bearing part is the delivery contract: three.js is a ~155KB gzip fixed cost, so **the lazy boundary is not an optimization, it is the pattern** (judgment rule 4: tier ④ requires lazy-load).\n\n## Anatomy\n\n- Two modules: a light wrapper (this recipe's code_asset) and a heavy implementation module that is the only place importing `three`/`@react-three/fiber`, reached exclusively via `React.lazy(() => import(...))`.\n- `<Suspense>` skeleton for the chunk-loading state.\n- An error boundary around the scene: WebGL/context failure renders a static fallback, never a broken canvas.\n- Material color read from the document's token custom properties at mount.\n- Auto-rotation gated by `prefers-reduced-motion` (checked in JS — render loops are not auto-suppressed by the media query).\n\n## States\n\n- Chunk loading: Suspense skeleton (visible on first visit; instant afterward from cache).\n- Rendering: lit object, slow auto-rotation.\n- Reduced motion: static object, no rotation — scene still renders.\n- WebGL unavailable: boundary fallback with plain text/imagery — content never depends on the scene.\n\n## Variants\n\n- Product/object viewer (add pointer-drag orbit).\n- Brand hero object (this demo).\n- Shader-material variant — same lazy contract, custom GLSL material.\n\n## Code\n\n```tsx\nconst ThreeObjectSceneImpl = lazy(() => import(\"@/components/three-object-scene-impl\"))\n\n<SceneBoundary fallback={staticFallback}>\n <Suspense fallback={<Skeleton />}>\n <ThreeObjectSceneImpl />\n </Suspense>\n</SceneBoundary>\n```\n\n## Checks\n\n- The build emits three.js as a separate chunk; the main bundle's size does not grow beyond noise (measure before/after).\n- No module outside the lazy implementation imports `three` or `@react-three/fiber` — one static import anywhere defeats the split.\n- Reduced motion stops auto-rotation in the render loop (JS check, not CSS).\n- WebGL failure path shows the static fallback (error boundary, not a blank canvas).\n- Material colors derive from token variables — no hex in scene code.\n\n## Anti-patterns\n\n- **Static import creep**: importing three.js types/helpers from the wrapper or shared modules — silently drags the whole library into the main chunk.\n- **3D as page furniture**: mounting scenes in headers/nav rendered on every page — GPU and battery cost for decoration (signature principle 5: manual opt-in).\n- **Ignoring context loss**: no error boundary means a driver hiccup leaves a dead black canvas.\n- **Unconditional auto-rotate**: continuous camera/object motion without a reduced-motion gate is a WCAG 2.3.3 risk (vestibular triggers).\n\n## Agent notes\n\n- prompt_phrases: \"React.lazy three.js scene with Suspense skeleton, WebGL error boundary, token-derived material, reduced-motion rotation gate\"\n- fallbacks: keep a DOM/text equivalent of any information the scene carries; the scene itself must always be droppable.\n- canonical guidance: `knowledge/expressive-stack.md` tier ④ rows + 판정 절차 3·4; bundle/lazy sources in `research/2026-07-17-expressive-stack-genealogy.md` (React 통합·번들 전략).\n\n\n## Related media contract\n\n- 슬라이드 매체의 3D 장면은 이 recipe 가 아니라 presentation-slides-yusung 스킬의 `three-scene` 레이아웃 계약을 따른다 (OrbitControls 필수·중앙 정렬·PDF export 정적 폴백). 원본: `~/projects/custom-skills/promoted/presentation-slides-yusung/references/interactive.md` §three-scene. 매체가 다르면 게이트도 다르다 — 웹은 lazy 경계·reduced-motion·WebGL 폴백, 슬라이드는 export 폴백이 하드 계약. (상호 링크 VI8 2026-07-28)"},{"id":"showcase-card","name":"Showcase Card","pattern_group":"application-ui","kind":"block","status":"draft","surface_refs":["websites","saas-dashboards"],"tokens_used":["color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.border.default","color.semantic.border.accent","color.semantic.text.default","color.semantic.text.muted","dimension.radius.md","dimension.space.4","dimension.space.2","typography.scale.xl","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/showcase-card.tsx","component_refs":[],"term_refs":["card","hero"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-07","source_path":"recipes/application-ui/showcase-card.md","body":"## Intent\n\nA showcase card is a self-contained shell that proves an interaction capability rather than describing it: a header (icon, title, one-line copy) sits above a bounded demo region that runs a live, interactive product-interface behavior. The demo is the content — the header only orients the reader before they touch it. This is the recurring shell behind the Askewly Design landing atlas (`docs/plans/2026-07-05-showcase-atlas-upgrade.md`), used once per capability (physics interaction, cursor field, agent-ready canvas, etc.).\n\n## Anatomy\n\n```\n<article> -- outer card frame, rounded, bordered, elevated on hover\n <header row>\n <icon> -- line-art glyph, fixed box\n <title + copy> -- h3 + one-line supporting sentence\n <demo region> -- bordered sub-surface, fixed/min height, overflow-hidden\n <interactive canvas/controls> -- the actual capability (pointer, drag, physics, toggle, etc.)\n```\n\nThe demo region is visually subordinate (muted surface, inset border) so the header always reads as the \"label\" and the demo as the \"proof.\"\n\n## States\n\n- **idle**: card at rest, demo region shows a settled/neutral frame (not a blank one) so the capability is legible even before interaction.\n- **hover**: outer card lifts slightly and border shifts to the accent tone; signals the card is inspectable, independent of whether the inner demo also has its own hover behavior.\n- **demo active (pointer entered / interaction started)**: pointer, drag, or toggle input inside the demo region changes visible state (position, filter, layout, selection) in real time. This state is the actual deliverable of the recipe — a card without it is not a showcase card, it is a static tile.\n- **reduced-motion fallback**: when `prefers-reduced-motion: reduce` is set, continuous/looping motion (auto-playing progress, physics drift, shader loops) must resolve to a single stable frame that still shows the relationship the motion demonstrates (e.g., final layout state instead of a mid-animation frame). Discrete interaction (pointer-driven force, drag) may keep responding to direct input even under reduced motion, since it is user-initiated, not autoplaying.\n\n## Variants\n\n- **pointer/force-field demo** — continuous physics or field response to cursor position (e.g. `MatterPhysicsDemo`), no discrete controls needed.\n- **stateful control demo** — explicit buttons/toggles drive state (e.g. material mode, treatment filter, mobile nav step) instead of ambient pointer tracking.\n- **wide/span variant** — card spans multiple grid columns for demos that need horizontal room (image treatment strip); anatomy is unchanged, only the outer grid span differs.\n\n## Code\n\n```tsx\nfunction AtlasCard({ item }: { item: (typeof atlasItems)[number] }) {\n return (\n <article\n className={cn(\n \"group flex h-full min-w-0 flex-col overflow-hidden rounded-md border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-askewly-lavender hover:shadow-[0_18px_50px_rgba(15,23,42,0.08)]\",\n item.layout,\n )}\n >\n <div className={cn(\"grid gap-5 p-6 sm:grid-cols-[5.75rem_minmax(0,1fr)]\")}>\n <div className=\"flex items-start justify-center sm:justify-start\">\n <LineArtIcon id={item.id} />\n </div>\n <div className=\"min-w-0\">\n <h3 className=\"text-2xl font-semibold tracking-normal text-slate-950\">{item.title}</h3>\n <p className=\"mt-2 text-sm leading-6 text-slate-600\">{item.copy}</p>\n </div>\n </div>\n <div className=\"flex flex-1 flex-col border-t border-slate-200 bg-slate-50 p-5\">\n <AtlasDemo id={item.id} />\n </div>\n </article>\n )\n}\n```\n\n`bg-white` / `bg-slate-50` / `border-slate-200` map to `color.semantic.surface.raised` / `color.semantic.surface.muted` / `color.semantic.border.default`; `hover:border-askewly-lavender` maps to `color.semantic.border.accent`. Full demo implementation lives in `code_asset` (`AtlasDemo`, `MatterPhysicsDemo`, and sibling demo functions).\n\n## Checks\n\n- Header renders icon + title + one-line copy without the demo region clipping or pushing the header off-screen at the card's narrowest supported width.\n- Demo region responds to its documented input (pointer move, drag, click/toggle) with a visible state change — verified interactively (Chrome smoke), not just build/lint.\n- With `prefers-reduced-motion: reduce` simulated, any autoplaying/looping element inside the demo region settles to one stable, legible frame instead of freezing mid-motion or continuing to animate.\n- Card hover state (lift + border accent) is visible independent of whether the pointer is inside the demo sub-region.\n- `npm run build` and `npm run lint` pass for `examples/ui-vocabulary-site`.\n\n## Anti-patterns\n\n- **Isolated toy**: a demo that animates but maps to no real product-interface capability (no analogue in websites/SaaS/dashboards/commerce) — the showcase plan's skeptic perspective flags this explicitly; every card must justify itself as a reusable interaction, not decoration.\n- **No stable frame**: motion that never has a coherent \"paused\" or settled composition — if a screenshot at any random instant looks broken or mid-collision, the demo fails the idle/reduced-motion requirement.\n- **Perpetual motion with no exit**: a loop that runs forever with no way to reach a resting state (no completion, no pause) reads as a screensaver, not a product state machine; reduced-motion users have no valid fallback frame to land on.\n- **Card nested in card**: stacking a second bordered/elevated \"card\" chrome inside the demo region duplicates the outer article's framing and confuses which surface is the actual interactive content; the demo region itself is the inner boundary — it should not re-introduce its own card shell.\n\n## Agent notes\n\n- `prompt_phrases`: \"add a showcase card for <capability>\", \"wrap this interactive demo in the showcase card shell\", \"give this demo an idle/hover/reduced-motion state\".\n- `fallbacks`: if the capability has no natural continuous motion (e.g. a discrete toggle demo), the \"demo active\" state can be satisfied by state change on click/toggle alone — a reduced-motion fallback is only required when the demo has autoplaying or looping motion in the first place.\n- When implementing a new card, start from `AtlasCard` (outer shell) and add a new branch to `AtlasDemo` (or a dedicated component like `MatterPhysicsDemo`) rather than inventing a new outer card wrapper."},{"id":"spring-drag-snap-card","name":"Spring Drag Snap-back Card","pattern_group":"application-ui","kind":"motion-pattern","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/spring-drag-snap-card.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/spring-drag-snap-card.md","body":"## Intent\n\nA draggable element that springs back to origin on release, demonstrating the property that makes springs the production default for drag/reorder/dismiss interactions: **interruptibility**. Grab the card mid-return and the spring re-targets from its current velocity — a duration/easing tween would snap or restart. Tier ② — physics integration runs in JS each frame; rendering stays on CSS transform.\n\n## Anatomy\n\n- A bounded field (the drag constraint) and a draggable card inside it.\n- Spring-driven return to origin on release (`stiffness`/`damping` as the feel knobs — no durations).\n- Slight scale-up while dragging as the \"held\" affordance; `touch-none select-none` so the gesture owns the pointer.\n- Reduced-motion branch: return becomes effectively instant (very stiff spring), gesture still works.\n\n## States\n\n- Rest / Held (scale cue, grabbing cursor) / Released mid-field (spring return) / Re-grabbed mid-return (spring re-targets, no snap).\n- Reduced motion: drag works, return is immediate.\n\n## Variants\n\n- Snap-back demo (this recipe) — the teaching form.\n- Dismiss threshold: past a distance, release triggers an action instead of return (pair with an explicit non-gesture alternative — anti-patterns cluster 3).\n- Reorder: spring settles items into their new slots.\n\n## Code\n\n```tsx\n<motion.div\n drag\n dragConstraints={fieldRef}\n dragElastic={0.2}\n dragSnapToOrigin\n dragTransition={{ bounceStiffness: 380, bounceDamping: 24 }}\n whileDrag={{ scale: 1.04 }}\n/>\n```\n\n## Checks\n\n- Return is interruptible: re-grabbing mid-flight continues from current position/velocity without a jump.\n- Springs are tuned with stiffness/damping, not durations — one shared tuning across a product's drag surfaces.\n- When drag triggers real actions (dismiss/reorder), a keyboard/button alternative exists (gesture is never the only path).\n- `prefers-reduced-motion` removes the bounce, not the function.\n\n## Anti-patterns\n\n- **Tween return**: animating the release with `duration`/`ease` — snaps on re-grab and feels dead; the exact failure springs exist to fix.\n- **Unconstrained drag**: no `dragConstraints`, letting the card leave its context and cover unrelated UI.\n- **Gesture-only action**: drag-to-dismiss with no visible alternative control.\n- **Per-instance spring tuning**: every card with different stiffness/damping reads as inconsistency, not physics.\n\n## Agent notes\n\n- prompt_phrases: \"spring snap-back drag with interruptible return and reduced-motion instant fallback\", \"stiffness/damping-tuned drag release, no duration tweens\"\n- fallbacks: on devices without precise pointers the same gesture works via touch; if drag must trigger actions, add explicit buttons for the same actions.\n- canonical guidance: `knowledge/expressive-stack.md` decision row \"스프링·관성 있는 UI 모션\"(②); `docs/design-system/anti-patterns.md` cluster 3 (accessible interaction paths)."},{"id":"staggered-entrance-group","name":"Staggered Entrance Group","pattern_group":"application-ui","kind":"motion-pattern","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.border.default","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/staggered-entrance-group.tsx","component_refs":["button"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/application-ui/staggered-entrance-group.md","body":"## Intent\n\nA staggered entrance offsets the start times of sibling items so a list, card grid, or dashboard reads as one coherent arrival instead of a simultaneous pop. Choreography principle from `knowledge/expressive-stack.md`: tier-agnostic — static lists can do it with CSS `animation-delay`; use tier ② (Motion `staggerChildren`) when item count is dynamic or entrances can be interrupted. Unlike magnetic hover, restrained stagger is a production default, not an experimental touch.\n\n## Anatomy\n\n- A parent that owns the choreography (`staggerChildren` interval) — children never self-schedule.\n- Per-item entrance of `opacity` + small `y` rise, spring-driven.\n- Tight interval (20–60ms) — rhythm, not a parade.\n- Sequencing order: stable structure first (headers), then content, important actions last — the eye lands where the next decision is.\n- Reduced-motion branch: everything immediately visible, no movement, no delays.\n\n## States\n\n- Entering: items cascade with offset starts.\n- Settled: identical to a never-animated layout — entrance leaves no residue.\n- Reduced motion: instant, complete render.\n- Re-run (filter/tab change): re-trigger only when the *set* changes, not on every data refresh.\n\n## Variants\n\n- List/feed entrance (this demo).\n- Card grid arrival (row-major order).\n- Dashboard widgets (structure → data → primary action).\n\n## Code\n\n```tsx\n<motion.ul\n initial={reduceMotion ? false : \"hidden\"}\n animate=\"show\"\n variants={{ show: { transition: { staggerChildren: 0.05 } } }}\n>\n {items.map((label) => (\n <motion.li\n key={label}\n variants={{ hidden: { opacity: 0, y: 10 }, show: { opacity: 1, y: 0 } }}\n transition={{ type: \"spring\", stiffness: 420, damping: 32 }}\n />\n ))}\n</motion.ul>\n```\n\n## Checks\n\n- Interval ≤60ms and total choreography under ~400ms for a typical list — content is never held hostage by its own entrance.\n- Only `opacity`/`transform` animate; layout is stable from first frame (no reflow as items land).\n- `prefers-reduced-motion` renders the complete list instantly.\n- Stagger runs on mount/set-change only — scrolling or data refresh does not replay it.\n\n## Anti-patterns\n\n- **Parade stagger**: 150ms+ intervals turn a 6-item list into a 1-second wait — choreography becomes latency.\n- **Everything staggers**: applying entrance to every section of a page makes the product feel gated; one choreographed group per view.\n- **Content gating**: hiding items until their turn in a way that survives animation failure — the settled state must be reachable even if the animation never runs.\n- **Random delays**: per-item random offsets read as jank, not rhythm — the interval is constant by design.\n\n## Agent notes\n\n- prompt_phrases: \"staggerChildren entrance with 50ms interval, spring items, reduced-motion instant render\", \"structure-first sequencing: headers, content rows, then primary actions\"\n- fallbacks: static known-length lists can use pure CSS `animation-delay` (tier ①) — escalate to Motion only for dynamic counts or interruptible entrances.\n- canonical guidance: `knowledge/expressive-stack.md` decision row \"다요소 등장 안무(stagger)\"; choreography sources in the genealogy research doc (Carbon/Material)."},{"id":"cart-drawer","name":"Cart Drawer","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.overlay","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","color.semantic.action.destructive","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/cart-drawer.tsx","component_refs":["popover","button"],"term_refs":["cart-summary","cart-summary-bar","quantity-stepper"],"source_refs":["tailwind-plus-ecommerce"],"last_verified":"2026-07-12","source_path":"recipes/commerce/cart-drawer.md","body":"## Intent\n\nA cart drawer keeps the browsing context intact while confirming what a user has added. It opens as a slide-over panel, shows each item with enough detail to double-check the choice, and moves the user toward checkout without a full page navigation. It intentionally shows only a subtotal — a full cost breakdown belongs to `checkout-order-summary`.\n\n## Anatomy\n\n- Trigger: a cart icon or button anywhere in the shell that opens the drawer without leaving the current page.\n- Panel: a slide-over surface anchored to one edge (right on desktop/mobile by default), with a scrim behind it.\n- Item rows: thumbnail, title, variant, per-item quantity control, remove action, and line price.\n- Subtotal: sum of visible line items only — no shipping/tax estimate here.\n- Primary action: a checkout button that repeats the subtotal.\n- Secondary action: a continue-shopping affordance that closes the drawer without discarding the cart.\n- Empty state: a message plus continue-shopping action when there are no items.\n\n## States\n\n- Empty: no items, subtotal hidden, only the continue-shopping action shown.\n- Populated: one or more items, subtotal and checkout action visible.\n- Quantity at minimum: decrease control disabled at quantity 1 rather than allowing an accidental removal via stepping to zero.\n- Item removed: row leaves the list and subtotal recalculates immediately.\n- Open/closed: panel slides in from the edge and returns focus to the trigger on close (inherited from the underlying overlay primitive).\n\n## Variants\n\n- Right-edge slide-over (default) for desktop and tablet.\n- Bottom-edge variant for narrow mobile viewports where a right-edge panel would feel cramped.\n- Free/zero-item variant that still renders the empty state rather than an empty list with a $0.00 subtotal.\n\n## Code\n\n```tsx\nexport function CartDrawer({ open, items, currency = \"USD\", trigger, onOpenChange, onQuantityChange, onRemove, onCheckout, onContinueShopping }: CartDrawerProps) {\n const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)\n const isEmpty = items.length === 0\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetTrigger asChild>{trigger}</SheetTrigger>\n <SheetContent aria-describedby={undefined} className=\"w-full sm:max-w-md\">\n <SheetHeader><SheetTitle>Your cart</SheetTitle></SheetHeader>\n {isEmpty ? (\n <EmptyCart onContinueShopping={onContinueShopping} />\n ) : (\n <CartItemList items={items} currency={currency} onQuantityChange={onQuantityChange} onRemove={onRemove} />\n )}\n {!isEmpty ? (\n <SheetFooter className=\"gap-3 border-t\">\n <SubtotalRow subtotal={subtotal} currency={currency} />\n <Button className=\"w-full\" onClick={onCheckout}>Checkout · {formatMoney(subtotal, currency)}</Button>\n <Button className=\"w-full\" variant=\"ghost\" onClick={onContinueShopping}>Continue shopping</Button>\n </SheetFooter>\n ) : null}\n </SheetContent>\n </Sheet>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/cart-drawer.tsx`. Built on the `Sheet` overlay primitive (`examples/ui-vocabulary-site/src/components/ui/sheet.tsx`) the same way `topbar-search.tsx` builds on `popover`.\n\n## Checks\n\n- Trigger opens the drawer without a route change; the underlying grid/page stays mounted.\n- Subtotal only sums visible line items — it never silently includes shipping or tax.\n- Decreasing quantity below 1 is blocked by the control, not by allowing 0 and hiding the row.\n- Removing the last item shows the empty state, not an empty list with a stale subtotal.\n- Closing the drawer (checkout, continue shopping, scrim click, Escape) returns focus to the trigger.\n\n## Anti-patterns\n\n- **Hidden fees in the drawer**: showing a total that already includes shipping/tax invites a mismatch with the real checkout total — keep the drawer to a subtotal only.\n- **Silent quantity-to-zero removal**: letting the stepper go to 0 and disappear the row without an explicit remove action surprises users who meant to just lower the count.\n- **Drawer as full checkout**: cramming address/payment fields into the drawer defeats its purpose as a fast confirm-and-continue surface; route to checkout instead.\n- **Stale subtotal**: updating item quantity or removing an item without recalculating the visible subtotal in the same render.\n\n## Agent notes\n\n- prompt_phrases: \"장바구니 아이콘을 누르면 오른쪽에서 열리는 cart drawer를 만들어줘\", \"수량 조절과 삭제, subtotal, checkout 버튼이 있는 슬라이드형 장바구니를 넣어줘\"\n- fallbacks: if the overlay primitive library lacks a side-anchored sheet, a `dialog` positioned flush to one edge with a manual slide transition is an acceptable substitute — do not fall back to a full-page cart for this recipe's use case.\n- component composition: reuses the same overlay-primitive relationship documented in `recipes/overlays/popover.md`'s Agent notes — assemble from the existing overlay primitive rather than re-implementing open/close/dismiss/focus-return logic."},{"id":"category-product-grid","name":"Category Product Grid","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/category-product-grid.tsx","component_refs":[],"term_refs":["product-card","filter-bar","sort-control"],"source_refs":["tailwind-plus-ecommerce"],"last_verified":"2026-07-12","source_path":"recipes/commerce/category-product-grid.md","body":"## Intent\n\nA category product grid assembles many `product-card` cells into the higher-order list structure a category or search-results page actually needs: a filter/sort bar above the grid, a loading skeleton that matches the final column count, and an empty state that offers a way out instead of a dead end. The card itself stays a separate term — this recipe is the layout and state machine around it.\n\n## Anatomy\n\n- Result count: a line above the grid stating how many items matched (or \"Loading results…\" while fetching).\n- Sort control: a single sort dropdown, right-aligned above the grid.\n- Grid container: a responsive column count (2 on narrow viewports, up to 4 on wide desktop).\n- Repeated `product-card` cells filling the grid.\n- Loading skeleton: placeholder cells matching the final column count and card shape, not a generic spinner.\n- Empty/zero-result state: message plus a reset-filters action, not just \"no results\".\n\n## States\n\n- Loading: skeleton grid shown at the same column count the real grid will use, so the layout doesn't jump on load.\n- Populated: result count, sort control, and full grid of cards.\n- Empty (zero results): no cards, a short explanation, and a reset-filters button — never an empty grid with no path forward.\n- Sort changed: result count and grid update together; scroll position is preserved rather than jumping back to the top.\n\n## Variants\n\n- 3-column grid for denser card content (larger imagery, more per-card metadata).\n- 4-column grid (default) for standard product cards.\n- Infinite-scroll load trigger appended below the grid instead of numbered pagination, for long category lists.\n\n## Code\n\n```tsx\nexport function CategoryProductGrid({ products, loading, sort, onSortChange, onResetFilters, columns = 4 }: CategoryProductGridProps) {\n if (loading) {\n return <SkeletonGrid columns={columns} />\n }\n if (products.length === 0) {\n return (\n <EmptyState message=\"No products match these filters\" onResetFilters={onResetFilters} />\n )\n }\n return (\n <section aria-label=\"Product results\">\n <ResultsHeader count={products.length} sort={sort} onSortChange={onSortChange} />\n <div className={gridColumnsClass(columns)}>\n {products.map((product) => <ProductCardCell key={product.id} product={product} />)}\n </div>\n </section>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/category-product-grid.tsx`. Built on the `select` primitive for sort and `skeleton` primitive for the loading grid.\n\n## Checks\n\n- The loading skeleton renders the same column count as the real grid — no layout shift on load.\n- Zero results always show a reset-filters action, never a silent empty grid.\n- Changing sort does not reset scroll position back to the top of the page.\n- Each grid cell renders a full `product-card` (image, title, price) — this recipe does not redefine the card's own anatomy.\n\n## Anti-patterns\n\n- **Generic spinner instead of skeleton**: a centered spinner during load causes a layout jump once real cards arrive; use a skeleton grid at the target column count instead.\n- **Dead-end empty state**: showing only \"No results\" with no reset action strands the user with the filters that produced zero matches.\n- **Column count drift**: loading skeleton at a different column count than the populated grid, causing a visible reflow.\n- **Card logic duplicated into the grid**: reimplementing product-card's own image/price/rating layout inline instead of composing the existing card.\n\n## Agent notes\n\n- prompt_phrases: \"카테고리 페이지에 필터/정렬 바와 상품 카드 그리드를 만들어줘\", \"결과 없을 때 필터 초기화 버튼이 있는 빈 상태를 넣어줘\"\n- fallbacks: if infinite scroll isn't available, numbered pagination below the grid is an acceptable substitute — keep the same loading-skeleton and empty-state behavior either way.\n- component composition: compose from the existing `product-card` term rather than inlining card markup; this recipe owns the grid/filter/loading/empty shell only."},{"id":"checkout-order-summary","name":"Checkout Order Summary","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites","mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","color.semantic.action.destructive","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.scale.lg","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/checkout-order-summary.tsx","component_refs":["button"],"term_refs":["cart-summary","checkout-step","checkout-progress-header","payment-method-card","order-status"],"source_refs":["tailwind-plus-ecommerce"],"last_verified":"2026-07-10","source_path":"recipes/commerce/checkout-order-summary.md","body":"## Intent\n\nA checkout order summary keeps the exact purchase commitment visible while the user provides delivery and payment details. Products, quantities, discounts, shipping, tax, total, fulfillment method, and final action stay consistent across steps and remain editable before confirmation.\n\n## Anatomy\n\n- Progress context: named checkout step and a clear return path.\n- Item summary: product identity, selected options, quantity, price, and edit/remove controls.\n- Cost breakdown: subtotal, discounts, shipping, tax, and total with currency consistency.\n- Fulfillment and payment facts: selected address/method with explicit change actions.\n- Final action: verb names the consequence and is disabled until required facts are valid.\n\n## States\n\n- Incomplete details with field-level errors and preserved item summary.\n- Recalculation after quantity, discount, shipping, or fulfillment changes.\n- Submitting state that prevents duplicate confirmation while keeping the total visible.\n- Payment failure with retry/change-method actions and no loss of entered details.\n- Confirmed state with order status, reference, and next-step expectations.\n\n## Variants\n\n- Desktop split layout with sticky summary beside the form.\n- Mobile linear flow with a collapsible but always reachable total.\n- Pickup checkout with location/time facts instead of shipping address.\n- Zero-charge or fully discounted order that still explains what will be authorized.\n\n## Code\n\n```tsx\nexport function CheckoutOrderSummary({ order, onConfirm, onEdit }: CheckoutOrderSummaryProps) {\n const total = order.subtotal - order.discount + order.shipping + order.tax\n const canConfirm = order.address != null && order.paymentMethod != null && !order.submitting\n\n return (\n <aside className=\"w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground\" aria-labelledby=\"checkout-summary-title\">\n <div className=\"flex items-center justify-between gap-4\">\n <h2 id=\"checkout-summary-title\" className=\"text-lg font-medium\">\n Order summary\n </h2>\n <Button size=\"sm\" type=\"button\" variant=\"ghost\" onClick={onEdit}>\n Edit cart\n </Button>\n </div>\n\n <ul className=\"mt-6 divide-y\">\n {order.items.map((item) => (\n <li key={item.id} className=\"flex justify-between gap-4 py-4\">\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-medium\">{item.name}</p>\n <p className=\"mt-0.5 text-xs text-muted-foreground\">{item.option} · Qty {item.quantity}</p>\n </div>\n <p className=\"shrink-0 text-sm font-medium tabular-nums\">{formatMoney(item.total, order.currency)}</p>\n </li>\n ))}\n </ul>\n\n <dl className=\"space-y-2 border-t pt-4 text-sm\">\n <div className=\"flex justify-between border-t pt-3 text-base font-medium\">\n <dt>Total</dt>\n <dd className=\"tabular-nums\">{formatMoney(total, order.currency)}</dd>\n </div>\n </dl>\n\n <Button className=\"mt-6 w-full\" disabled={!canConfirm} type=\"button\" onClick={onConfirm}>\n {order.submitting ? \"Placing order\" : `Place order · ${formatMoney(total, order.currency)}`}\n </Button>\n </aside>\n )\n}\n```\n\n## Checks\n\n- Total is derived from displayed line items and every adjustment uses one currency/rounding rule.\n- Edit actions return to the relevant fact without clearing unrelated delivery or payment input.\n- Final button names the consequence and repeats the committed total.\n- Submitting blocks duplicate orders; failure preserves the cart and entered details.\n- Mobile keeps total and confirmation reachable without hiding required terms or fees.\n- Pickup, shipping, and zero-charge flows explain their distinct authorization/fulfillment behavior.\n\n## Anti-patterns\n\n- **Surprise total**: tax, shipping, or fees appear only after the confirmation action.\n- **Detached summary**: line items change but the visible total remains stale.\n- **Generic Continue button**: the final action does not distinguish navigation from placing an order.\n- **Failure resets checkout**: a payment error clears address, fulfillment, or cart state.\n- **Mobile summary trap**: collapsed totals hide fees and require an unclear gesture to review the commitment.\n\n## Agent notes\n\n- prompt_phrases: \"checkout summary with editable facts, explicit fee breakdown, and duplicate-submit guard\", \"mobile order commitment with visible total and payment recovery\"\n- fallbacks: for a one-item free claim, keep a compact confirmation summary rather than removing the commitment step.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 4, 5, and 7."},{"id":"product-detail-purchase-stack","name":"Product Detail Purchase Stack","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.scale.lg","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/product-detail-purchase-stack.tsx","component_refs":[],"term_refs":["product-option-sheet","quantity-stepper"],"source_refs":["baymard-product-page-ux"],"last_verified":"2026-07-12","source_path":"recipes/commerce/product-detail-purchase-stack.md","body":"## Intent\n\nThe product detail purchase stack is the desktop-default vertical block that binds title, price, variant selection, quantity, and the add-to-cart CTA into one continuously visible unit. Baymard's product-page research recommends exposing variant choices (size/color) as visible buttons rather than hiding them behind a dropdown, and surfacing the estimated total plus a return-policy link right next to the CTA rather than deferring cost/policy facts to a later step. `product-option-sheet` is the mobile bottom-sheet variant of this same decision set — this recipe is the inline desktop default, not a replacement for it.\n\n## Anatomy\n\n- Title and rating summary link.\n- Price, with an optional unit price line (e.g. price per 100g) beneath it.\n- Variant selectors rendered as a visible button group per axis (color, size) — not a hidden `<select>`.\n- Quantity stepper (reuses the `quantity-stepper` term).\n- Stock/availability state surfaced per variant, not just for the product as a whole.\n- Primary add-to-cart CTA that repeats the estimated total.\n- Return-policy link placed directly beneath the CTA.\n\n## States\n\n- Default: a valid variant pre-selected where possible, CTA enabled.\n- Variant unavailable: that option renders disabled with a visible reason (e.g. \"Out of stock\") rather than disappearing from the button group.\n- No variant selected yet (when no sane default exists): CTA disabled until a required axis is chosen.\n- Quantity at minimum: decrease control disabled at 1, matching `quantity-stepper`'s own floor behavior.\n- Updated total: changing variant or quantity recalculates the CTA's repeated total in the same interaction.\n\n## Variants\n\n- Single-axis variant selection (color only, or size only) — the button group renders for just that one axis.\n- Two-axis variant selection (color and size together) — two separate button groups, each with its own label.\n- No variants (single SKU product) — the variant section is omitted entirely rather than shown empty.\n\n## Code\n\n```tsx\nexport function ProductDetailPurchaseStack({ title, ratingSummary, price, variantOptions, selectedVariant, onSelectVariant, quantity, onQuantityChange, estimatedTotal, onAddToCart }: ProductDetailPurchaseStackProps) {\n return (\n <div>\n <h1>{title}</h1>\n <p>{ratingSummary}</p>\n <p>{formatMoney(price)}</p>\n <div>\n {variantOptions.map((option) => (\n <button key={option.value} aria-pressed={option.value === selectedVariant} disabled={!option.available} onClick={() => onSelectVariant(option.value)}>\n {option.label}\n </button>\n ))}\n </div>\n <QuantityStepper quantity={quantity} onChange={onQuantityChange} />\n <Button onClick={onAddToCart}>Add to cart · {formatMoney(estimatedTotal)}</Button>\n <a href=\"#return-policy\">Return policy</a>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/product-detail-purchase-stack.tsx`.\n\n## Checks\n\n- Variant options render as buttons, never a `<select>`/dropdown.\n- An out-of-stock variant stays visible and disabled with a reason, rather than being removed from the list.\n- The CTA's repeated total updates in the same interaction as a variant or quantity change — no stale total.\n- A return-policy link is present directly beneath the CTA, not only on a separate page.\n- Quantity control shares the same floor-at-1 behavior as the standalone `quantity-stepper` term.\n\n## Anti-patterns\n\n- **Variant dropdown**: hiding size/color behind a `<select>` forces an extra click to see all options and hides stock state per option — Baymard flags this as a recurring product-page friction point.\n- **Silent stock-outs**: removing an unavailable variant from the button group instead of showing it disabled with a reason confuses users comparing options.\n- **Total revealed only after Add to cart**: not repeating the estimated total on the CTA itself defers a cost fact the user should see before committing.\n- **Return policy buried**: linking to return policy only from a footer or separate page, far from the purchase decision itself.\n\n## Agent notes\n\n- prompt_phrases: \"사이즈 색상을 버튼으로 고르는 product detail purchase stack을 만들어줘\", \"담기 버튼에 예상 총액과 반품 정책 링크를 함께 보여줘\"\n- fallbacks: if a unit price isn't applicable to the product, omit that line entirely rather than showing an empty placeholder.\n- component composition: reuses `quantity-stepper` for the quantity control and stays inline/desktop-default — for a mobile bottom-sheet presentation of the same variant/quantity/CTA decision set, see `product-option-sheet`."},{"id":"promo-banner-system","name":"Promo Banner System","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/promo-banner-system.tsx","component_refs":[],"term_refs":["incentive-trust-strip"],"source_refs":["tailwind-plus-ecommerce"],"last_verified":"2026-07-12","source_path":"recipes/commerce/promo-banner-system.md","body":"## Intent\n\nA promo banner system is one banner shape that carries different urgency-driven offer copy — a discount rate, a limited-time sale, a countdown — depending on where it's placed (top-of-site vs in-page). It exists specifically for time-limited, dismissible promotional messaging. It is **not** the same thing as the `incentive-trust-strip` term: that term shows fixed reassurance signals (free shipping, returns, secure checkout) that don't expire and aren't urgency-driven, and it is not user-dismissible in the same session-scoped way this recipe is.\n\n## Anatomy\n\n- Banner container: full-width at the top of the site, or scoped to a section/page.\n- Primary offer copy: the discount, sale, or promotion message.\n- Optional countdown: remaining time to the offer's end, exposed to screen readers as text (not just a visual timer).\n- CTA: links to the relevant category or product set the promotion applies to.\n- Dismiss action: closes the banner for the current session only — it reappears next session rather than being permanently hidden.\n\n## States\n\n- Visible (default): offer copy, optional countdown, CTA, and dismiss control all shown.\n- Dismissed: banner is removed from the layout for the remainder of the session.\n- Countdown expired: the banner should be swapped or removed by the caller — this recipe does not auto-hide on expiry, since the countdown value is owned by the caller.\n- No countdown: banner renders offer copy and CTA only, omitting the timer region entirely rather than leaving an empty gap.\n\n## Variants\n\n- Top-of-site placement: full-width strip above the main site chrome, typically persistent across pages until dismissed.\n- In-page placement: bordered, section-scoped banner (e.g. inside a category page) rather than spanning the full viewport width.\n- Countdown variant: adds a live remaining-time region for flash sales or limited windows.\n- Evergreen variant: same shape without a countdown, for promotions without a hard deadline.\n\n## Code\n\n```tsx\nexport function PromoBannerSystem({ copy, ctaLabel, onCtaClick, dismissed, onDismiss, countdownLabel, placement = \"top-of-site\" }: PromoBannerSystemProps) {\n if (dismissed) return null\n return (\n <div className={placement === \"in-page\" ? \"rounded-md border\" : \"border-b\"} role=\"region\" aria-label=\"Promotion\">\n <p>\n {copy}\n {countdownLabel ? <span role=\"timer\">{countdownLabel}</span> : null}\n </p>\n <Button variant=\"link\" onClick={onCtaClick}>{ctaLabel}</Button>\n <button aria-label=\"Dismiss promotion\" onClick={onDismiss}>×</button>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/promo-banner-system.tsx`.\n\n## Checks\n\n- Dismissing the banner hides it only for the current session — it is not a permanent preference change.\n- A countdown, when present, is exposed as readable text via `role=\"timer\"`, not conveyed by animation alone.\n- Top-of-site and in-page variants share the same component and prop surface; only layout/border styling differs.\n- The banner never claims to be `incentive-trust-strip` content (free shipping/returns/security) — offer/urgency copy only.\n\n## Anti-patterns\n\n- **Permanent dismissal**: persisting the dismiss choice indefinitely removes a legitimate future promotion from a returning visitor.\n- **Countdown with no text fallback**: a purely visual ticking timer is invisible to screen reader users; the remaining time must be readable text.\n- **Confusing with the trust strip**: reusing this recipe to show \"free shipping over $50\" duplicates `incentive-trust-strip`'s job and blurs the urgency-vs-reassurance distinction for users.\n- **CTA without a target**: a banner that dismisses but has no CTA linking to the relevant category/products wastes the placement.\n\n## Agent notes\n\n- prompt_phrases: \"상단에 카운트다운이 있는 세일 배너를 넣어줘, 닫으면 이번 세션에서만 사라지게 해줘\", \"상품 상세 안에 섹션 범위 프로모션 배너를 넣어줘\"\n- fallbacks: if a live countdown isn't available, drop to the evergreen variant rather than showing a static, misleading time value.\n- related: distinct from `incentive-trust-strip` — that term is fixed reassurance signals near cart/checkout, this recipe is time-limited promotional urgency messaging anywhere on the site."},{"id":"shipping-method-selector","name":"Shipping Method Selector","pattern_group":"commerce","kind":"block","status":"draft","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.border.focus","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/shipping-method-selector.tsx","component_refs":[],"term_refs":["checkout-step","checkout-progress-header","payment-method-card","cart-summary"],"source_refs":["baymard-checkout-flow-ux"],"last_verified":"2026-07-12","source_path":"recipes/commerce/shipping-method-selector.md","body":"## Intent\n\nA shipping method selector lets a checkout user compare delivery/pickup options by cost and timing in one glance, and binds the choice directly to the order total. Baymard's checkout-flow research treats a total that changes unexpectedly after this step as a trust problem — so selecting a method must recalculate the visible total immediately, not just at final confirmation.\n\n## Anatomy\n\n- Option list: one row per method, structured as a radio group (single choice).\n- Per-row facts: carrier/store name, ETA, and price shown together, not in separate sections.\n- Selected state: visually distinct row (border/background) plus the checked radio control.\n- Pickup variant: rows for in-store pickup carry a location/time label instead of a carrier name.\n- Total binding: the selection is wired to whatever component displays the order total (`cart-summary` / `checkout-order-summary`), which must update the moment selection changes.\n\n## States\n\n- Default: one method pre-selected (commonly the cheapest or fastest, per product policy) so the total is always current.\n- Selected: chosen row highlighted; radio indicator filled.\n- Free shipping: price renders as \"Free\" rather than \"$0.00\" to read as a benefit, not a placeholder.\n- Pickup selected: no delivery ETA copy; store/pickup-window copy instead.\n- Disabled/unavailable method: row present but not selectable, with a short reason (e.g. address outside delivery zone) — not silently hidden, so the user understands why an option is missing.\n\n## Variants\n\n- Delivery-only list (no pickup row) for sellers without physical locations.\n- Mixed delivery + pickup list, pickup row(s) grouped at the end or start per product convention.\n- Single-method checkout: skip the selector entirely and show the one method as a fixed line instead of a one-item radio group.\n\n## Code\n\n```tsx\nexport function ShippingMethodSelector({ methods, selectedId, currency = \"USD\", onChange }: ShippingMethodSelectorProps) {\n return (\n <RadioGroup value={selectedId} onValueChange={onChange}>\n {methods.map((method) => {\n const selected = method.id === selectedId\n return (\n <label key={method.id} className={cn(\"flex items-start justify-between gap-4 rounded-md border p-3\", selected && \"border-ring bg-accent/50\")}>\n <span className=\"flex items-start gap-3\">\n <RadioGroupItem value={method.id} />\n <span className=\"flex flex-col gap-0.5\">\n <span className=\"text-sm font-medium\">{method.name}</span>\n <span className=\"text-xs text-muted-foreground\">{method.carrier} · {method.eta}</span>\n </span>\n </span>\n <span className=\"text-sm font-medium tabular-nums\">{formatMoney(method.price, currency)}</span>\n </label>\n )\n })}\n </RadioGroup>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/shipping-method-selector.tsx`. Built on the `radio-group` primitive (`examples/ui-vocabulary-site/src/components/ui/radio-group.tsx`).\n\n## Checks\n\n- Exactly one method can be selected at a time (native radio-group semantics, not independent checkboxes).\n- Changing selection recalculates the bound order total in the same interaction — no separate \"apply\" step.\n- Free shipping renders as \"Free\", not \"$0.00\".\n- Pickup rows never show a delivery ETA string.\n- Disabled/unavailable methods remain visible with a reason rather than disappearing.\n\n## Anti-patterns\n\n- **Cost revealed only at confirmation**: showing every method as \"calculated at next step\" instead of a real price per row reproduces the surprise-total problem this recipe exists to avoid.\n- **Split facts**: putting price in one column and ETA in another disconnected block forces the user to cross-reference rows instead of comparing at a glance.\n- **Checkbox group instead of radio group**: shipping method is a single choice; checkboxes imply multiple methods can apply simultaneously.\n- **Silent total lag**: updating the selected radio without immediately recalculating the visible order total shown elsewhere on the page.\n\n## Agent notes\n\n- prompt_phrases: \"배송/픽업 방식별 도착일과 가격을 비교하는 shipping method selector를 만들어줘\", \"선택하면 바로 총액이 바뀌는 배송 옵션 라디오 그룹을 checkout에 넣어줘\"\n- fallbacks: if carrier-level ETA data isn't available, show a date range instead of a carrier name, but never drop the price-per-row structure.\n- related: pairs with `cart-summary`/`checkout-order-summary` for the total this selector feeds; does not replace `payment-method-card`, which is a separate selection for how the order is paid, not how it is delivered."},{"id":"audit-log-filterable-export-feed","name":"Audit Log Filterable Export Feed","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/audit-log-filterable-export-feed.tsx","component_refs":["button"],"term_refs":["audit-log","activity-feed"],"source_refs":["linear-workspace-audit-log"],"last_verified":"2026-07-12","source_path":"recipes/data-display/audit-log-filterable-export-feed.md","body":"## Intent\n\nAn audit log filterable export feed implements the `audit-log` term's actor/action/target/timestamp record as a filterable, exportable operational surface: filters scope by actor, action type, and date range, and export always applies to the currently active filter rather than silently dumping the full unfiltered table. Unlike `activity-feed`, which reads as a lightweight social/collaboration stream, this recipe is a forensic/compliance record — the column order (actor, action, target, timestamp) stays fixed regardless of filter state, and metadata expands on demand rather than always rendering inline.\n\n## Anatomy\n\n- Filter bar: actor, action type, and date range controls.\n- Row: actor, action verb, target entity, timestamp, with metadata available via expand-on-demand.\n- Export action: scoped to the current filter (CSV/JSON), never a silent full-table export.\n- Empty state: distinguishes \"no events match this filter\" from \"no events have occurred yet.\"\n\n## States\n\n- Populated with active filter: rows reflect only the current actor/action/date scope; the export label reflects that scope (e.g. \"Export filtered\").\n- No events yet: empty state explains the log is genuinely empty, not filtered down to nothing.\n- No matching filter: empty state explains the filter excluded everything and invites resetting it — distinct copy from the \"no events yet\" case.\n- Row expanded: metadata renders inline for that row only, collapsing back on toggle.\n\n## Variants\n\n- Full filter bar + export (default, this recipe) for compliance/forensic review screens.\n- Read-only feed without export, for surfaces where export permission is restricted to a subset of operators.\n- Compact variant collapsing the date-range control into a single \"last 24h/7d/30d\" preset selector for lighter-weight admin panels.\n\n## Code\n\n```tsx\nexport function AuditLogFilterableExportFeed({ entries, actionFilter, onActionFilterChange, hasMatchingFilterButNoEvents, onExport }: AuditLogFilterableExportFeedProps) {\n return (\n <section className=\"flex flex-col gap-3 rounded-md border bg-card p-4 text-card-foreground\">\n {/* filter bar (actor/action/date), export button scoped to filter, actor/action/target/timestamp rows, two distinct empty states */}\n </section>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/audit-log-filterable-export-feed.tsx`.\n\n## Checks\n\n- Export always reflects the currently active filter scope; it never silently exports the full unfiltered table.\n- The \"no events yet\" and \"no matching filter\" empty states use distinct copy, not one generic empty message.\n- Column order (actor, action, target, timestamp) stays fixed across all filter states.\n- Metadata is not rendered inline by default — it requires an explicit expand action per row.\n- Filtering does not remove or reorder columns, only the row set.\n\n## Anti-patterns\n\n- **Export ignores active filter**: exporting the full table while a filter is applied silently gives the operator more (or different) data than what they reviewed on screen.\n- **One generic empty state**: collapsing \"no events yet\" and \"no matching filter\" into the same message forces the operator to guess whether the filter or the data is the problem.\n- **Always-expanded metadata**: rendering full metadata inline for every row turns a scannable log into a wall of text, defeating the feed's forensic scanning purpose.\n- **Treating this as `activity-feed`**: writing collaboration-style narrative copy (\"Alice updated the doc\") instead of structured actor/action/target/timestamp fields loses the compliance-grade precision this recipe exists for.\n\n## Agent notes\n\n- prompt_phrases: \"actor, action, target, timestamp를 필터링하고 필터 범위로 export할 수 있는 audit log를 만들어줘\", \"감사 로그를 액션 타입과 날짜 범위로 필터링하고 CSV로 내보내는 화면을 넣어줘\"\n- fallbacks: if export permission is restricted, a read-only variant without the export button is an acceptable reduced variant — do not fabricate a disabled export button as a placeholder.\n- component composition: implements the same term (`audit-log`) already in the dictionary; distinguishes itself from `activity-feed` by forensic/compliance intent rather than social/collaboration intent, per the term's own `anti_use` note."},{"id":"bulk-action-toolbar-selection-model","name":"Bulk Action Toolbar with Selection Model","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.action.destructive","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/bulk-action-toolbar.tsx","component_refs":["interactive-data-table","button"],"term_refs":["bulk-action-bar","table","checkbox"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-12","source_path":"recipes/data-display/bulk-action-toolbar-selection-model.md","body":"## Intent\n\nA bulk action toolbar turns a table's per-row selection into a single place to act on many rows at once. It appears only once a selection exists, always states the selected count out loud, and offers an explicit way to go beyond the current page — selecting every row that matches the active filter, not just the ones currently rendered. It builds directly on `interactive-data-table`'s selection state rather than defining a second, competing selection model.\n\n## Anatomy\n\n- Selected count: a badge or label stating how many rows are selected.\n- Clear selection: always-visible action that resets selection to none.\n- Select-all-matching-filter: appears only when every visible row is selected and more rows exist behind the current filter; offers to extend selection to all matches, not just the current page.\n- Bulk actions: primary/secondary buttons for the available operations (e.g. archive, export); destructive actions (e.g. delete) are visually distinct from non-destructive ones.\n- Placement: docked at the top or bottom of the table region so it stays reachable while scrolling.\n\n## States\n\n- Hidden: no rows selected — the toolbar renders nothing rather than an empty bar.\n- Partial selection: some rows selected; count reflects the exact number, select-all-matching is not offered.\n- All-visible selected: every rendered row is selected; if the table's total (post-filter) count is larger, the select-all-matching affordance appears.\n- All-matching selected: user has extended selection beyond the visible page; count reflects the full matching total.\n- Destructive action pending: a destructive bulk action (e.g. delete) routes through `destructive-confirmation` before executing — it never fires directly from the toolbar.\n\n## Variants\n\n- Sticky/floating bar anchored to the table (default) for desktop and tablet data operations.\n- Compact variant with icon-only actions for dense internal-tool tables where horizontal space is scarce.\n- Read-only variant with only \"export selected\" when no destructive or mutating action applies.\n\n## Code\n\n```tsx\nexport function BulkActionToolbar({ selectedCount, visibleCount, totalMatchingCount, onClearSelection, onSelectAllMatching, actions }: BulkActionToolbarProps) {\n if (selectedCount === 0) return null\n const allVisibleSelected = selectedCount === visibleCount\n const canSelectAllMatching = allVisibleSelected && typeof totalMatchingCount === \"number\" && totalMatchingCount > selectedCount\n\n return (\n <div className=\"flex flex-wrap items-center justify-between gap-3 rounded-md border bg-card p-2 text-card-foreground\">\n <div className=\"flex flex-wrap items-center gap-2 text-sm\">\n <Badge variant=\"secondary\">{selectedCount} selected</Badge>\n <Button size=\"sm\" variant=\"ghost\" onClick={onClearSelection}>Clear</Button>\n {canSelectAllMatching ? (\n <Button size=\"sm\" variant=\"link\" onClick={onSelectAllMatching}>Select all {totalMatchingCount} matching filter</Button>\n ) : null}\n </div>\n <div className=\"flex flex-wrap items-center gap-2\">\n {actions.map((action) => (\n <Button key={action.id} size=\"sm\" variant={action.variant === \"destructive\" ? \"destructive\" : \"outline\"} onClick={action.onAction}>\n {action.label}\n </Button>\n ))}\n </div>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/bulk-action-toolbar.tsx`. Consumes the same row/selection shape as `interactive-data-table` — it does not own selection state, it renders based on it.\n\n## Checks\n\n- Toolbar renders nothing when selectedCount is 0 (no empty bar with disabled buttons).\n- Selected count is always numerically accurate, including after select-all-matching-filter expands the selection.\n- Select-all-matching-filter only appears when every visible row is already selected and the total exceeds the visible count.\n- Destructive bulk actions require a confirmation step before executing.\n- Clear selection resets to zero and re-hides the toolbar.\n\n## Anti-patterns\n\n- **Always-visible empty toolbar**: showing a disabled bulk-action bar with 0 selected wastes space and implies actions exist when they don't.\n- **Silent full-selection lie**: labeling a \"select all\" checkbox as selecting everything when it only selects the current page misleads users operating on large filtered sets.\n- **Undifferentiated destructive action**: styling delete the same as archive/export invites accidental data loss.\n- **Selection state duplicated in the toolbar**: keeping a second copy of selected IDs inside the toolbar component instead of reading the table's selection model causes drift between the two.\n\n## Agent notes\n\n- prompt_phrases: \"테이블에서 행을 선택하면 상단에 선택 개수와 일괄 작업 버튼이 나타나는 bulk action toolbar를 만들어줘\", \"필터에 맞는 전체 선택 옵션이 있는 bulk action toolbar를 테이블 위에 추가해줘\"\n- fallbacks: if a floating/sticky toolbar isn't feasible, an inline row directly above the table header is an acceptable substitute — do not hide bulk actions inside a per-row menu once more than one row is selected.\n- component composition: assembles on top of `interactive-data-table`'s selection and filter state — do not fork a parallel selection model for this recipe."},{"id":"interactive-data-table","name":"Interactive Data Table","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["saas-dashboards","internal-tools","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/interactive-data-table.tsx","component_refs":["button"],"term_refs":["table","data-grid","data-table-toolbar","table-density-control","table-row-actions"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-10","source_path":"recipes/data-display/interactive-data-table.md","body":"## Intent\n\nAn interactive data table supports scanning and acting on structured records while preserving column meaning. Selection, sorting, filtering, density, and row actions are added only when the user task needs them; the table remains readable without those controls.\n\n## Anatomy\n\n- Toolbar: query, filters, bulk actions, and result count when applicable.\n- Header: stable column labels and optional sort controls.\n- Body: one record per row with a durable row key.\n- Selection and actions: explicit checkbox state, bulk summary, and row-level menu.\n- Responsive fallback: stacked priority fields or a deliberate reduced column set.\n\n## States\n\n- Default and hover for scanning.\n- Selected rows with observable count and reversible bulk state.\n- Sorted column with direction announced in the header.\n- Empty results with filter-reset guidance.\n- Loading, error, and narrow-screen fallbacks that retain column meaning.\n\n## Variants\n\n- Read-only comparison table.\n- Selectable operations table with bulk actions.\n- Dense table for high-frequency operator work.\n- Responsive stacked records when horizontal comparison is secondary.\n\n## Code\n\n```tsx\nexport function InteractiveDataTable({ rows }: InteractiveDataTableProps) {\n const [selected, setSelected] = useState<string[]>([])\n\n const toggle = (id: string) =>\n setSelected((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]))\n\n const toggleAll = () =>\n setSelected((current) => (current.length === rows.length ? [] : rows.map((row) => row.id)))\n\n const allSelected = rows.length > 0 && selected.length === rows.length\n\n return (\n <section className=\"overflow-hidden rounded-lg border bg-card text-card-foreground\">\n <header className=\"flex items-center justify-between gap-4 border-b p-4\">\n <p className=\"text-sm text-muted-foreground\">{rows.length} members</p>\n {selected.length > 0 ? (\n <Button size=\"sm\" type=\"button\" variant=\"outline\" onClick={() => setSelected([])}>\n Archive {selected.length}\n </Button>\n ) : null}\n </header>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"w-10\">\n <Checkbox aria-label=\"Select all rows\" checked={allSelected} onCheckedChange={toggleAll} />\n </TableHead>\n <TableHead>Name</TableHead>\n <TableHead>Role</TableHead>\n <TableHead>Status</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.id} data-state={selected.includes(row.id) ? \"selected\" : undefined}>\n <TableCell>\n <Checkbox aria-label={`Select ${row.name}`} checked={selected.includes(row.id)} onCheckedChange={() => toggle(row.id)} />\n </TableCell>\n <TableCell className=\"font-medium\">{row.name}</TableCell>\n <TableCell className=\"text-muted-foreground\">{row.role}</TableCell>\n <TableCell className=\"text-muted-foreground\">{row.status}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </section>\n )\n}\n```\n\n## Checks\n\n- Headers use table semantics and sorting exposes direction to assistive technology.\n- Row keys and selection use stable IDs, not visual positions.\n- Empty filtered results explain the filter and offer a reset action.\n- Narrow screens preserve priority fields or stack records without unlabeled values.\n- Bulk actions appear only when selection is non-empty and show the affected count.\n- Interactive row height never drops below the touch-target minimum (44px / `dimension.size.touch-target-min`) even at the densest setting — density comes from spacing and type, not from shrinking hit areas (taste ledger T-5, Linear measured).\n\n## Anti-patterns\n\n- **Table-shaped div grid**: visual columns have no header relationships for assistive technology.\n- **Position-based selection**: sorting changes which record a checked index represents.\n- **Every feature enabled**: filters, density, pagination, and bulk actions add noise without a user task.\n- **Horizontal scroll as the only mobile plan**: primary record identity and action disappear off-screen.\n\n## Agent notes\n\n- prompt_phrases: \"semantic selectable data table with stable IDs and mobile fallback\", \"operator table with explicit bulk action state\"\n- fallbacks: use a list when records have few comparable fields or each row is primarily a navigation target.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 2, and 5."},{"id":"permission-matrix-editor-grid","name":"Permission Matrix Editor Grid","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","color.semantic.action.destructive","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/permission-matrix-editor-grid.tsx","component_refs":["button"],"term_refs":["permission-matrix-editor"],"source_refs":["retool-user-permissions-template"],"last_verified":"2026-07-12","source_path":"recipes/data-display/permission-matrix-editor-grid.md","body":"## Intent\n\nA permission matrix editor grid implements the `permission-matrix-editor` term's role-by-permission table as an editable, high-density admin surface: both axes stay readable while scrolling a large matrix, explicit grants are visually distinct from inherited ones, and a batch of checkbox edits stays pending behind a diff step instead of writing to the backend on every click. This is the interaction shape Retool-style admin templates and workspace role-settings screens repeat once a permission table grows past a handful of rows and columns.\n\n## Anatomy\n\n- Sticky row header: permission/module label, one per row.\n- Sticky column header: role name, one per column.\n- Per-cell control: a checkbox with three visual states — granted, inherited, denied.\n- Dangerous-permission highlight: a badge on rows like \"delete all\" or \"billing admin\" that carries elevated risk.\n- Bulk toggle: an entire row or column can be set at once (not modeled per-cell only).\n- Diff banner: appears once any cell changes, states the pending change count, and offers commit or discard before anything is saved.\n\n## States\n\n- Clean: no pending changes — the diff banner is absent, not present-but-disabled.\n- Pending edits: one or more cells changed since the last commit; the banner shows the exact count.\n- Inherited cell: shown as granted but visually marked \"inherited\" rather than indistinguishable from an explicit grant.\n- Dangerous permission row: carries a persistent risk badge regardless of grant state, so risk is visible even before scanning individual cells.\n- Committed: pending state clears and the banner disappears after a successful save.\n\n## Variants\n\n- Full matrix with bulk row/column toggle (default, this recipe) for workspace-wide role administration.\n- Read-only audit variant that renders the same grid without checkboxes, for reviewing current grants without edit risk.\n- Single-role column view when only one role's permissions are being reviewed, dropping the multi-column sticky-header need.\n\n## Code\n\n```tsx\nexport function PermissionMatrixEditorGrid({ roles, permissions, cellState, pendingChangeCount, onToggleCell, onCommit, onDiscard }: PermissionMatrixEditorGridProps) {\n return (\n <section className=\"flex flex-col gap-2 overflow-hidden rounded-md border bg-card text-card-foreground\">\n {pendingChangeCount > 0 ? (\n <div className=\"flex items-center justify-between gap-3 border-b bg-muted/50 px-4 py-2 text-sm\">\n <span>{pendingChangeCount} unsaved permission change{pendingChangeCount === 1 ? \"\" : \"s\"}</span>\n <div className=\"flex gap-2\">\n <Button size=\"sm\" variant=\"ghost\" onClick={onDiscard}>Discard</Button>\n <Button size=\"sm\" onClick={onCommit}>Save changes</Button>\n </div>\n </div>\n ) : null}\n <div className=\"overflow-auto\">\n <Table>\n {/* sticky permission-row header, sticky role-column header, per-cell checkbox with granted/inherited/denied state */}\n </Table>\n </div>\n </section>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/permission-matrix-editor-grid.tsx`.\n\n## Checks\n\n- No pending change renders no diff banner (not a disabled/empty one).\n- Toggling a cell never writes immediately — it only updates local pending state until commit.\n- Inherited grants are visually distinguishable from explicit ones, not just implied by tooltip.\n- Dangerous-permission rows carry their risk badge in every state, not only when changed.\n- Discard reverts every pending cell back to the last committed state, not just the most recent toggle.\n\n## Anti-patterns\n\n- **Silent auto-save per checkbox**: writing each toggle immediately removes the operator's chance to review a batch of changes before they take effect on live access control.\n- **Inherited and explicit grants rendered identically**: an operator cannot tell whether revoking a cell actually removes access or just masks an inherited grant that persists elsewhere.\n- **Risk badge only on hover or expand**: burying the dangerous-permission signal behind an interaction defeats its purpose — it must be scannable in the default row view.\n- **Unbounded horizontal table with no sticky headers**: past a handful of roles, losing the permission-row label while scrolling right makes every cell ambiguous.\n\n## Agent notes\n\n- prompt_phrases: \"역할을 열로 권한을 행으로 배치하고 저장 전 diff를 보여주는 permission matrix editor grid를 만들어줘\", \"위험한 권한에 경고 배지가 있는 편집 가능한 권한 매트릭스를 넣어줘\"\n- fallbacks: if the role/permission count is small (a handful of each), a simpler per-role permission-prompt list is an acceptable reduced variant — do not force the sticky dual-axis grid on a table that fits on one screen without scrolling.\n- component composition: builds on the same term (`permission-matrix-editor`) that already defines the concept; this recipe is the grid-interaction structure (sticky headers, diff-before-commit), not a new concept."},{"id":"pull-to-refresh-list-pattern","name":"Pull to Refresh List Pattern","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/pull-to-refresh-list-pattern.tsx","component_refs":[],"term_refs":["pull-to-refresh","pull-to-refresh-indicator"],"source_refs":["material-m3-components"],"last_verified":"2026-07-12","source_path":"recipes/data-display/pull-to-refresh-list-pattern.md","body":"## Intent\n\nThe full drag-triggered refresh state machine for a mobile list: `pulling` → `threshold-crossed` → `refreshing` → `settled`/`cancelled`. `pull-to-refresh` names the gesture concept and `pull-to-refresh-indicator` names the visual indicator alone; this recipe is the composed implementation contract — the threshold-crossing judgment, the cancelable drag-back, and the rule that the indicator stays pinned while refreshing rather than scrolling away with the list.\n\n## Anatomy\n\n- List viewport: the scrollable container the gesture is scoped to.\n- Drag-offset indicator: a spinner/icon area above the list content whose height and rotation track the current pull offset.\n- Threshold line: an invisible offset value (not a rendered line) that separates \"will snap back\" from \"will refresh.\"\n- Refreshing spinner: pinned to the top of the viewport while `refreshing` is true — never scrolled off-screen by list content.\n- Released list content: the list itself, offset by the same amount as the indicator so the two move together during the drag.\n\n## States\n\n- **Idle**: no pull offset, not at drag start.\n- **Pulling**: pointer is dragging down from scroll-top, offset > 0 but below threshold. Releasing here snaps back to idle — no refresh fires.\n- **Threshold-crossed**: offset has passed `THRESHOLD_PX`. Releasing here commits to a refresh.\n- **Refreshing**: indicator pinned at the threshold height, spinning; the underlying async refresh is in flight. The list cannot be dragged again until this resolves.\n- **Settled**: refresh resolved, offset animates back to 0, new content (if any) is visible at the top.\n\n## Variants\n\n- Gesture is scoped to \"loose vertical drag-down at scroll-top only\" — it must not trigger during any other scroll direction or when the list isn't already at its top.\n- Desktop/web equivalents should prefer an explicit refresh button instead of this gesture (`pull-to-refresh`'s own `anti_use`) — this recipe is mobile/touch-scoped.\n\n## Code\n\n```tsx\nasync function onPointerUp() {\n if (phase === \"threshold-crossed\") {\n setPhase(\"refreshing\")\n setPullOffset(THRESHOLD_PX)\n await onRefresh()\n setPhase(\"idle\")\n setPullOffset(0)\n return\n }\n // Cancelled drag: released before crossing the threshold snaps back.\n setPhase(\"idle\")\n setPullOffset(0)\n}\n```\n\nFull pointer-tracking, scroll-top gating, and the colocated `PullToRefreshListPatternDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- Releasing before the threshold always snaps back without calling the refresh handler.\n- While `phase === \"refreshing\"`, the indicator stays pinned at a fixed height — it must not be pushed off-screen by the list's own scroll.\n- The gesture only starts when the list is scrolled to its top (`atScrollTop`) — a mid-list drag must not be interpreted as a refresh pull.\n- `aria-live=\"polite\"` on the indicator region so screen readers get a refresh announcement without needing to see the animation.\n\n## Anti-patterns\n\n- **Refresh firing before the threshold**: committing to a refresh on any nonzero pull offset (instead of only after crossing `THRESHOLD_PX`) makes accidental small drags trigger unwanted network calls.\n- **Indicator scrolling away while refreshing**: letting the list's own scroll carry the spinner off-screen during `refreshing` violates the M3 guidance this recipe encodes (\"Don't scroll the loading indicator off-screen\") and leaves the user unsure whether a refresh is still in progress.\n- **No cancel path**: only supporting \"drag past threshold and release\" with no way to abort a pull-in-progress strands the user in a stuck partial-pull state; a release before threshold must always resolve back to idle.\n- **Gesture not scoped to scroll-top**: triggering pull-to-refresh from anywhere in the list (not just when already scrolled to the top) conflicts with normal vertical scrolling.\n\n## Agent notes\n\n- `prompt_phrases`: \"피드 목록에 pull-to-refresh 상태 기계를 전부 구현해줘\", \"threshold를 넘기기 전에 놓으면 스냅백되게 해줘\", \"refreshing 중에는 스피너가 스크롤로 밀려나지 않게 고정해줘\"\n- If only the static visual (not the full drag state machine) is needed, use `pull-to-refresh-indicator` directly instead of this recipe.\n- Fallback: if pointer events are unavailable, a native `touchstart`/`touchmove`/`touchend` handler set can replace the pointer handlers as long as the same threshold/pin/cancel rules are preserved."},{"id":"stat-summary-grid","name":"Stat Summary Grid","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["saas-dashboards","internal-tools","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.scale.xl","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/stat-summary-grid.tsx","component_refs":[],"term_refs":["stat-list","dashboard-grid","status-indicator"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-10","source_path":"recipes/data-display/stat-summary-grid.md","body":"## Intent\n\nA stat summary grid gives a small set of operational measures a consistent hierarchy: label, current value, and a contextual trend or status. It supports quick orientation at the top of a real dashboard; it is not a license to fabricate metrics for decorative proof.\n\n## Anatomy\n\n- Section label or time range that defines the measurement context.\n- Peer stat cells with label, value, and optional trend/status.\n- Shared alignment and number formatting for comparison.\n- Optional link to the detailed report, separate from the value itself.\n\n## States\n\n- Stable value with no trend when comparison is unavailable.\n- Positive, negative, or neutral trend with text as well as color.\n- Loading placeholder that preserves final dimensions.\n- Unavailable data shown as an explicit message, not a misleading zero.\n\n## Variants\n\n- Bordered cells in an application dashboard.\n- Unframed summary band above a table.\n- Compact status list for an operations overview.\n- Dark monitoring surface using the same semantic hierarchy.\n\n## Code\n\n```tsx\nexport function StatSummaryGrid({ title, rangeLabel, stats, reportHref }: StatSummaryGridProps) {\n return (\n <section aria-labelledby=\"stat-summary-title\">\n <div className=\"flex items-end justify-between gap-4\">\n <div>\n <h2 id=\"stat-summary-title\" className=\"text-base font-medium text-foreground\">{title}</h2>\n <p className=\"text-sm text-muted-foreground\">{rangeLabel}</p>\n </div>\n {reportHref ? <a className=\"text-sm font-medium text-primary hover:underline\" href={reportHref}>Open report</a> : null}\n </div>\n <dl className=\"mt-6 grid grid-cols-1 overflow-hidden rounded-lg border sm:grid-cols-3\">\n {stats.map((stat) => (\n <div key={stat.label} className=\"border-b p-6 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0\">\n <dt className=\"text-sm text-muted-foreground\">{stat.label}</dt>\n <dd className=\"mt-2 text-3xl font-medium text-foreground tabular-nums\">{stat.value}</dd>\n {stat.trend ? <p className=\"mt-1 text-sm text-muted-foreground\">{stat.trend}</p> : null}\n </div>\n ))}\n </dl>\n </section>\n )\n}\n```\n\n## Checks\n\n- Every value names its unit, time range, and comparison context where relevant.\n- Trend meaning is conveyed in text, not color alone.\n- Unavailable data is distinct from a valid zero.\n- Values align and wrap without pushing peer cells out of the grid.\n- Metrics come from the named product workflow rather than generic dashboard filler.\n- Emphasis within a series uses a neutral base plus a single accent point (with a text label alongside the color), never coloring every bar/cell — the data-surface form of \"accent as signal\" (taste ledger T-14).\n\n## Anti-patterns\n\n- **Fake proof metrics**: arbitrary revenue, users, or conversion numbers decorate a hero with no data source or product meaning.\n- **Color-only trend**: red/green arrows provide no semantic label.\n- **Card wall**: every number sits in a heavy independent card, weakening comparison.\n- **Mixed time windows**: peer values compare daily, monthly, and lifetime measures without labels.\n\n## Agent notes\n\n- prompt_phrases: \"operational stat grid with named time range and text trends\", \"compact dashboard summary without generic KPI decoration\"\n- fallbacks: use a status list when values are categorical rather than numeric.\n- canonical guidance: `docs/design-system/principles.md` principles 2 and 4."},{"id":"swipe-action-row-pattern","name":"Swipe Action Row (Leading/Trailing)","pattern_group":"data-display","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.action.destructive","color.semantic.accent.base","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/swipe-action-row-pattern.tsx","component_refs":[],"term_refs":["swipe-action-row","swipe-to-delete","swipe-action"],"source_refs":["apple-hig-components"],"last_verified":"2026-07-12","source_path":"recipes/data-display/swipe-action-row-pattern.md","body":"## Intent\n\nA list row implementation of the vocabulary's `swipe-action-row` term: one row that can reveal either a leading action (e.g. mark done) or a trailing, destructive-toned action (e.g. delete), instead of only supporting a single delete-only direction like `swipe-to-delete`, or a single generic direction like `swipe-action`. This recipe is the concrete two-sided contract those three related terms describe from different angles.\n\n## Anatomy\n\n- Row content: the primary tappable label/content, flex-1, always present.\n- Leading action slot: appears to the left of the content only while `revealed === \"leading\"` — non-destructive tone (accent), fixed width.\n- Trailing action slot: appears to the right of the content only while `revealed === \"trailing\"` — destructive tone, fixed width.\n- Closed-state affordance: two small tap-only icon buttons (not a swipe) shown when nothing is revealed, one per direction, so both actions stay reachable without a drag gesture at all.\n\n## States\n\n- **Closed** (`revealed === \"none\"`): row content full-width; the two tap-only reveal icons are visible at the trailing edge.\n- **Leading revealed**: leading action button visible and focusable; tapping row content again returns to closed.\n- **Trailing revealed**: trailing (destructive) action button visible; same reset-on-content-tap behavior.\n- Both leading and trailing use the same reveal/dismiss state machine — only one side can be revealed on a given row at a time.\n\n## Variants\n\n- Full-swipe commit (not implemented in the code asset): a real swipe gesture can additionally support committing an action immediately at the end of a fast full-width swipe, rather than only revealing the button. Treat that as an enhancement layered on top of the reveal/tap contract here, not a replacement for it.\n- Leading-only or trailing-only rows: omit the unused slot and its reveal icon entirely rather than rendering an empty slot — don't imply an action exists where none does.\n\n## Code\n\n```tsx\n{revealed === \"trailing\" ? (\n <button\n type=\"button\"\n onClick={onTrailingAction}\n aria-label=\"Delete\"\n className=\"flex w-16 shrink-0 items-center justify-center bg-destructive text-white\"\n >\n <TrashIcon className=\"size-4\" aria-hidden=\"true\" />\n </button>\n) : null}\n```\n\nFull row (leading slot, content, trailing slot, and the two tap-only reveal icons) and the colocated `SwipeActionRowPatternDemo` live in `code_asset`.\n\n## Checks\n\n- Every revealed action has a non-drag path to reach it — the code asset's tap-only reveal icons in the closed state satisfy this; a real gesture-driven implementation must keep an equivalent tap/long-press/more-menu alternative.\n- Trailing/destructive action uses a distinct tone (`color.semantic.action.destructive`) from the leading action (`color.semantic.accent.base`), so the two sides read as different in kind, not just position.\n- Tapping the row content while an action is revealed dismisses it back to closed, rather than triggering the action — accidental content taps must not fire a destructive action.\n- Each revealed action button has an `aria-label` describing the action, not just an icon.\n\n## Anti-patterns\n\n- **Swipe as the only path to an action**: shipping a version where the leading/trailing actions can only be reached by a physical swipe gesture strands keyboard, switch-control, and low-dexterity users — always keep a tap-only alternative (icon button, long-press menu, or an explicit \"more\" affordance).\n- **Same tone for both sides**: styling the leading and trailing actions identically removes the \"this side is safe, this side is destructive\" signal the color contrast provides.\n- **Auto-committing on reveal**: firing the action as soon as the button becomes visible (instead of waiting for an explicit tap on it) turns a two-step reveal-then-confirm interaction into a single accidental-trigger-prone one.\n\n## Agent notes\n\n- `prompt_phrases`: \"리스트 행을 스와이프하면 왼쪽엔 완료 오른쪽엔 삭제가 나오는 swipe action row를 만들어줘\", \"삭제 액션은 빨간색으로 구분해줘\", \"스와이프 못하는 사용자를 위해 탭으로도 액션을 열 수 있게 해줘\"\n- If only a single destructive action per row is needed (no leading/mark-done action), `swipe-to-delete` is the narrower term to reference — this recipe still works for that case with the leading slot simply omitted.\n- Fallback: if a drag/pointer-tracking library isn't available, the tap-only reveal icons implemented here are an acceptable baseline, not a fallback to remove once dragging works — keep both."},{"id":"api-reference-three-column-layout","name":"API Reference Three-Column Layout","pattern_group":"docs","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/api-reference-layout.tsx","component_refs":["article-documentation-layout"],"term_refs":["badge"],"source_refs":["stripe-api-reference-docs"],"last_verified":"2026-07-12","source_path":"recipes/docs/api-reference-three-column-layout.md","body":"## Intent\n\nAn API reference page needs a reading path that is different from a prose article: the reader is scanning parameter names and types on the left while running or copying a request on the right. Splitting the page into a left explanation column and a right sticky code/response column lets both happen without scrolling back and forth.\n\n## Anatomy\n\n- Endpoint header: HTTP method badge + path, above both columns.\n- Left column: one row per parameter — name, type, required/optional badge, description; nested object parameters indent under their parent.\n- Right column: language/tool tabs (curl/node/python, etc.) over a request code sample, sticky while the left column scrolls.\n- Response panel: JSON response sample stacked below the request sample in the same sticky column.\n\n## States\n\n- Default: two columns side by side on wide viewports, right column sticky within the viewport.\n- Narrow viewport: columns stack, right column loses sticky positioning and follows normal document flow.\n- Nested parameter: child rows render indented under the parent row, same row shape.\n- No parameters: left column shows only the endpoint header and a short description, no empty param-list chrome.\n\n## Variants\n\n- Read-only reference: no request execution, samples are static text (this recipe's default).\n- Paired with `interactive-api-playground` term: the right column's code sample becomes an executable form instead of static text — a distinct, heavier composition, not a variant to fake here.\n\n## Code\n\n```tsx\nfunction ApiReferenceLayout({ method, path, params, samples, response }: ApiReferenceLayoutProps) {\n return (\n <div className=\"grid w-full max-w-6xl gap-8 lg:grid-cols-[minmax(0,1fr)_26rem]\">\n <div className=\"min-w-0 flex flex-col gap-6\">\n <div className=\"flex items-center gap-2\">\n <Badge className=\"font-mono uppercase\">{method}</Badge>\n <code className=\"text-sm\">{path}</code>\n </div>\n <div className=\"flex flex-col gap-5\">\n {params.map((param) => <ParamRow key={param.name} param={param} />)}\n </div>\n </div>\n <aside className=\"lg:sticky lg:top-6 lg:h-fit\">\n <Tabs defaultValue={samples[0]?.language}>\n <TabsList>{samples.map((s) => <TabsTrigger key={s.language} value={s.language}>{s.label}</TabsTrigger>)}</TabsList>\n {samples.map((s) => (\n <TabsContent key={s.language} value={s.language}>\n <pre className=\"overflow-x-auto rounded-md bg-foreground p-4 text-background\"><code className=\"text-xs\">{s.code}</code></pre>\n </TabsContent>\n ))}\n </Tabs>\n </aside>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/api-reference-layout.tsx`. Built on the `tabs` and `badge` shadcn primitives (`examples/ui-vocabulary-site/src/components/ui/tabs.tsx`, `.../ui/badge.tsx`).\n\n## Checks\n\n- Right column's `overflow-x-auto` never widens the page on narrow viewports; the code block scrolls within its own boundary.\n- Required/optional badges are present on every top-level and nested parameter row.\n- Nested parameters remain visually subordinate (indent + border) to their parent row, not flattened into the same list level.\n- Sticky positioning only applies at `lg` and above; below that the right column reads in normal document order after the left column.\n\n## Anti-patterns\n\n- **Sticky column captures scroll on mobile**: applying the sticky/fixed right-column behavior below the `lg` breakpoint traps scroll or overlaps the left column.\n- **Flattened nested params**: rendering a nested object's fields at the same indent level as their parent loses the object grouping the reader needs.\n- **Missing required/optional signal**: omitting the badge forces the reader to infer requiredness from prose instead of scanning it.\n- **Request sample without a response sample**: showing only the call and never the shape of what comes back leaves the reference half-finished.\n\n## Agent notes\n\n- prompt_phrases: \"왼쪽에 파라미터 설명, 오른쪽에 sticky 코드 샘플이 있는 API 레퍼런스 레이아웃을 만들어줘\", \"엔드포인트 파라미터 목록과 언어별 요청 예시를 나란히 보여주는 문서 페이지를 만들어줘\"\n- fallbacks: if only one language sample exists, still use the `Tabs` shell with a single tab rather than switching to a bare `<pre>` block, so a second language can be added without restructuring.\n- related: distinct from `article-documentation-layout`, whose right rail is a static on-this-page label list, not an executable code/response panel."},{"id":"article-documentation-layout","name":"Article Documentation Layout","pattern_group":"docs","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.4","dimension.space.8","dimension.space.12","typography.scale.sm","typography.scale.xl","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/article-documentation-layout.tsx","component_refs":[],"term_refs":["breadcrumb","typography","content-section","anchor-nav"],"source_refs":[],"last_verified":"2026-07-10","source_path":"recipes/docs/article-documentation-layout.md","body":"## Intent\n\nA documentation article layout turns a long implementation rule into a predictable reading path: breadcrumb and title establish context, a short lead states the decision the article helps make, ordered sections carry definitions and examples, and an on-this-page rail exposes the structure without competing with the body.\n\n## Anatomy\n\n- Context header: breadcrumb, article title, and one-paragraph lead.\n- Main column: readable line length with ordered sections and optional code blocks.\n- Supporting rail: section labels only; it never duplicates full body copy.\n- Related terms: a final comparison surface that keeps nearby vocabulary discoverable.\n\n## States\n\n- Default: article and rail are visible on wide screens.\n- Narrow viewport: the rail disappears and the article becomes one column without horizontal overflow.\n- Long code: the code block scrolls within its own boundary.\n- Missing optional data: previews, API rows, examples, or related terms collapse without empty shells.\n\n## Variants\n\n- Setup article: conceptual guidance and ordered next steps.\n- Foundation article: tokens, usage, light/dark behavior, and agent constraints.\n- Element article: interactive preview, state contract, API, and accessibility details.\n- Category article: shared judgment rules plus a related-term catalog.\n\n## Code\n\n```tsx\nfunction DocumentationArticle({ page }: { page: DocsArticlePageData }) {\n return (\n <article className=\"mx-auto grid w-full max-w-[76rem] gap-10 xl:grid-cols-[minmax(0,1fr)_16rem]\">\n <div className=\"min-w-0 max-w-4xl\">\n <p className=\"text-xs font-semibold uppercase text-muted-foreground\">\n {page.breadcrumb}\n </p>\n <h1 className=\"mt-5 text-5xl text-foreground\">{page.title}</h1>\n <p className=\"mt-8 max-w-3xl leading-7 text-muted-foreground\">{page.lead}</p>\n <div className=\"mt-14 flex flex-col gap-14\">\n {page.sections.map((section) => (\n <section key={section.title} className=\"flex flex-col gap-6\">\n <h2 className=\"text-xl font-semibold text-foreground\">{section.title}</h2>\n {section.body.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}\n {section.code && <pre className=\"overflow-x-auto bg-foreground text-background\"><code>{section.code}</code></pre>}\n </section>\n ))}\n </div>\n </div>\n <aside className=\"hidden border-l pl-6 xl:block\" aria-label=\"On this page\">\n {page.onThisPage.map((item) => <span key={item}>{item}</span>)}\n </aside>\n </article>\n )\n}\n```\n\n## Checks\n\n- The lead names the decision or task, not merely the topic.\n- Heading order remains semantic and does not skip levels for styling.\n- At narrow width the rail is absent, the main column is `min-w-0`, and code scrolls locally.\n- Empty optional sections leave no placeholder chrome.\n- Light and dark surfaces preserve text, muted text, border, and code contrast.\n\n## Anti-patterns\n\n- **Planning memo as public copy**: internal status, implementation history, or TODO language replaces a reader-facing explanation.\n- **Navigation duplicates the article**: the rail repeats paragraphs instead of exposing a short outline.\n- **Unbounded code block**: a long command or URL widens the entire page on mobile.\n- **Heading paint without hierarchy**: arbitrary font sizes replace semantic heading order.\n\n## Agent notes\n\n- prompt_phrases: \"documentation article with a readable main column and responsive outline\", \"decision-first docs page with related vocabulary\"\n- fallbacks: omit the rail when there are fewer than three sections; preserve the article order.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 5, and 7.\n- scroll-spy rail: `anchor-nav` (term) already covers a section-link rail with an active-section indicator; wiring the `onThisPage` rail here to highlight on scroll (IntersectionObserver-based) is an `anchor-nav`-style extension of this rail, not a separate recipe."},{"id":"docs-changelog-category-filter-page","name":"Docs Changelog Page With Category Filter Chips And Pagination","pattern_group":"docs","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/docs-changelog-category-filter-page.tsx","component_refs":[],"term_refs":["docs-changelog-page","release-note-card"],"source_refs":["stripe-changelog-docs"],"last_verified":"2026-07-12","source_path":"recipes/docs/docs-changelog-category-filter-page.md","body":"## Intent\n\n`docs-changelog-page` already names a date-grouped `release-note-card` list as a page; this recipe is the concrete implementation with the two controls that make a long changelog navigable — a category filter chip row (All/GA/Preview-style categories) that narrows which cards show, and pagination so the page doesn't grow unbounded. Filtering and pagination share one state contract: changing the category resets which date groups are visible, and a date group with zero remaining cards after filtering disappears rather than rendering with an empty heading.\n\n## Anatomy\n\n- Category filter row: chip-style toggle buttons (All + one per category), one active at a time.\n- Date-grouped sections: a date header followed by that date's `release-note-card` entries.\n- Release note card: version label, tag badges, change summary — matches the `release-note-card` term's own anatomy.\n- Pagination footer: page indicator plus previous/next controls, shown only when there is more than one page.\n\n## States\n\n- Default (All category, page 1): every date group with at least one release renders.\n- Category selected: date groups whose releases don't match the category are hidden entirely, not shown with a \"no releases\" placeholder inside an otherwise-empty group.\n- No releases match the selected category across every group: a single page-level empty message replaces the group list.\n- Last page reached: the \"Next\" control is disabled (`aria-disabled`), not hidden, so the control's position stays stable.\n\n## Variants\n\n- Single-page changelog (few releases): pagination footer is omitted entirely rather than rendered disabled, since `pageCount <= 1`.\n- Search-augmented variant: a search field ahead of the category chips — not implemented here; this recipe covers the category+pagination baseline only.\n\n## Code\n\n```tsx\n<div role=\"group\" aria-label=\"Filter changelog by category\">\n {categories.map((c) => (\n <button key={c.id} aria-pressed={c.id === activeCategoryId} onClick={() => onCategoryChange(c.id)}>\n <Badge variant={c.id === activeCategoryId ? \"default\" : \"outline\"}>{c.label}</Badge>\n </button>\n ))}\n</div>\n{visibleGroups.map((group) => (\n <section key={group.date}>\n <h2>{group.date}</h2>\n {group.releases.map((r) => (\n <article key={r.id}>\n <span>{r.version}</span>\n {r.tags.map((t) => <Badge key={t} variant=\"secondary\">{t}</Badge>)}\n <p>{r.summary}</p>\n </article>\n ))}\n </section>\n))}\n{pageCount > 1 ? <Pagination>{/* previous / page indicator / next */}</Pagination> : null}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/docs-changelog-category-filter-page.tsx`. Built on the `badge` and `pagination` shadcn primitives.\n\n## Checks\n\n- Hidden date groups (zero releases after filtering) do not render their date header either — no empty section chrome.\n- Only one category chip shows the active (`default` variant) state at a time.\n- Pagination is entirely absent, not just visually inert, when `pageCount <= 1`.\n- Previous/Next controls stay in the same layout position whether enabled or `aria-disabled`.\n\n## Anti-patterns\n\n- **Empty date headers**: rendering a date section with no cards underneath it after a category filter removes all of that date's releases.\n- **Category chips as a single-select dropdown**: collapsing the chip row into a `Select`, losing the at-a-glance multi-option scan a changelog reader relies on.\n- **Pagination appended instead of replacing**: a \"load more\" that keeps appending pages to a single infinite list instead of this recipe's page-replaces-page contract (either is valid UX, but they are different recipes — do not mix load-more state into a page-number pager).\n- **Filter and pagination state drifting apart**: not resetting to page 1 when the category changes, leaving the reader on a page number that may not exist for the new filtered set.\n\n## Agent notes\n\n- prompt_phrases: \"카테고리 필터 chip과 페이지네이션이 있는 changelog 페이지를 만들어줘\", \"release note를 날짜별로 묶고 카테고리로 필터링할 수 있는 체인지로그를 만들어줘\"\n- fallbacks: if no `tags` exist for a release, omit the tag badge row rather than rendering an empty badge group.\n- related: implements the existing `docs-changelog-page` term and reuses `release-note-card`'s card anatomy directly — no new term needed for either."},{"id":"docs-code-block-with-tabs-and-copy","name":"Docs Code Block With Language Tabs And Copy","pattern_group":"docs","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/docs-code-block.tsx","component_refs":["button"],"term_refs":["copy-button"],"source_refs":["nextra-docs-theme"],"last_verified":"2026-07-12","source_path":"recipes/docs/docs-code-block-with-tabs-and-copy.md","body":"## Intent\n\nDocumentation readers often need the same instruction in more than one equivalent form (npm/yarn/pnpm, or curl/node/python) and want to copy it without hand-selecting text. Tabs switch between equivalent code variants in place, and a fixed copy button turns the block into a one-click action instead of a select-and-copy chore.\n\n## Anatomy\n\n- Tab header: one tab per language/tool variant, switching the code body below without changing surrounding prose.\n- Copy button: pinned to the top-right corner of the code body, above the code, not inline with it.\n- Code body: monospace text with local horizontal scroll — never widens the parent column.\n- Copy confirmation: the button's icon/state changes briefly after a successful copy, then reverts.\n\n## States\n\n- Default: first variant tab active, copy button in its resting icon state.\n- Copied: button shows a confirmation icon for a short duration, then returns to the copy icon.\n- Long line: code scrolls horizontally within the block; the block's own width still respects the parent column.\n- Single variant: tab header still renders (with one tab) rather than collapsing to a bare code block, so a second variant can be added later without restructuring.\n\n## Variants\n\n- Package manager tabs (npm/yarn/pnpm/bun) for install commands.\n- Language tabs (curl/node/python/etc.) for API usage samples.\n- Optional line-highlight/diff styling layered on top of the same tab+copy shell (not implemented in the base `code_asset` — a token-safe extension point, not a new recipe).\n\n## Code\n\n```tsx\nfunction DocsCodeBlock({ variants }: DocsCodeBlockProps) {\n const [copied, setCopied] = useState(false)\n async function handleCopy(code: string) {\n await navigator.clipboard.writeText(code)\n setCopied(true)\n window.setTimeout(() => setCopied(false), 1500)\n }\n return (\n <Tabs defaultValue={variants[0]?.id}>\n <TabsList variant=\"line\">\n {variants.map((v) => <TabsTrigger key={v.id} value={v.id}>{v.label}</TabsTrigger>)}\n </TabsList>\n {variants.map((v) => (\n <TabsContent key={v.id} value={v.id} className=\"relative\">\n <Button aria-label=\"Copy code\" className=\"absolute right-2 top-2\" size=\"icon\" variant=\"ghost\" onClick={() => handleCopy(v.code)}>\n {copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />}\n </Button>\n <pre className=\"overflow-x-auto rounded-md bg-foreground p-4 pr-10 text-background\"><code className=\"text-xs\">{v.code}</code></pre>\n </TabsContent>\n ))}\n </Tabs>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/docs-code-block.tsx`. Built on the `tabs` and `button` shadcn primitives (`examples/ui-vocabulary-site/src/components/ui/tabs.tsx`, `.../ui/button.tsx`).\n\n## Checks\n\n- Copy button sits above the code text (`pr-10` reserves space) so it never overlaps a long line's visible characters.\n- The code block's `overflow-x-auto` is scoped to the block itself, not the page.\n- Copy confirmation state reverts automatically; it does not require a second click to reset.\n- Tab switching swaps only the code body, not the surrounding heading/prose.\n\n## Anti-patterns\n\n- **Copy button inline with code**: placing the button inside the text flow instead of pinned to a corner breaks selection and reading of the code itself.\n- **No confirmation after copy**: a copy button that gives no visible feedback leaves the reader unsure whether the click worked.\n- **Single global scrollbar**: letting a long code line scroll the whole page horizontally instead of scoping `overflow-x-auto` to the `<pre>` block.\n- **Tabs without equivalent content**: using tabs to switch between genuinely different (non-equivalent) instructions, which belongs in separate sections, not a tab set implying interchangeability.\n\n## Agent notes\n\n- prompt_phrases: \"npm yarn pnpm 탭과 복사 버튼이 있는 문서 코드 블록을 만들어줘\", \"여러 언어 탭 전환과 복사 버튼이 있는 코드 스니펫을 문서 페이지에 넣어줘\"\n- fallbacks: if clipboard write fails (unsupported browser/context), keep the button visible but silently no-op rather than throwing; do not block rendering the code block.\n- related: pairs with `copy-button` (the underlying action) and can sit inside `api-reference-three-column-layout`'s right column when the reference needs one code sample per language."},{"id":"actionable-toast","name":"Actionable Toast","pattern_group":"feedback","kind":"component","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards","commerce","internal-tools"],"tokens_used":["color.semantic.surface.overlay","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/actionable-toast.tsx","component_refs":["button"],"term_refs":["toast","toast-stack","undo-toast","alert"],"source_refs":[],"last_verified":"2026-07-10","source_path":"recipes/feedback/actionable-toast.md","body":"## Intent\n\nAn actionable toast confirms a recent low-risk operation and may offer one short recovery action such as Undo or Retry. It does not request a decision, carry long instructions, or hide a persistent error that belongs next to the affected content.\n\n## Anatomy\n\n- Concise outcome title using the user's action language.\n- Optional one-line detail that identifies the affected object.\n- At most one action, plus a dismiss control when the toast does not expire quickly.\n- Live-region announcement that mirrors the visible outcome without stealing focus.\n\n## States\n\n- Success confirmation with automatic dismissal.\n- Failure with Retry when the operation is safe to repeat.\n- Undo window that remains long enough to perceive and activate.\n- Stacked notifications with a bounded visible count.\n- Dismissed state that clears timers and removes the live announcement.\n\n## Variants\n\n- Passive confirmation with no action.\n- Undo toast for reversible deletion, archive, or move.\n- Retry toast for transient network failure.\n- Persistent alert instead of toast when the state remains relevant on the page.\n\n## Code\n\n```tsx\nexport function ActionableToast({ toast, onDismiss, onUndo }: ActionableToastProps) {\n useEffect(() => {\n const timer = window.setTimeout(onDismiss, toast.duration)\n return () => window.clearTimeout(timer)\n }, [onDismiss, toast.duration])\n\n return (\n <div\n className=\"flex w-[min(23rem,calc(100vw-2rem))] items-start gap-3 rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg\"\n role=\"status\"\n aria-live=\"polite\"\n >\n <div className=\"min-w-0 flex-1\">\n <p className=\"text-sm font-medium\">{toast.title}</p>\n {toast.body ? <p className=\"mt-1 text-sm text-muted-foreground\">{toast.body}</p> : null}\n </div>\n {toast.undoable ? (\n <Button className=\"shrink-0\" size=\"sm\" type=\"button\" variant=\"link\" onClick={onUndo}>\n Undo\n </Button>\n ) : null}\n <button aria-label=\"Dismiss notification\" className=\"shrink-0 text-muted-foreground transition hover:text-foreground\" type=\"button\" onClick={onDismiss}>\n <XIcon aria-hidden=\"true\" className=\"size-4\" />\n </button>\n </div>\n )\n}\n```\n\n## Checks\n\n- Message states what happened and names the affected object when ambiguity is possible.\n- `role=status` or an equivalent polite live region announces the result without moving focus.\n- Undo/Retry is the only action; consequential choices use an inline surface or dialog.\n- Timer cleanup prevents dismissed toasts from reappearing or updating unmounted state.\n- A stack limits visible notifications and preserves newest/oldest ordering deliberately.\n\n## Anti-patterns\n\n- **Toast as error storage**: a blocking or persistent failure disappears before the user can act.\n- **Action pile**: multiple buttons turn a transient notification into a hidden dialog.\n- **Color-only outcome**: success and failure differ only by green or red.\n- **Focus theft**: showing the toast moves keyboard focus away from the task.\n\n## Agent notes\n\n- prompt_phrases: \"polite actionable toast with one undo action and timer cleanup\", \"observable save result without focus theft\"\n- fallbacks: use an inline alert when the message remains relevant to a form, section, or unresolved error.\n- canonical guidance: `docs/design-system/principles.md` principles 5 and 7."},{"id":"recoverable-empty-state","name":"Recoverable Empty State","pattern_group":"feedback","kind":"block","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards","commerce","internal-tools"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.scale.lg","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/recoverable-empty-state.tsx","component_refs":["button"],"term_refs":["empty-state","empty-search-result","empty-filter-state","error-state"],"source_refs":[],"last_verified":"2026-07-10","source_path":"recipes/feedback/recoverable-empty-state.md","body":"## Intent\n\nA recoverable empty state explains why expected content is absent and gives the user the most direct next action. It distinguishes first-use emptiness, no search results, filtered-out content, permission limits, and load failures instead of showing one generic illustration for every cause.\n\n## Anatomy\n\n- Cause-specific title that describes the current state.\n- One or two sentences explaining what changed or what will appear here.\n- Primary recovery action such as Create, Clear filters, Retry, or Request access.\n- Optional secondary guidance only when it helps the user choose a different path.\n\n## States\n\n- First use: no records exist yet; creation is primary.\n- Search empty: query is visible and editing/clearing it is primary.\n- Filter empty: active constraints are visible and reset is primary.\n- Error empty: failure is named and Retry/manual fallback is available.\n- Permission empty: access boundary and request/return path are explicit.\n\n## Variants\n\n- Inline table/list empty row for a scoped result set.\n- Full-panel first-use state for a new workspace.\n- Search recovery with related suggestions.\n- Offline or failure state with retry and cached-content fallback.\n\n## Code\n\n```tsx\nconst COPY: Record<EmptyStateKind, { title: (query?: string) => string; body: string; action: string }> = {\n firstUse: { title: () => \"No projects yet\", body: \"Create the first project for this workspace.\", action: \"Create project\" },\n search: { title: (query) => `No results for \"${query}\"`, body: \"Try a broader term or clear the query.\", action: \"Clear search\" },\n filtered: { title: () => \"No matching records\", body: \"The current filters exclude every record.\", action: \"Clear filters\" },\n error: { title: () => \"Could not load records\", body: \"Your changes are safe. Try loading this view again.\", action: \"Retry\" },\n}\n\nexport function RecoverableEmptyState({ kind, query, onRecover }: RecoverableEmptyStateProps) {\n const copy = COPY[kind]\n\n return (\n <section className=\"grid min-h-64 place-items-center rounded-lg border bg-muted/30 p-8 text-center\">\n <div className=\"max-w-md\">\n <InboxIcon aria-hidden=\"true\" className=\"mx-auto size-10 text-muted-foreground\" />\n <h2 className=\"mt-4 text-lg font-medium text-foreground\">{copy.title(query)}</h2>\n <p className=\"mt-2 text-sm leading-6 text-muted-foreground\">{copy.body}</p>\n <Button className=\"mt-6\" type=\"button\" onClick={onRecover}>\n {copy.action}\n </Button>\n </div>\n </section>\n )\n}\n```\n\n## Checks\n\n- The empty-state kind matches the actual cause; valid zero data is not mislabeled as an error.\n- Search/filter states repeat the active query or constraint and make reset observable.\n- Recovery action is real, enabled, and returns to the same task context.\n- Illustration remains subordinate to the title and action and has no redundant accessible label.\n- Retry failure remains visible with an alternate path rather than looping silently.\n\n## Anti-patterns\n\n- **One generic empty illustration**: first use, no results, permissions, and errors receive identical copy.\n- **Dead-end apology**: the message explains absence but offers no recovery action.\n- **Create action for filter emptiness**: users add duplicate data instead of clearing a constraint.\n- **Decorative takeover**: a large illustration pushes the cause and action below the viewport.\n\n## Agent notes\n\n- prompt_phrases: \"cause-specific empty state with a working recovery action\", \"filtered results empty state that exposes and clears constraints\"\n- fallbacks: use a compact inline row when only a table subsection is empty.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 4, and 5."},{"id":"advanced-filter-builder-condition-groups","name":"Advanced Filter Builder with Condition Groups","pattern_group":"forms","kind":"form-pattern","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/advanced-filter-builder.tsx","component_refs":["button"],"term_refs":["advanced-filter-builder","select","text-field"],"source_refs":["appmaster-io-blog"],"last_verified":"2026-07-12","source_path":"recipes/forms/advanced-filter-builder-condition-groups.md","body":"## Intent\n\nAn advanced filter builder with condition groups implements the `advanced-filter-builder` term's field-operator-value contract at composition scale: instead of one flat list of conditions, conditions nest inside groups, and groups combine with a top-level AND/OR join. This is the structure Retool-style internal admin tools repeat when a single filter row can't express \"status is active AND (region is US OR region is EU)\".\n\n## Anatomy\n\n- Field select: which column/attribute the condition targets.\n- Operator select: equals, contains, greater than, etc. — the available set follows the selected field's type.\n- Value input: adapts to the field's type (text, number, date) rather than always being a plain text box.\n- Condition join: AND/OR selector between conditions inside the same group.\n- Group join: a separate AND/OR selector between groups, visually distinguished from the in-group join.\n- Add condition / add group: explicit actions to grow the structure.\n- Remove condition: per-row action to shrink a group.\n- Save as view: optional action connecting the built filter to `saved-view-tabs`.\n\n## States\n\n- Single group, single condition: the simplest case — no join selectors are shown yet.\n- Multiple conditions, one group: an in-group AND/OR selector appears between conditions.\n- Multiple groups: a group-level AND/OR selector appears between groups, styled distinctly from the in-group join so the two nesting levels stay visually separable.\n- Field changed mid-condition: the operator and value input reset to match the new field's type rather than keeping a stale operator/value combination.\n- Empty condition: a newly added condition with no field chosen yet still renders a full field/operator/value row, prompting completion rather than hiding controls.\n\n## Variants\n\n- Inline builder embedded directly above a data table (default, this recipe).\n- Panel/drawer variant when the condition list is long enough to compete with the data view — compose with `filter-panel` instead of inlining.\n- Read-only summary variant that renders the built expression as text/chips once complete, useful for confirming a saved view.\n\n## Code\n\n```tsx\nexport function AdvancedFilterBuilder({ fields, groups, groupJoin, onGroupJoinChange, onConditionChange, onGroupJoinToggle, onAddCondition, onRemoveCondition, onAddGroup, onSaveAsView }: AdvancedFilterBuilderProps) {\n const fieldType = (fieldValue: string) => fields.find((field) => field.value === fieldValue)?.type ?? \"text\"\n\n return (\n <div className=\"flex flex-col gap-3 rounded-md border bg-card p-4 text-card-foreground\">\n {groups.map((group, groupIndex) => (\n <div key={group.id} className=\"flex flex-col gap-2\">\n {groupIndex > 0 ? (\n <Select value={groupJoin} onValueChange={(value) => onGroupJoinChange(value as \"and\" | \"or\")}>\n <SelectTrigger size=\"sm\" aria-label=\"Join groups with\"><SelectValue /></SelectTrigger>\n <SelectContent><SelectItem value=\"and\">AND</SelectItem><SelectItem value=\"or\">OR</SelectItem></SelectContent>\n </Select>\n ) : null}\n <div className=\"flex flex-col gap-2 rounded-md border border-dashed p-3\">\n {group.conditions.map((condition, conditionIndex) => (\n <div key={condition.id} className=\"flex flex-wrap items-center gap-2\">\n {/* field select, operator select, type-aware value input, remove action */}\n </div>\n ))}\n <Button size=\"sm\" variant=\"ghost\" onClick={() => onAddCondition(group.id)}>+ Add condition</Button>\n </div>\n </div>\n ))}\n <Button size=\"sm\" variant=\"outline\" onClick={onAddGroup}>+ Add group</Button>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/advanced-filter-builder.tsx`. Built on the existing `select`/`text-field` input primitives, not a bespoke query-string editor.\n\n## Checks\n\n- Changing a condition's field resets its operator/value to a valid combination for the new field's type rather than leaving a stale mismatch.\n- The in-group join selector and the between-group join selector are visually distinguishable from each other.\n- Adding a group always starts with at least one empty condition row, never an empty group with no conditions.\n- Removing the last condition in a group removes the group itself rather than leaving an empty shell.\n- Value input type (text/number/date) matches the selected field's declared type.\n\n## Anti-patterns\n\n- **Exposed query string**: rendering the built filter as raw SQL/query-language text for the user to hand-edit defeats the purpose of a structured builder — keep the structure the primary editing surface.\n- **Flat conditions with no grouping**: when the underlying data model supports nested AND/OR, forcing everything into one flat list silently drops expressiveness the user needs.\n- **Operator list ignoring field type**: showing \"greater than\" for a text field or \"contains\" for a date field confuses users and produces invalid queries.\n- **Untouched empty groups**: allowing a save action while a group has zero conditions produces a filter that silently matches everything or nothing depending on the join semantics.\n\n## Agent notes\n\n- prompt_phrases: \"필드, 연산자, 값을 조합하고 AND/OR로 그룹까지 묶을 수 있는 advanced filter builder를 만들어줘\", \"조건을 여러 그룹으로 나누고 그룹끼리도 AND/OR로 연결되는 필터 빌더를 넣어줘\"\n- fallbacks: if nested groups are out of scope, a single flat AND-only condition list is an acceptable reduced variant — but do not silently drop the OR case; state the limitation to the user instead.\n- component composition: reuses `select`/`text-field` input primitives directly — assemble from those rather than building custom dropdown/input controls for this recipe."},{"id":"button","name":"Button","pattern_group":"forms","kind":"component","status":"draft","surface_refs":["websites","saas-dashboards","components-primitives"],"tokens_used":["color.component.button.bg","color.component.button.text","color.semantic.action.primary","color.semantic.border.default","dimension.radius.sm","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/ui/button.tsx","component_refs":[],"term_refs":["button","primary-button","secondary-button"],"source_refs":[],"last_verified":"2026-07-07","source_path":"recipes/forms/button.md","body":"## Intent\n\nA button lets a user trigger a single, immediate action (submit, save, confirm, cancel). It communicates the relative importance of an action through fill and contrast rather than size — primary actions get a solid fill, secondary actions get an outline.\n\n## Anatomy\n\n- Container: fixed height, horizontal padding, rounded corners.\n- Label: short verb-first text (e.g. \"Save\", \"Cancel\").\n- Optional leading/trailing icon.\n- Optional loading indicator replacing or alongside the label.\n\n## States\n\n- Default\n- Hover (opacity shift on primary, subtle bg shift on secondary/ghost/outline)\n- Focus-visible (ring)\n- Disabled (reduced opacity, pointer-events off)\n- Active/pressed\n\n## Variants\n\n- `default` (primary): solid fill using `color.component.button.bg`, text using `color.component.button.text`.\n- `secondary` / `outline`: transparent background, 1px border using `color.semantic.border.default`, text follows surface foreground.\n- `destructive`: reserved for irreversible actions — do not substitute for primary emphasis.\n- `ghost` / `link`: lowest emphasis, no container fill or border.\n\nSizes follow a fixed scale (`sm`, `default`, `lg`, icon-only variants) — do not introduce ad hoc heights per screen.\n\n## Code\n\n```tsx\nconst buttonVariants = cva(\n \"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n {\n variants: {\n variant: {\n default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n destructive:\n \"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20\",\n outline:\n \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground\",\n secondary:\n \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n ghost:\n \"hover:bg-accent hover:text-accent-foreground\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n size: {\n default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n sm: \"h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5\",\n lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n icon: \"size-9\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n)\n```\n\nComponent-level spec (Askewly Design landing usage, see `DESIGN.md` §7 Components > Button): height 40, padding-x 24, radius `sm`; primary is a solid `color.component.button.bg` fill with `color.component.button.text` label, hover reduces opacity to 0.9; secondary is transparent with a 1px `color.semantic.border.default` border.\n\n## Checks\n\n- Text has sufficient contrast against the fill (component tier resolves through `action.primary` → `text.on-accent`, both already contrast-checked at the semantic tier).\n- Focus-visible ring is present and not suppressed.\n- Disabled state is visually distinct and non-interactive (`pointer-events-none`, reduced opacity).\n- Only one primary (solid) button visible per immediate decision context.\n- Hit target meets minimum touch size on `mobile-apps`/touch surfaces even when using compact sizes.\n- Label leads with an actionable verb ({verb}+{noun}, common actions like Save/Cancel exempt) and predicts the outcome; no filler verbs (View/Go/Read) or articles (taste ledger T-17, Polaris diff adopted).\n\n## Anti-patterns\n\n- **Oversized rounded pill buttons.** `DESIGN.md` fixes radius at `sm` (4px); full-pill radii read as generic template output, not as this system's shape language.\n- **Heavy drop shadows on buttons.** Elevation here comes from borders and surface tint, not shadow — a shadowed button breaks the flat, bordered aesthetic (`DESIGN.md` §6, §8).\n- **Every button styled as primary.** Solid-fill overuse erases hierarchy; reserve the primary fill for the one dominant action and use secondary/outline/ghost for everything else.\n- **One-note gradient fills on the button background.** Gradients are called out directly as a page-level anti-pattern and apply equally at component scale.\n\n## Agent notes\n\n- `prompt_phrases`: \"저장 버튼을 오른쪽 아래에 넣어줘\", \"가장 중요한 행동은 primary button으로 강조해줘\", \"취소는 secondary button으로 넣어줘\".\n- Fallback when no variant is specified: use `default` (primary) for the single most important action on the screen, `outline`/`secondary` for everything else, and reserve `destructive` strictly for irreversible actions.\n- When asked for a \"big rounded CTA button,\" push back toward the fixed radius/height spec above rather than inventing a new shape."},{"id":"data-import-wizard-validation-preview-steps","name":"Data Import Wizard Validation Preview Steps","pattern_group":"forms","kind":"form-pattern","status":"draft","surface_refs":["internal-tools","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.action.destructive","color.semantic.border.default","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/data-import-wizard-validation-preview-steps.tsx","component_refs":["button"],"term_refs":["data-import-wizard-validation-preview","wizard"],"source_refs":["retool-csv-import-flow-blog"],"last_verified":"2026-07-12","source_path":"recipes/forms/data-import-wizard-validation-preview-steps.md","body":"## Intent\n\nA data import wizard validation preview steps recipe implements the `data-import-wizard-validation-preview` term's upload-to-summary contract as five discrete steps: upload, map, validate, preview, confirm. Each step is its own screen rather than a single scrolling form, per-row validation errors surface inline in the preview table instead of only as an aggregate count, and a failing row can be fixed or explicitly skipped without aborting the whole import. This is the structure Retool/Linear/Stripe-style admin CSV import flows repeat.\n\n## Anatomy\n\n- Step indicator: upload / map / validate / preview / confirm, with the current step visually distinct from completed and upcoming ones.\n- Column mapping table: source column paired with target field, with type coercion implied by the target field's type.\n- Row-level validation: each preview row shows its own error inline, with an explicit fix-or-skip choice rather than a single blocking error banner.\n- Preview table: shows the first N rows post-mapping, labeled with target schema column names, not the raw source column names.\n- Summary screen: imported / skipped / failed counts once the import completes.\n\n## States\n\n- Upload: no mapping or preview yet, only the file input and step indicator.\n- Map: column mapping table is editable; no row-level errors are shown yet.\n- Validate/Preview: preview rows render with per-row error state where applicable; error count is visible as a scannable badge.\n- Row error: shows an inline message plus both \"fix\" and \"skip row\" actions — never only an abort-the-whole-import path.\n- Confirm: final summary counts render after the import completes; the step indicator marks all prior steps done.\n\n## Variants\n\n- Full five-step wizard (default, this recipe) for first-time or infrequent bulk imports.\n- Collapsed three-step variant (upload, validate+preview combined, confirm) when column mapping is fixed/non-configurable for a given import type.\n- Re-run variant that pre-fills mapping from the previous successful import, skipping straight to validate.\n\n## Code\n\n```tsx\nexport function DataImportWizardValidationPreviewSteps({ step, mappings, previewRows, summary, onFixRow, onSkipRow, onContinue }: DataImportWizardStepsProps) {\n const errorCount = previewRows.filter((row) => row.error).length\n return (\n <section className=\"flex flex-col gap-3 rounded-md border bg-card p-4 text-card-foreground\">\n {/* step indicator, column mapping table (map step), preview table with per-row fix/skip (validate/preview step), summary counts (confirm step) */}\n </section>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/data-import-wizard-validation-preview-steps.tsx`.\n\n## Checks\n\n- Every preview row with an error shows both a fix action and a skip-row action, not only an abort option.\n- The preview table's column labels match the target schema, not the raw uploaded column names.\n- The step indicator always reflects the actual current step — a user cannot land on \"preview\" while still seeing \"map\" highlighted.\n- The confirm summary's three counts (imported/skipped/failed) always sum to the total row count submitted.\n- Changing a column mapping after validation invalidates the stale preview rather than silently keeping outdated validation results.\n\n## Anti-patterns\n\n- **Single scrolling form for all steps**: collapsing upload/map/validate/preview/confirm into one long form loses the step indicator's orientation value and makes partial progress unclear.\n- **Aggregate-only error count**: showing \"12 rows failed\" with no per-row detail forces the user to guess which records need fixing.\n- **Abort-only on row error**: forcing the user to cancel the entire import because of one bad row wastes the successfully mapped/validated rows.\n- **Preview table using source column names**: showing raw uploaded headers instead of target field labels makes it hard to confirm the mapping actually did what was intended.\n\n## Agent notes\n\n- prompt_phrases: \"업로드, 매핑, 검증, 미리보기, 확인 단계로 나뉜 data import wizard를 만들어줘\", \"행 단위 오류에 고치기와 건너뛰기 선택지가 있는 CSV 임포트 미리보기를 넣어줘\"\n- fallbacks: if per-row fix-in-place isn't feasible, skip-row plus a downloadable error report is an acceptable reduced variant — do not silently drop failed rows from the summary count.\n- component composition: implements the same term (`data-import-wizard-validation-preview`) already in the dictionary; this recipe is the step-by-step structural realization, reusing the generic `wizard` step-indicator pattern rather than inventing a new one."},{"id":"mobile-signup-field-stack","name":"Mobile Signup Field Stack","pattern_group":"forms","kind":"form-pattern","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.border.focus","color.semantic.action.destructive"],"code_asset":"examples/ui-vocabulary-site/src/components/mobile-signup-field-stack.tsx","component_refs":[],"term_refs":["mobile-form-field-stack"],"source_refs":["apple-hig-components"],"last_verified":"2026-07-12","source_path":"recipes/forms/mobile-signup-field-stack.md","body":"## Intent\n\n`mobile-form-field-stack` names the structural constraint (48×48 touch targets, next-field keyboard action) but not the field-level implementation. This recipe fixes the per-field keyboard type dispatch (email/numeric/secure), the logical tab order driven by the keyboard's own return key, and blur-time (not keystroke-time) validation for a stacked signup/login form.\n\n## Anatomy\n\n- Stacked label-input-helper units: one label, one input, one helper/error line per field, stacked vertically.\n- Secure text field for password: masked input with `autoComplete=\"new-password\"`, distinct from plain text fields.\n- Per-field keyboard type: `inputMode`/`type` set per field purpose (email keyboard for email, default for name, masked for password).\n- Next-field return key action: `enterKeyHint=\"next\"` on every field except the last, `\"done\"` on the last.\n- Submit CTA: pinned at the end of the stack, a full-width button separate from the last field's return key action.\n\n## States\n\n- **Untouched**: no helper/error shown beyond the field's static hint (e.g. password's minimum-length note).\n- **Focused**: visible focus ring (`focus:ring-2`), current field highlighted so the active position in the stack is unambiguous.\n- **Touched + invalid**: error replaces the helper text only after the field has been blurred at least once — never on every keystroke.\n- **Touched + valid**: helper text (if any) reappears, no error state.\n\n## Variants\n\n- Field set is illustrative (name/email/password) — swap in the actual fields needed, keeping one keyboard-type decision and one validation-timing decision per field.\n- Submit CTA can be `pinned` (fixed above the keyboard) or `inline` (part of the scrolling stack, as implemented) depending on form length; both are valid per the parent term.\n\n## Code\n\n```tsx\n<input\n id={field.id}\n type={field.type}\n inputMode={field.inputMode}\n enterKeyHint={field.enterKeyHint}\n autoComplete={field.type === \"password\" ? \"new-password\" : field.id}\n onBlur={() => setTouched((current) => ({ ...current, [field.id]: true }))}\n onKeyDown={(event) => {\n if (event.key === \"Enter\" && field.enterKeyHint === \"next\") {\n event.preventDefault()\n focusNext(index)\n }\n }}\n className=\"h-11 min-h-11 rounded-md border px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring\"\n/>\n```\n\nFull field definitions, focus-ref wiring, and the colocated `MobileSignupFieldStackDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- Every field is at least 44×44 (`h-11 min-h-11`, ~44px) per the parent term's touch-target floor.\n- Password field uses a masked `type=\"password\"` input with `autoComplete=\"new-password\"`, not a plain text field.\n- Pressing the keyboard's return/next action on a non-final field moves focus to the next field without requiring a tap.\n- Validation errors only appear after a field has been blurred at least once (`touched`), never while the user is still actively typing into it.\n\n## Anti-patterns\n\n- **Wrong keyboard type per field**: using the default text keyboard for an email field (instead of `inputMode=\"email\"`) forces users to manually switch keyboards to type `@`.\n- **Keystroke-time validation**: showing an error on every keystroke (e.g. \"too short\" while the user is still typing the password) is noisy and reads as broken rather than helpful — validate on blur.\n- **Swipe-only field navigation**: relying on the user to manually tap each next field, or requiring a swipe gesture to move between fields, ignores the keyboard's own next/return action that most users already expect.\n- **Sub-44px touch targets**: shrinking field height below the 44×44 floor to fit more fields on screen trades density for tap accuracy, contradicting the parent term's core constraint.\n\n## Agent notes\n\n- `prompt_phrases`: \"모바일 가입 폼에 이름/이메일/비밀번호를 세로로 쌓아줘\", \"이메일 필드는 이메일 키보드가 뜨게 해줘\", \"다음 필드로 넘어가는 키보드 액션을 연결해줘\", \"비밀번호는 8자 이상 검증을 blur 시점에만 보여줘\"\n- If the form only has a single field, use a plain `text-field` instead of this stack — the term's own `anti_use` already says one field doesn't need the stack contract.\n- Fallback: if a UI kit doesn't expose `enterKeyHint`, at minimum wire the `Enter`/`Return` keydown to advance focus (as implemented) so keyboard-driven progression still works."},{"id":"responsive-content-grid","name":"Responsive Content Grid","pattern_group":"layout","kind":"block","status":"draft","surface_refs":["websites","saas-dashboards","commerce","documentation"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.4","dimension.space.8","dimension.space.12","dimension.radius.md","typography.scale.sm","typography.scale.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/responsive-content-grid.tsx","component_refs":[],"term_refs":["grid","responsive-stack","responsive-layout","container"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-10","source_path":"recipes/layout/responsive-content-grid.md","body":"## Intent\n\nA responsive content grid arranges peer items into as many columns as the content can support, then collapses without changing their reading order. It is a layout contract for cards, examples, products, or records, not a decorative checkerboard.\n\n## Anatomy\n\n- Outer container: a content-driven maximum width and page padding.\n- Grid: one source order, explicit gap, and breakpoint-based column count.\n- Item boundary: `min-w-0` plus a consistent internal spacing contract.\n- Content priority: heading and primary information remain visible before optional media or metadata.\n\n## States\n\n- Mobile: one column with no clipped actions or horizontal scroll.\n- Intermediate: two columns after each item can preserve its minimum useful width.\n- Wide: three or four columns only when content remains readable.\n- Sparse data: the final row stays left aligned; empty slots are not rendered as placeholders.\n\n## Variants\n\n- Equal cards for peer choices.\n- Dense records with a smaller gap and stronger row scanning.\n- Featured item spanning columns when its information priority is genuinely higher.\n- Media grid with a fixed aspect ratio and text below.\n\n## Code\n\n```tsx\nexport function ResponsiveContentGrid({ items }: ResponsiveContentGridProps) {\n return (\n <section className=\"mx-auto w-full max-w-5xl\">\n <div className=\"grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3\">\n {items.map((item) => (\n <article key={item.id} className=\"min-w-0 rounded-lg border bg-card p-6 text-card-foreground\">\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"min-w-0\">\n <h3 className=\"truncate text-base font-medium\">{item.title}</h3>\n <p className=\"mt-2 text-sm leading-6 text-muted-foreground\">{item.description}</p>\n </div>\n {item.status ? <Badge variant=\"secondary\">{item.status}</Badge> : null}\n </div>\n {item.actions?.length ? (\n <div className=\"mt-6 flex flex-wrap gap-2\">\n {item.actions.map((action) => (\n <Button key={action.label} size=\"sm\" type=\"button\" variant=\"outline\" onClick={action.run}>\n {action.label}\n </Button>\n ))}\n </div>\n ) : null}\n </article>\n ))}\n </div>\n </section>\n )\n}\n```\n\n## Checks\n\n- Breakpoints follow the point where content no longer fits, not device names alone.\n- DOM order remains the reading and keyboard order at every width.\n- Each item and text wrapper has `min-w-0` where truncation or overflow is possible.\n- At mobile width the page has zero horizontal overflow and primary actions remain visible.\n- Empty results use an explicit empty state instead of an empty grid frame.\n\n## Anti-patterns\n\n- **Desktop grid squeezed smaller**: three columns persist until text and actions clip.\n- **Visual reordering**: CSS order creates a different keyboard and screen-reader sequence.\n- **Card mosaic without information priority**: random spans make peer items appear unequal.\n- **Nested frame repetition**: every item contains several more bordered cards instead of a clear internal hierarchy.\n\n## Agent notes\n\n- prompt_phrases: \"content-driven responsive grid with stable source order\", \"one-to-three column product grid with mobile overflow check\"\n- fallbacks: use a vertical list when comparison depends on aligned columns or rows.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 2, and 5."},{"id":"sidebar-application-shell","name":"Sidebar Application Shell","pattern_group":"layout","kind":"block","status":"draft","surface_refs":["saas-dashboards","internal-tools","documentation"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.space.8","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/sidebar-application-shell.tsx","component_refs":[],"term_refs":["app-shell","sidebar-nav","collapsible-sidebar","sidebar-dashboard-layout"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-10","source_path":"recipes/layout/sidebar-application-shell.md","body":"## Intent\n\nA sidebar application shell gives repeated product navigation a stable rail while keeping the task surface flexible. It is appropriate for dashboards, internal tools, and documentation products where users move among peer work areas many times per session.\n\n## Anatomy\n\n- Sidebar rail: brand or workspace identity, primary navigation, and optional account controls.\n- Active item: one unmistakable selected state, not a second primary CTA.\n- Main surface: `minmax(0, 1fr)` content that owns its own max width and overflow.\n- Mobile trigger: opens the same navigation model as a temporary sheet or drawer.\n\n## States\n\n- Expanded: labels and icons are visible.\n- Collapsed: icons retain accessible names and active state.\n- Mobile closed/open: navigation leaves the content flow and restores focus on dismiss.\n- Long navigation: the rail scrolls independently while account actions remain reachable.\n\n## Variants\n\n- Light rail with bordered content.\n- Dark rail with semantic text contrast.\n- Compact icon rail for dense operator tools.\n- Sidebar with a top header when global actions must remain separate from section navigation.\n\n## Code\n\n```tsx\nexport function SidebarApplicationShell({ items, activeId, onSelect, children }: SidebarApplicationShellProps) {\n const [expanded, setExpanded] = useState(true)\n\n return (\n <div className=\"grid min-h-[28rem] overflow-hidden rounded-lg border bg-background lg:grid-cols-[auto_minmax(0,1fr)]\">\n <aside className={cn(\"flex flex-col gap-1 border-b bg-card p-2 lg:border-b-0 lg:border-r\", expanded ? \"lg:w-56\" : \"lg:w-16\")}>\n <button\n aria-expanded={expanded}\n aria-label={expanded ? \"Collapse sidebar\" : \"Expand sidebar\"}\n className=\"mb-1 inline-flex size-9 items-center justify-center self-start rounded-md text-muted-foreground transition hover:bg-accent hover:text-accent-foreground\"\n type=\"button\"\n onClick={() => setExpanded((value) => !value)}\n >\n <PanelLeftIcon aria-hidden=\"true\" className=\"size-4\" />\n </button>\n <nav aria-label=\"Workspace\" className=\"flex flex-col gap-1\">\n {items.map((item) => {\n const isActive = item.id === activeId\n const Icon = item.icon\n return (\n <button\n key={item.id}\n aria-current={isActive ? \"page\" : undefined}\n aria-label={expanded ? undefined : item.label}\n className={cn(\"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition\", isActive ? \"bg-muted text-foreground\" : \"text-muted-foreground hover:bg-accent/50 hover:text-foreground\")}\n type=\"button\"\n onClick={() => onSelect(item.id)}\n >\n <Icon aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n {expanded ? <span className=\"truncate\">{item.label}</span> : null}\n </button>\n )\n })}\n </nav>\n </aside>\n <main className=\"min-w-0 overflow-y-auto bg-background p-6\">{children}</main>\n </div>\n )\n}\n```\n\n## Checks\n\n- The rail represents repeated peer navigation, not one page's local filters.\n- Active state is exposed with `aria-current` and a visible semantic surface change.\n- Collapse controls preserve accessible names and do not strand keyboard focus.\n- Main content uses `min-w-0`; tables and code cannot force the rail off-screen.\n- Mobile behavior uses a dismissible overlay or sheet and restores focus to its trigger.\n\n## Anti-patterns\n\n- **Permanent desktop rail squeezed onto mobile**: labels and content become unusably narrow instead of switching to a temporary surface.\n- **Icon-only mystery navigation**: collapsed items lose accessible labels and tooltips.\n- **Two independent scroll pages**: body and sidebar scroll unpredictably without a deliberate sticky/overflow model.\n- **Every item looks active**: filled rows erase the current-location signal.\n\n## Agent notes\n\n- prompt_phrases: \"responsive application shell with collapsible sidebar and stable content column\", \"operator dashboard rail with explicit active state\"\n- fallbacks: use a topbar when there are fewer than four peer destinations or navigation is infrequent.\n- canonical guidance: `docs/design-system/principles.md` principles 1, 2, and 5."},{"id":"grain-texture-overlay","name":"Grain Texture Overlay","pattern_group":"marketing","kind":"visual-effect","status":"draft","surface_refs":["websites","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/grain-texture-overlay.tsx","component_refs":["mesh-gradient-surface"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/marketing/grain-texture-overlay.md","body":"## Intent\n\nA grain texture overlay layers subtle procedural noise above a gradient or brand surface — it removes visible banding on smooth gradients and adds analog film texture. Tier ① (SVG): CSS `filter` alone cannot generate procedural noise, so an SVG `feTurbulence` tile embedded as a data URI does the work — still zero JS, zero requests, zero dependencies.\n\n## Anatomy\n\n- A noise tile: inline SVG data URI with `feTurbulence type=\"fractalNoise\"` + desaturating `feColorMatrix`, sized ~180px and tiled by `background-repeat`.\n- An absolutely-positioned overlay div above the surface, `mix-blend-soft-light`, opacity as the strength knob (keep ≤0.3).\n- `aria-hidden=\"true\"` and `pointer-events: none` — the layer is pure decoration and must never intercept interaction or reach assistive tech.\n- The host container clips the overlay (`overflow-hidden`).\n\n## States\n\n- Static only — grain that animates (\"TV static\") is a different, attention-grabbing effect and an experimental touch requiring manual opt-in (signature principle 5).\n- Dark mode: soft-light blending adapts to the underlying tokens; verify strength doesn't muddy dark surfaces (grain reads stronger on dark).\n\n## Variants\n\n- Banding fix over `mesh-gradient-surface` (the default pairing).\n- Brand texture over solid accent sections.\n- Photo treatment: consistent grain across an image set (aligns with the site's Image Treatment showcase).\n\n## Code\n\n```tsx\nconst GRAIN_SVG = encodeURIComponent(\n '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"180\" height=\"180\"><filter id=\"n\"><feTurbulence type=\"fractalNoise\" baseFrequency=\"0.9\" numOctaves=\"2\" stitchTiles=\"stitch\"/><feColorMatrix type=\"saturate\" values=\"0\"/></filter><rect width=\"100%\" height=\"100%\" filter=\"url(#n)\"/></svg>',\n)\n\n<div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-0 mix-blend-soft-light\"\n style={{ backgroundImage: `url(\"data:image/svg+xml,${GRAIN_SVG}\")`, opacity: strength }}\n/>\n```\n\n## Checks\n\n- Overlay is `aria-hidden` + `pointer-events: none` — no interaction path or screen-reader noise.\n- Strength stays subtle (≤0.3) and identical across surfaces in one product.\n- Noise tile uses `stitchTiles=\"stitch\"` so the repeat seam is invisible.\n- Text over the grained surface still passes contrast (grain lowers effective contrast slightly).\n\n## Anti-patterns\n\n- **Grain as loading noise**: animating the noise tile — becomes a distraction and a `prefers-reduced-motion` violation.\n- **Heavy grain**: opacity above ~0.4 reads as dirt, not texture, and visibly degrades text contrast.\n- **PNG noise asset**: shipping a raster noise image when the SVG data URI is smaller, resolution-independent, and request-free.\n- **Interactive layer on top**: placing the overlay above buttons without `pointer-events: none` silently kills clicks — the failure is invisible in a screenshot.\n\n## Agent notes\n\n- prompt_phrases: \"feTurbulence grain overlay as data URI with soft-light blending to fix gradient banding\", \"aria-hidden pointer-events-none film grain layer over a token-derived surface\"\n- fallbacks: if SVG filters are unavailable (vanishingly rare), the overlay simply doesn't render — the surface below remains fully functional.\n- canonical guidance: `knowledge/expressive-stack.md` tier ① decision row \"그레인/필름 노이즈 텍스처\"."},{"id":"landing-hero","name":"Landing Hero","pattern_group":"marketing","kind":"block","status":"draft","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.action.primary","color.semantic.text.on-accent","color.semantic.border.default","dimension.radius.xl","typography.font.sans","typography.scale.xl","typography.scale.2xl"],"code_asset":"examples/ui-vocabulary-site/src/components/landing-hero.tsx","component_refs":["topbar-command-search"],"term_refs":["command-palette","autocomplete"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-07","source_path":"recipes/marketing/landing-hero.md","body":"## Intent\n\nThe first-viewport block for a public product/design-system homepage: a centered product name or direct promise, one line of supporting copy naming the two things the product actually is, two calls to action, and a single restrained proof surface underneath (here, a live search field) instead of a wall of decorative cards. It exists to convert a first-time visitor in one scroll-length without resorting to a split hero (text left / mockup right) or a fake analytics dashboard — both of which `DESIGN.md` §8 explicitly bans for this product's landing page.\n\n## Anatomy\n\n- Section root: full-bleed `surface.base` background, generous top/bottom padding, `overflow-hidden` to host any ambient background field without clipping content oddly.\n- Optional ambient layer (`FloatingField` in the current asset): a pointer-reactive decorative field sitting behind content at a lower stacking order — never a blob/orb gradient (banned, §8).\n- `h1`: centered, large clamp-based type scale, single line of product name or promise. Uses `typography.scale.2xl` as the floor of its clamp range.\n- Subcopy `p`: centered, max-width constrained (`max-w-2xl`) so line length stays readable, one sentence naming both the public site and the agent-facing system.\n- CTA row: two buttons, `flex-col` on mobile → `flex-row` on `sm:`, gap between them.\n - Primary: solid `color.semantic.action.primary` fill, `color.semantic.text.on-accent` text, trailing icon.\n - Secondary: outline only, `color.semantic.border.default`, transparent fill.\n- Proof surface: exactly one below the CTA row — in the current asset this is a live search input with a suggestion popover (`component_refs: topbar-command-search`), not a decorative dashboard mockup.\n\n## States\n\n- **Default (Centered)**: title, subcopy, two CTAs, and the search proof surface stacked and centered — the shipped state.\n- **Search proof surface focused**: input expands/opens a suggestion popover beneath it (delegates to the `topbar-command-search`-family state machine: collapsed/open-empty/open-typed/keyboard nav). The hero block itself does not own this state, it only hosts the component.\n- **Reduced motion**: any ambient pointer-reactive field must respect `prefers-reduced-motion` and render static or fully disabled.\n- **Responsive reflow**: at narrow widths the CTA row stacks vertically and the `h1` clamp shrinks; copy must never truncate or get clipped by the section's `overflow-hidden`.\n\n## Variants\n\n- **Centered** (current, shipped): title → subcopy → CTA row → single proof surface, all centered on one column. Use when the product has one clear promise and one clear next action (search or explore).\n- **Proof**: swaps the plain search field for the \"Hero Proof Surface\" spec in `DESIGN.md` §7 — a compact design-system workspace preview (left nav / central preview / right inspector, labels like Docs, Patterns, Showcase, Resources, Pro) shown *below* the centered title instead of beside it. Still one column, still centered — never split left-text/right-mockup (banned).\n- **Compact**: same content order, reduced vertical padding and a smaller `h1` clamp ceiling, for use as a secondary/nested hero (e.g. inside a sub-page) where the full first-viewport treatment would be excessive.\n\n## Code\n\n```tsx\n<section className=\"relative isolate overflow-hidden bg-white px-4 pb-10 pt-14 md:px-8 md:pb-14 md:pt-20 lg:px-10\">\n <div className=\"pointer-events-none absolute inset-0 -z-10 bg-white\" />\n <FloatingField />\n <div className=\"relative z-30 mx-auto flex max-w-[1180px] min-w-0 flex-col items-center\">\n <h1 className=\"mt-4 text-center text-[clamp(3.5rem,16vw,9.5rem)] font-semibold leading-[0.9] tracking-normal text-slate-950\">\n Askewly Design\n </h1>\n <p className=\"mt-7 w-full max-w-2xl text-center text-base leading-7 text-slate-600 sm:text-lg sm:leading-8 md:text-xl\">\n A visual library and agent-ready system for designing better product interfaces.\n </p>\n\n <div className=\"mt-7 flex flex-col gap-3 sm:flex-row\">\n <Button\n className=\"h-11 rounded-full bg-askewly-violet px-6 text-white hover:bg-askewly-violet/90\"\n type=\"button\"\n onClick={() => onNavigate({ page: \"plus\", filter: \"nav:plus-application-ui\" })}\n >\n Get Started\n <ArrowRight aria-hidden=\"true\" className=\"size-4\" />\n </Button>\n <Button\n className=\"h-11 rounded-full border-slate-300 bg-white px-6 text-slate-950 hover:bg-slate-50\"\n variant=\"outline\"\n type=\"button\"\n onClick={() => onNavigate({ page: \"docs\", filter: \"nav:docs-getting-started-setup\" })}\n >\n Open Docs\n </Button>\n </div>\n\n <HeroSearch filter={filter} terms={terms} onNavigate={onNavigate} onSearch={onSearch} />\n </div>\n</section>\n```\n\nFull hero markup, `FloatingField`, and the `HeroSearch` proof-surface component live in `code_asset`; this excerpt is the structural contract (title → subcopy → CTA pair → single proof surface), not the full implementation.\n\n## Checks\n\n- Known deviation (2026-07-07, found by the recipe-consumption demo): the shipped hero code uses pill-radius CTAs, contradicting the `button` recipe spec (radius `sm`) and DESIGN.md §8. New implementations must follow the `button` recipe; the shipped hero is queued for reconciliation in the product-surface pass.\n- Exactly one `h1` in the hero, centered, no split-column layout with a mockup/card on the opposite side.\n- Exactly two CTAs, one primary (solid fill) and one secondary (outline) — not more, not a single lonely button.\n- Subcopy mentions both the public reference site and the agent-facing design system, not just one.\n- Below the CTAs there is exactly one proof surface (search field, or the Proof-variant workspace preview) — never multiple unrelated decorative cards.\n- Ambient background field (if present) has a `motion-reduce` fallback and does not read as a purple-dominated page (§8) on its own.\n- When the product has a real UI, the proof surface prefers an actual product screen (real data, real states) over illustration or abstract mockups — the UI proves itself (taste ledger T-9, Codex measured).\n- CTA buttons have visible hover/active/focus-visible states; focus ring must be keyboard-reachable.\n- At narrow viewports, title and subcopy reflow without clipping or horizontal scroll from the section's `overflow-hidden`.\n\n## Anti-patterns\n\n- **Split hero**: text on one side, product screenshot/card mockup on the other. `DESIGN.md` §8 bans this explicitly for this product — the hero must stay single-column and centered.\n- **Decorative blob/orb backgrounds**: gradient blobs or orb shapes behind the title are banned (§8); use a restrained ambient field (e.g. a pointer-reactive dot/line field) or nothing.\n- **Purple-dominated page**: leaning on `color.semantic.action.primary` (the violet) as a large background wash instead of a small accent on the primary CTA only — the hero background stays `surface.base`, not tinted.\n- **Fake metrics dashboard as the proof surface**: substituting the search field or workspace preview with a generic SaaS analytics mockup (charts, fabricated KPI numbers) is banned unless the page's selected mode is explicitly Dashboard (§7 Hero Proof Surface, §8).\n\n## Agent notes\n\n- `prompt_phrases`: \"중앙 정렬 hero에 제품 약속과 CTA 2개, 그 아래 proof surface 하나만 넣어줘\", \"split hero 말고 한 칼럼으로, 배경에 blob 쓰지 말고\", \"Proof 버전으로 바꿔서 검색창 대신 좌측 nav/중앙 preview/우측 inspector 워크스페이스 미리보기 보여줘\"\n- Fallback: if the host page has no `terms`-shaped search dataset to power a live proof surface, fall back to the Proof variant's static workspace preview (left nav / central preview / right inspector) rather than leaving the CTA row as the last element — the block's contract requires one proof surface below the CTAs.\n- If the product needs a two-column layout with a real product screenshot, that is a different, currently-unbanned-elsewhere pattern outside this recipe's contract — do not force it into `landing-hero`, which is scoped to the centered/no-split direction confirmed in `DESIGN.md` §7-§8."},{"id":"magnetic-hover-button","name":"Magnetic Hover Button","pattern_group":"marketing","kind":"motion-pattern","status":"draft","surface_refs":["websites"],"tokens_used":["color.semantic.action.primary","color.semantic.text.on-accent","color.semantic.border.focus","color.semantic.surface.muted","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/magnetic-hover-button.tsx","component_refs":["button"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/marketing/magnetic-hover-button.md","body":"## Intent\n\nA magnetic hover button leans toward the cursor while hovered and springs back on leave. Tier ② in `knowledge/expressive-stack.md`: CSS `:hover` cannot know where the cursor is, so JS reads pointer coordinates and a spring drives a CSS transform. **This is an experimental touch (signature principle 5): showcase and marketing CTAs on explicit request only — never a product-wide button default.**\n\n## Anatomy\n\n- A regular, fully functional button — the magnetism is additive decoration on top of an intact button contract.\n- Pointer-move handler mapping cursor offset from center to a small translate (≤12px pull; attraction, not chase).\n- Spring-smoothed motion values so entry, movement, and snap-back are continuous and interruptible.\n- Reduced-motion branch that disables the lean entirely; keyboard focus/activation identical with or without magnetism.\n\n## States\n\n- Rest: plain button, token styling.\n- Hover: leans toward cursor, spring-smoothed.\n- Leave: springs back to origin (no teleport).\n- Focus-visible: standard ring; magnetism does not react to keyboard focus (no fake pointer).\n- Reduced motion: static button, all function preserved.\n\n## Variants\n\n- Single hero CTA (the canonical use).\n- Icon button magnet in a showcase context.\n- Whole-card magnet — larger surface, smaller pull.\n\n## Code\n\n```tsx\nconst springX = useSpring(x, { stiffness: 320, damping: 22 })\n\nconst handleMove = (event: React.PointerEvent) => {\n if (reduceMotion || !ref.current) return\n const rect = ref.current.getBoundingClientRect()\n x.set(((event.clientX - rect.left) / rect.width - 0.5) * 2 * pull)\n}\n\n<motion.button style={{ x: springX, y: springY }} onPointerMove={handleMove} onPointerLeave={reset} />\n```\n\n## Checks\n\n- Click target and focus ring behave identically to a plain button — the lean never moves the hit area out from under the cursor (pull stays small).\n- `prefers-reduced-motion` disables the lean.\n- Spring returns to exact origin on leave — no residual offset accumulating across hovers.\n- Used sparingly: at most one magnetic element per viewport.\n\n## Anti-patterns\n\n- **Magnetism as the default button**: applying it product-wide turns a signature accent into noise and violates restraint.\n- **Large pull values**: >16px pull makes the button chase the cursor and misalign with its hit area.\n- **Hover-only affordance**: any behavior difference for keyboard users beyond the missing lean — the button must work identically.\n- **Tween instead of spring**: duration-based easing snaps when the cursor re-enters mid-return; the spring's interruptibility is the point.\n\n## Agent notes\n\n- prompt_phrases: \"magnetic CTA with spring-smoothed pointer lean, reduced-motion off, intact keyboard contract\"\n- fallbacks: without pointer input (touch), the component is just a button — no degradation to handle.\n- canonical guidance: `knowledge/expressive-stack.md` decision row \"커서 추종·마그네틱 hover\"(②); signature principle 5 (experimental touches are manual opt-in)."},{"id":"mesh-gradient-surface","name":"Mesh Gradient Surface","pattern_group":"marketing","kind":"visual-effect","status":"draft","surface_refs":["websites","saas-dashboards","commerce"],"tokens_used":["color.semantic.action.primary","color.semantic.accent.base","color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/mesh-gradient-surface.tsx","component_refs":["glass-panel","grain-texture-overlay"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/marketing/mesh-gradient-surface.md","body":"## Intent\n\nA mesh gradient surface gives a hero, brand section, or empty backdrop an organic multi-point color field without imagery. Tier ① (CSS-only) in `knowledge/expressive-stack.md`: several radial-gradient layers stacked on one element approximate a mesh — no JS, no dependency, static by default. Use it where a flat token background feels inert but a photo or 3D scene would be overkill.\n\n## Anatomy\n\n- One container with 3–5 `radial-gradient` background layers, each positioned at a different corner/edge.\n- Every color stop derived from project tokens via `color-mix(in oklch, var(--token) N%, transparent)` — never literal stops, so the surface re-themes with the token system.\n- A base `background` token underneath so uncovered areas stay on-theme in both modes.\n- Foreground content slot; the surface itself is decoration and carries no semantics.\n\n## States\n\n- Static (default): no animation — the field is composition, not motion.\n- Animated (opt-in only): color-stop drift via `@property` registered custom properties; must respect `prefers-reduced-motion` and is an experimental touch (signature principle 5 — manual opt-in).\n- Dark mode: mixes against the dark `background` token automatically because stops are token-derived — verify contrast of overlaid text in both modes.\n\n## Variants\n\n- Hero backdrop (full-bleed, content overlaid).\n- Card/section accent (bounded, rounded, bordered).\n- Backdrop for glass surfaces (pair with `glass-panel` — the blur needs a busy background to be visible).\n- Dark-base aura: on a near-black base, confine the gradient layers to one region (typically the top) as a soft aura and float content cards above it — mood without sacrificing content contrast (taste ledger T-10).\n\n## Code\n\n```tsx\nconst style: CSSProperties = {\n backgroundImage: [\n \"radial-gradient(42% 55% at 18% 22%, color-mix(in oklch, var(--primary) 34%, transparent) 0%, transparent 100%)\",\n \"radial-gradient(50% 60% at 82% 18%, color-mix(in oklch, var(--accent) 65%, transparent) 0%, transparent 100%)\",\n \"radial-gradient(55% 65% at 70% 85%, color-mix(in oklch, var(--primary) 22%, transparent) 0%, transparent 100%)\",\n \"radial-gradient(40% 45% at 12% 88%, color-mix(in oklch, var(--accent) 40%, transparent) 0%, transparent 100%)\",\n ].join(\", \"),\n}\nreturn <div className=\"relative overflow-hidden bg-background\" style={style}>{children}</div>\n```\n\n## Checks\n\n- All gradient stops derive from semantic tokens via `color-mix` — zero literal colors.\n- Text overlaid on the surface passes WCAG contrast in light and dark modes (test at the busiest gradient intersection).\n- The surface renders acceptably with banding on low-bit displays — pair with `grain-texture-overlay` when banding is visible.\n- Container clips its layers (`overflow-hidden`) so gradients never bleed past the radius.\n\n## Anti-patterns\n\n- **Invented palette**: hardcoded pastel/purple stops that ignore the project's tokens — the exact failure the style signature's token-derivation principle exists for.\n- **Decorative blob divs**: extra absolutely-positioned blurred circles to fake the mesh — one element with layered gradients does it cheaper and stays in normal flow.\n- **Tier escalation**: reaching for a WebGL shader when the gradient is static — per the expressive-stack decision table, animated organic noise justifies tier ④, a static field does not.\n- **Unreadable overlay**: dropping body text on the gradient without checking the busiest region's contrast.\n\n## Agent notes\n\n- prompt_phrases: \"token-derived mesh gradient hero backdrop from layered radial-gradients\", \"CSS-only multi-point color field using color-mix over project tokens\"\n- fallbacks: browsers without `color-mix` support fall back to the base background token (layers resolve invalid) — surface degrades to a flat themed background, never breaks.\n- canonical guidance: `knowledge/expressive-stack.md` tier ① + decision table row \"메시/원뿔형 그라디언트 배경\"."},{"id":"pinned-scroll-sequence","name":"Pinned Scroll Sequence","pattern_group":"marketing","kind":"motion-pattern","status":"draft","surface_refs":["websites","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/pinned-scroll-sequence.tsx","component_refs":[],"term_refs":[],"source_refs":["https://github.com/greensock/GSAP"],"last_verified":"2026-07-28","source_path":"recipes/marketing/pinned-scroll-sequence.md","body":"## Intent\n\nA pinned scroll sequence holds a section in the viewport while the user's scroll scrubs a multi-step narrative, then releases it back into the flow. Tier ② in `knowledge/expressive-stack.md` — the decision-table row \"핀 고정·스크럽·스크롤 스토리텔링\" that tier ① CSS `animation-timeline` cannot express: pinning and cross-element sequencing need JS orchestration (GSAP ScrollTrigger). For a plain \"appear on entry\" reveal, stay on tier ① (`scroll-driven-reveal`) — escalating to this recipe without pin/scrub need is a tier violation.\n\n## Anatomy\n\n- A scroll container that owns the effect: ScrollTrigger's `scroller` points at it, never at the page scroll — the sequence must not hijack surrounding layout.\n- A pinned stage (`pin: true`) whose steps are sequenced on one timeline; `scrub` maps scroll position to timeline progress (reversible by scrolling back).\n- Step elements marked with a data attribute, entered via `fromTo` at staggered timeline positions, previous steps dimming as the next arrives.\n- `gsap.context` + `revert()` cleanup on unmount — timelines and triggers must not outlive the component.\n- A `prefers-reduced-motion` branch that renders every step statically: no pin, no scrub, content complete.\n\n## States\n\n- Before entry: steps hidden, stage unpinned.\n- Pinned + scrubbing: scroll drives step-by-step reveal; direction reversible.\n- Released: pin ends, page flow resumes.\n- Reduced motion: static complete list — the narrative must read without any motion.\n\n## Variants\n\n- Dim-previous (default here): earlier steps fade to a fraction as later ones arrive — keeps one focal step (motion-principles §3: 한 번에 하나).\n- Keep-all: steps accumulate at full opacity — for short sequences where the set is the message.\n- Horizontal scrub: timeline translates a track on the x-axis — same contract, different axis.\n\n## Code\n\n```tsx\nconst timeline = gsap.timeline({\n scrollTrigger: {\n trigger: stage,\n scroller: container, // scoped — never the page scroll in embedded contexts\n start: \"top top\",\n end: \"+=600\",\n pin: true,\n scrub: 0.4,\n },\n})\nsteps.forEach((step, i) => timeline.fromTo(step, { opacity: 0, y: 24 }, { opacity: 1, y: 0 }, i))\n// cleanup: gsap.context(...) + ctx.revert() on unmount\n```\n\n## Checks\n\n- Scoped scroller: the demo/gallery embed must pass its own container as `scroller` — verify surrounding page scroll is unaffected.\n- Cleanup: unmount then remount must not duplicate triggers (gsap.context revert).\n- Reduced motion renders all content statically — no pin, no hidden steps.\n- Dynamic import keeps gsap out of the initial chunk until the demo mounts; record the bundle diff when adopting.\n- License: GSAP core + ScrollTrigger free for all uses since the Webflow acquisition — recheck `gsap.com/standard-license` before redistribution (toolshelf card guidance).\n\n## Anti-patterns\n\n- Escalating a simple entry reveal to ScrollTrigger — tier ① `scroll-driven-reveal` covers it with zero dependency.\n- Page-scroll pinning inside an embedded/gallery context — hijacks layout the component does not own.\n- Leaving triggers alive after unmount (no context/revert) — ghost pins corrupt later layout measurements.\n- Hiding narrative-critical content behind scrub progress with no reduced-motion static path — content loss for reduced-motion users.\n\n## Agent notes\n\n- prompt_phrases: \"pinned scroll storytelling section with scrubbed steps\", \"GSAP ScrollTrigger pin and scrub sequence scoped to its own container\"\n- fallbacks: reduced-motion renders the full step list statically; without JS the steps are plain stacked content — narrative never depends on scroll position.\n- canonical guidance: `knowledge/expressive-stack.md` tier ② + decision table row \"핀 고정·스크럽·스크롤 스토리텔링\"; plain entry reveals stay tier ① (`scroll-driven-reveal`).\n\n## Changelog\n\n- 2026-07-28: 초판 (VI8 — VI7 A 대기 판정 집행, GSAP 첫 도입)."},{"id":"scroll-driven-reveal","name":"Scroll-Driven Reveal","pattern_group":"marketing","kind":"motion-pattern","status":"draft","surface_refs":["websites","commerce","documentation"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/scroll-driven-reveal.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/marketing/scroll-driven-reveal.md","body":"## Intent\n\nA scroll-driven reveal fades/raises content in as it enters the scrollport, using CSS view-progress timelines (`animation-timeline: view()`). Tier ① in `knowledge/expressive-stack.md`: for a plain \"appear on entry\" mapping this needs no scroll listeners, no IntersectionObserver, and runs off the main thread. Escalate to tier ② (GSAP ScrollTrigger) only for pinning, scrubbing, or cross-element sequencing — per the decision table.\n\n## Anatomy\n\n- A reveal item whose hidden initial state (`opacity: 0`, small translate) is declared **only inside** `@supports (animation-timeline: view())` — the load-bearing safety: unsupported browsers see content immediately.\n- `animation: <keyframes> both` + `animation-timeline: view()` + `animation-range: entry 0% entry N%` mapping the entry window to animation progress.\n- A `prefers-reduced-motion: reduce` override that disables the animation (content visible, no movement).\n- The nearest scrollport (page or an `overflow` container) drives the timeline automatically.\n\n## States\n\n- Before entry: hidden (supported browsers only).\n- During entry: progress mapped to scroll position — reversible by scrolling back up.\n- Reduced motion: fully visible, no animation.\n- No support (Safari/Firefox partial as of 2026-07): fully visible static content — indistinguishable from \"already revealed\".\n\n## Variants\n\n- Landing section reveal (page scrollport).\n- Bounded feed/list reveal (inner `overflow-y-auto` scrollport).\n- Progress-linked treatments (scale/blur instead of rise) — same timeline, different keyframes.\n\n## Code\n\n```tsx\nconst REVEAL_CSS = `\n@supports (animation-timeline: view()) {\n .sdr-item {\n animation: sdr-reveal both;\n animation-timeline: view();\n animation-range: entry 0% entry 70%;\n }\n @keyframes sdr-reveal {\n from { opacity: 0; transform: translateY(1.25rem); }\n to { opacity: 1; transform: none; }\n }\n}\n@media (prefers-reduced-motion: reduce) {\n .sdr-item { animation: none; }\n}\n`\n```\n\n## Checks\n\n- The hidden initial state lives only under `@supports` — removing timeline support must leave all content visible (reveal failure ≠ content loss).\n- `prefers-reduced-motion` disables movement entirely.\n- Only `opacity`/`transform` animate — no layout properties on a scroll-linked timeline.\n- Reveal distance stays small (≤1.5rem) and the entry range ends well before center — content is readable when the user's eye reaches it.\n\n## Anti-patterns\n\n- **Hidden-by-default without @supports gate**: `opacity: 0` in base styles turns every unsupported browser into a blank page — the single worst failure of this pattern.\n- **JS re-implementation**: adding IntersectionObserver for a plain entry reveal when the CSS timeline covers it — tier escalation without justification.\n- **Reveal everything**: staggering every section element makes the page feel gated; reveal one structural unit per viewport at most.\n- **Scroll-linked layout animation**: animating height/margin on a scroll timeline causes continuous reflow.\n\n## Agent notes\n\n- prompt_phrases: \"CSS view() timeline reveal with @supports-gated hidden state and reduced-motion off-switch\", \"scroll-entry fade-rise without JS listeners\"\n- fallbacks: unsupported browsers get static visible content by construction; if pinning/scrubbing/sequencing is requested, use tier ② GSAP ScrollTrigger instead of stretching this recipe.\n- canonical guidance: `knowledge/expressive-stack.md` decision rows \"스크롤 진행률에 매핑된 애니메이션\"(①) vs \"핀 고정·스크럽\"(②)."},{"id":"shader-gradient-surface","name":"Shader Gradient Surface","pattern_group":"marketing","kind":"motion-pattern","status":"draft","surface_refs":["websites","commerce"],"tokens_used":["color.semantic.surface.muted","color.semantic.border.default","color.semantic.action.primary","color.semantic.accent.base","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/shader-gradient-surface.tsx","component_refs":[],"term_refs":[],"source_refs":["https://github.com/paper-design/shaders"],"last_verified":"2026-07-28","source_path":"recipes/marketing/shader-gradient-surface.md","body":"## Intent\n\nA shader gradient surface gives a hero or brand section an **animated** organic color field computed per-frame on the GPU (Paper Shaders `MeshGradient`). Tier ④ in `knowledge/expressive-stack.md` — the tier boundary against `mesh-gradient-surface` (tier ①) is animation: layered CSS radial-gradients approximate a static mesh, but frame-by-frame organic noise motion cannot be expressed in CSS. If the surface can be static, stay on tier ① — this recipe exists for the escalation, not as the default.\n\n## Anatomy\n\n- A lazy boundary (`React.lazy` + Suspense) so the shader runtime never enters the initial chunk — the mandatory tier-④ delivery contract (judgment rule 4).\n- Colors read from token CSS custom properties at mount (`getComputedStyle`) — no hardcoded hex; the shader is a renderer of the token system, not a new palette.\n- A `prefers-reduced-motion` branch: `speed 0` renders a static organic field — same look, no motion.\n- An error boundary whose fallback is a static CSS gradient built from the same token colors — WebGL failure degrades to tier ①, never to a broken canvas.\n- `aria-hidden` — the surface is decoration; content must never live only inside it.\n\n## States\n\n- Loading: static token-gradient fallback (indistinguishable from the reduced/failure states — one visual contract).\n- Running: animated organic field.\n- Reduced motion: frozen frame (speed 0).\n- WebGL unavailable: static CSS gradient fallback.\n\n## Variants\n\n- Hero backdrop (default): full-bleed behind hero content with an overlay for text contrast.\n- Panel accent: bounded card/section surface (the demo form).\n- Grain-mixed: add `grainMixer`/`grainOverlay` uniforms instead of stacking the separate grain-texture-overlay recipe.\n\n## Code\n\n```tsx\nconst MeshGradientLazy = lazy(() =>\n import(\"@paper-design/shaders-react\").then((m) => ({ default: m.MeshGradient })),\n)\n// colors: token custom properties read at mount — never hardcoded hex\nconst colors = TOKEN_VARS.map((v) => getComputedStyle(host).getPropertyValue(v).trim()).filter(Boolean)\n<Suspense fallback={staticTokenGradient}>\n <MeshGradientLazy colors={colors} distortion={0.8} swirl={0.5} speed={reduced ? 0 : 0.4} />\n</Suspense>\n```\n\n## Checks\n\n- Initial-chunk isolation: build output must show the shader module in a separate chunk; record the diff when adopting.\n- Token roundtrip: changing the token custom properties changes the shader colors without code edits.\n- Reduced-motion query honored at mount; failure fallback verified by forcing the boundary.\n- Text over the surface passes contrast against the *darkest and lightest* frames, not one snapshot.\n\n## Anti-patterns\n\n- Hardcoding hex arrays into the shader (the showcase's earlier shortcut) — breaks the token SSOT and silently drifts from theme changes.\n- Using this where a static gradient reads identically — tier violation; use `mesh-gradient-surface`.\n- Placing readable content only inside the canvas — screen readers and fallback states lose it.\n- Shipping the shader in the main bundle \"because it's small\" — the lazy boundary is the pattern, matching `lazy-three-object-scene`.\n\n## Agent notes\n\n- prompt_phrases: \"animated GPU mesh gradient hero from project tokens\", \"shader gradient surface with reduced-motion freeze and static fallback\"\n- fallbacks: Suspense/loading, reduced-motion, and WebGL failure all resolve to the same static token gradient — one visual contract, three triggers.\n- canonical guidance: `knowledge/expressive-stack.md` tier ④ + decision table row \"애니메이팅되는 유기적 셰이더 그라디언트\"; static alternative = `mesh-gradient-surface` (tier ①).\n\n## Changelog\n\n- 2026-07-28: 초판 (VI8 — VI7 A 대기 판정 집행, 쇼케이스 하드코딩 hex 를 토큰 판독으로 교정한 정본)."},{"id":"bottom-tab-bar","name":"Bottom Tab Bar","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.destructive"],"code_asset":"examples/ui-vocabulary-site/src/components/bottom-tab-bar.tsx","component_refs":[],"term_refs":["tab-bar"],"source_refs":["apple-hig-components"],"last_verified":"2026-07-12","source_path":"recipes/navigation/bottom-tab-bar.md","body":"## Intent\n\nA persistent bar fixed to the bottom of a mobile screen that switches between the app's top-level, equally-important destinations (`tab-bar` in the vocabulary). Unlike `mobile-segmented-tabs`, which switches views inside one screen, this bar changes the whole destination — home, search, activity, profile — and stays visible across all of them.\n\n## Anatomy\n\n- Bar container: full-width row, fixed to the bottom of the screen, sitting above the device's safe-area inset.\n- One button per destination (3-5 total): icon + a short 1-2 word label, always shown together — never icon-only.\n- Active indicator: the active tab's icon switches from outlined to filled and its text/icon color shifts from muted to full-emphasis foreground.\n- Optional badge: a small filled circle with a count, reserved for critical/actionable info (unread count), not decorative.\n\n## States\n\n- **Inactive tab**: outlined icon, muted label/icon color (`text-muted-foreground`).\n- **Active tab**: filled icon (`fill=\"currentColor\"`), full-emphasis color (`text-foreground`), `aria-current=\"page\"` and `aria-selected=\"true\"` for the current destination.\n- **Badge present**: rendered as a small absolutely-positioned circle on the icon; paired with visually-hidden text (`sr-only`, e.g. \"3 unread\") so the count isn't screen-reader-silent.\n- **Tap**: `onTabChange` fires immediately on click/tap — there is no drag or swipe state; switching between destinations is tap-only.\n\n## Variants\n\n- Destination count: keep to 3-5 (HIG). More than that should trigger an information-architecture review (move less-used destinations into a `More` screen or reconsider grouping) rather than reflowing the bar to fit extra tabs.\n- Trailing accessory (not implemented in the code asset): platforms like iOS allow a minimize-on-scroll trailing element (e.g. a mini player) docked beside the tab bar; treat that as a separate composed element, not a tab.\n\n## Code\n\n```tsx\n<button\n type=\"button\"\n role=\"tab\"\n aria-selected={isActive}\n aria-current={isActive ? \"page\" : undefined}\n onClick={() => onTabChange(tab.id)}\n className={cn(\n \"relative flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors\",\n isActive ? \"text-foreground\" : \"text-muted-foreground\"\n )}\n>\n <span className=\"relative\">\n <Icon className=\"size-5\" fill={isActive ? \"currentColor\" : \"none\"} aria-hidden=\"true\" />\n {tab.badgeCount ? (\n <span\n aria-hidden=\"true\"\n className=\"absolute -right-1.5 -top-1.5 flex size-3.5 items-center justify-center rounded-full bg-destructive text-[9px] font-semibold text-white\"\n >\n {tab.badgeCount}\n </span>\n ) : null}\n </span>\n <span>{tab.label}</span>\n</button>\n```\n\nFull tab list, layout, and the colocated `BottomTabBarDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- Every tab has both an icon and a visible text label — never ship icon-only tabs.\n- Active tab is distinguishable by more than color alone (filled vs outlined icon), so it still reads correctly for color-blind users.\n- Badge count has an `sr-only` text equivalent; the badge itself is `aria-hidden`.\n- `role=\"tab\"` + `aria-selected`/`aria-current` are present so assistive tech announces the current destination.\n- Bar respects the bottom safe-area inset on real devices (see `safe-area` term) so it doesn't collide with the home indicator.\n\n## Anti-patterns\n\n- **Icon-only tabs**: dropping the label to save space breaks HIG guidance and hurts discoverability — always pair icon + label.\n- **Using tabs for view-mode switching**: wiring this bar to swap sort/filter modes within the same screen instead of switching top-level destinations conflates it with `mobile-segmented-tabs` — keep tab bar reserved for destination switching only.\n- **Overflowing past 5 destinations**: adding a 6th+ tab instead of restructuring the IA (e.g. a `More` screen or moving a destination into settings) makes the bar cramped and harder to scan.\n- **Swipe-to-switch destinations**: allowing a horizontal swipe on the screen body to change the active tab (instead of requiring an explicit tap on the bar) contradicts the \"tabs switch only by tapping\" rule this recipe encodes.\n\n## Agent notes\n\n- `prompt_phrases`: \"하단에 tab bar를 넣고 홈 검색 설정 탭을 보여줘\", \"선택된 탭은 아이콘과 라벨을 강조해줘\", \"활동 탭에 안 읽은 알림 뱃지를 표시해줘\"\n- If the request needs same-screen view switching (not top-level destination switching), use `mobile-segmented-tabs` instead of adding more tabs here.\n- If destinations exceed 5 or the app also runs on a wide/tablet layout, consider `adaptive-split-view` (sidebar-convertible) rather than cramming more tabs into this bar.\n- Fallback: if no icon set matches the destination set, keep labels mandatory and use any consistent icon set — the label is the accessibility floor, not the icon choice."},{"id":"doc-search-cmdk-grouped-results-panel","name":"Doc Search Cmd-K Grouped Results Panel","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.overlay","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/doc-search-cmdk-grouped-results-panel.tsx","component_refs":["topbar-command-search"],"term_refs":["command-palette","search-suggestions"],"source_refs":["tailwind-plus-documentation-command-palette"],"last_verified":"2026-07-12","source_path":"recipes/navigation/doc-search-cmdk-grouped-results-panel.md","body":"## Intent\n\nA documentation site's Cmd/Ctrl+K search reuses the `command-palette` overlay shell (`topbar-command-search` implements the same input+popover idea inline in a topbar), but a docs search result set spans several kinds of content — pages, guides, API entries — and a flat list makes the reader scan section by section anyway. Grouping results under section headers (Docs/Guides/API) inside the same overlay does that scanning for them, without inventing a second overlay pattern.\n\n## Anatomy\n\n- Trigger: a topbar button showing a keyboard shortcut hint (handled by the caller, not this component — this recipe covers the overlay itself).\n- Overlay: `CommandDialog` (Radix `Dialog` + `cmdk`), with a search input pinned at the top.\n- Result list: one `CommandGroup` per section (e.g. Docs, Guides, API), each with its own heading and rows.\n- Row: label plus an optional one-line description, keyboard-navigable via `cmdk`'s built-in active-row handling.\n- Empty-query state: instead of an empty overlay, shows a \"Recent\" group (past queries) and a \"Suggested\" group (curated docs) — this absorbs the recent/suggested-terms behavior into the same panel rather than a separate recipe.\n- No-match state: a single `CommandEmpty` row when a non-empty query yields zero results in every group.\n\n## States\n\n- Empty query (`query === \"\"`): shows Recent + Suggested groups, no `CommandEmpty` row.\n- Typed query with matches: shows the section groups that have results; a group with zero matching rows is omitted entirely, not rendered with a \"no results\" placeholder.\n- Typed query with zero matches across every group: shows only `CommandEmpty`.\n- Keyboard navigation: arrow keys move the active row across group boundaries, Enter selects it — handled by `cmdk`, not re-implemented here.\n\n## Variants\n\n- Docs-scoped (this recipe): sections are content-type groups (Docs/Guides/API).\n- Ops-scoped variant: `command-palette`'s own description already covers an entity-scoped ops variant with visually separated destructive actions — that is a different scoping axis (entity, not content-type) and stays a `command-palette` usage note rather than a second grouped-results recipe.\n\n## Code\n\n```tsx\n<CommandDialog open={open} title=\"Search documentation\" onOpenChange={onOpenChange}>\n <CommandInput value={query} onValueChange={onQueryChange} placeholder=\"Search docs...\" />\n <CommandList>\n {isEmptyQuery ? (\n <>\n <CommandGroup heading=\"Recent\">{/* recent queries */}</CommandGroup>\n <CommandGroup heading=\"Suggested\">{/* suggested docs */}</CommandGroup>\n </>\n ) : (\n <>\n <CommandEmpty>No results found.</CommandEmpty>\n {groups.map((group) => (\n <CommandGroup key={group.id} heading={group.label}>\n {group.items.map((item) => <CommandItem key={item.id} value={item.label} onSelect={() => onSelect(item.id)} />)}\n </CommandGroup>\n ))}\n </>\n )}\n </CommandList>\n</CommandDialog>\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/doc-search-cmdk-grouped-results-panel.tsx`. Built on the `command` (cmdk) and `dialog` shadcn primitives.\n\n## Checks\n\n- A group with no matching rows for the current query is omitted from the list, not rendered with an empty sub-heading.\n- The Recent/Suggested empty-query content and the grouped-results typed-query content are mutually exclusive — never both visible at once.\n- `CommandEmpty` only renders when every group has zero rows, not per-group.\n- Keyboard active-row state survives moving from one group's last row to the next group's first row.\n\n## Anti-patterns\n\n- **Flat list pretending to be grouped**: rendering section labels as plain text inside one list instead of using `CommandGroup`, which breaks `cmdk`'s per-group keyboard semantics.\n- **Empty overlay on first open**: showing a blank result list before the user types anything, instead of the Recent/Suggested fallback content.\n- **Per-group \"no results\" rows**: rendering an explicit empty message inside every group instead of simply omitting empty groups.\n- **Reimplementing keyboard navigation**: hand-rolling arrow-key/active-index logic on top of `cmdk`'s `Command.List`/`Command.Item`, which already owns that behavior.\n\n## Agent notes\n\n- prompt_phrases: \"문서 사이트에 Cmd K로 여는 검색 패널을 만들어줘, 결과를 Docs/Guides/API로 그룹핑해줘\", \"검색어가 없을 때는 최근 검색어와 추천 문서를 보여주는 문서 검색 오버레이를 만들어줘\"\n- fallbacks: if only one section has results, still render it inside a `CommandGroup` with its heading rather than collapsing to a flat list — a second section can be added later without restructuring.\n- related: distinct from `topbar-command-search`, which renders search inline in the topbar via a `Popover` and is not grouped by content-type; this recipe is a full-overlay `CommandDialog` grouped by section."},{"id":"large-title-collapsing-header","name":"Large Title Collapsing Header","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.text.default","color.semantic.surface.base","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/large-title-collapsing-header.tsx","component_refs":[],"term_refs":["large-title-header","top-app-bar"],"source_refs":["material-m3-components"],"last_verified":"2026-07-12","source_path":"recipes/navigation/large-title-collapsing-header.md","body":"## Intent\n\n`large-title-header` names the visual states (big title / collapsed) but not the transition contract. This recipe fixes the scroll-driven large→small compression itself: the exact threshold behavior, the back button staying a separate always-present tap target distinct from the title transform, and the compact header filling in a background once collapsed so it visually separates from the scrolling content beneath it.\n\n## Anatomy\n\n- Large title text row: the screen's title rendered at a large type scale, left-aligned above the content.\n- Safe-area-top inset: the header sits above the device's safe area, not overlapping it.\n- Back-button leading: a fixed 44×44+ tap target, always present regardless of collapse state — never removed or resized during the transition.\n- Scroll-driven compress transition: a `sticky` header whose title size and background opacity respond to the list's scroll offset, not to any tap/drag gesture on the header itself.\n- Compact header post-collapse: once past the threshold, the header gains a bottom border and background fill so it reads as a distinct bar from the content scrolling underneath it.\n\n## States\n\n- **Expanded**: `scrollTop <= COLLAPSE_THRESHOLD_PX`. Title renders at the large type scale, header background and border are transparent (title appears to sit directly on the content).\n- **Compact**: `scrollTop > COLLAPSE_THRESHOLD_PX`. Title shrinks to the compact type scale, header gains a background fill and bottom border.\n- **Screen-reader active**: the header must not auto-hide on scroll direction the way a `top-app-bar` show/hide bar might — the title-collapse transform stays, but a hide-on-scroll behavior (if added) must be suppressed per the accessibility exception both terms already document.\n\n## Variants\n\n- Passive scroll-linked transform only — the header itself is not a tap or drag target for the compression; only the back button is interactive.\n- This recipe implements title-collapse only; it does not implement the separate scroll-direction show/hide behavior described on `top-app-bar` — combine the two only if the product actually needs both.\n\n## Code\n\n```tsx\n<div\n className={cn(\n \"sticky top-0 z-10 flex shrink-0 items-center gap-2 border-b px-4 transition-colors\",\n isCompact ? \"border-border bg-background py-3\" : \"border-transparent bg-background pb-3 pt-1\"\n )}\n>\n <button type=\"button\" aria-label=\"Back\" className=\"flex size-11 shrink-0 items-center justify-center rounded-full\">\n <ChevronLeftIcon className=\"size-5\" aria-hidden=\"true\" />\n </button>\n <p className={cn(\"font-semibold transition-all\", isCompact ? \"text-base\" : \"text-2xl\")}>Inbox</p>\n</div>\n```\n\nFull scroll-offset tracking and the colocated `LargeTitleCollapsingHeaderDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- Back button stays a constant 44×44+ tap target at every scroll position — it must never shrink alongside the title.\n- Title type scale and header background transition only in response to scroll offset, not to any header tap/drag.\n- Compact state visually separates the header from content (border/background fill) so users can tell where the fixed header ends and scrollable content begins.\n- Verify no auto-hide-on-scroll behavior is added without the screen-reader-active exception carried over from `top-app-bar`.\n\n## Anti-patterns\n\n- **Back button changing size/position across states**: shrinking or moving the back button as part of the title-collapse animation breaks a fixed, predictable tap target users rely on mid-scroll.\n- **Conflating back with sheet dismiss**: reusing this back button to dismiss a bottom sheet or modal (instead of stack navigation) contradicts `large-title-header`'s own distinction between the two actions.\n- **Header opaque at all times**: skipping the expanded/transparent state and always rendering the compact background fill loses the \"large presence at the top of a screen\" effect the term is named for.\n- **Auto-hiding this header on scroll direction without the accessibility exception**: if a hide-on-scroll behavior is layered on top, it must be suppressed when a screen reader is active — do not hide navigation controls from assistive tech users the same way as sighted scroll-based hiding.\n\n## Agent notes\n\n- `prompt_phrases`: \"화면 상단에 스크롤하면 접히는 large title header를 넣어줘\", \"뒤로가기 버튼은 스크롤 상태와 무관하게 항상 같은 크기로 유지해줘\", \"접힌 뒤에는 헤더 배경을 채워서 콘텐츠와 분리해줘\"\n- If the product also needs the bar to hide on scroll-down and reappear on scroll-up (not just shrink), combine with `top-app-bar`'s show/hide rule rather than adding that behavior here.\n- Fallback: if a spring-animation library isn't available, a CSS `transition` on font-size/background (as implemented) is an acceptable baseline — do not skip the transition entirely and snap instantly between states."},{"id":"topbar-command-search","name":"Topbar Command Search","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["websites","saas-dashboards"],"tokens_used":["color.semantic.surface.overlay","color.semantic.border.focus","color.semantic.text.muted","color.semantic.surface.muted","dimension.radius.lg","dimension.radius.xl","typography.scale.sm"],"code_asset":"examples/ui-vocabulary-site/src/components/topbar-search.tsx","component_refs":["popover"],"term_refs":["command-palette","autocomplete"],"source_refs":["tailwind-plus-application-ui"],"last_verified":"2026-07-07","source_path":"recipes/navigation/topbar-command-search.md","body":"## Intent\n\nA collapsed topbar icon that expands into a full-width search input with a live suggestion popover underneath it. It gives a site or app header a lightweight, command-palette-adjacent entry point without the modal weight of a full command palette (`command-palette`): the field lives inline in the topbar, expands in place, and collapses back to an icon when the user is done. Suggestions blend two behaviors from the vocabulary — starter shortcuts (`group`/`category` suggestions shown before typing) and typed autocomplete (`autocomplete`) ranked as the user types.\n\n## Anatomy\n\n- Root container: fixed-width wrapper that animates `width` between a collapsed icon-button size and an expanded input size (`transition-[width]`, respects `motion-reduce`).\n- Collapsed state: icon-only button (`Search` icon + optional `Ctrl F` hint label on desktop).\n- Expanded state: leading search icon, text input, trailing close (`X`) button.\n- `Popover` (Radix) anchored to the input via `PopoverAnchor`, with `PopoverContent` rendering a scrollable suggestion list below.\n- Each suggestion row: leading `Search` icon, label + description stack, trailing `ArrowRight` icon; highlighted row uses `bg-secondary`.\n- Section label above the list switches between \"바로 찾기\" (starter suggestions, empty query) and \"추천 결과\" (typed results).\n\n## States\n\n- **Closed / collapsed**: `expanded === false`. Only the icon button renders; clicking it sets `expanded = true`, which focuses the input on a short delay (`window.setTimeout(..., 40)`) so the width transition finishes first.\n- **Open, empty query**: `expanded === true` and `draftQuery` is empty. `suggestions` falls back to a fixed `topbarStarterSuggestions` list (site sections such as Hero Sections, Footers, Application Shells). The popover is open (`open = expanded && suggestions.length > 0`) as long as this fallback list is non-empty, so an empty-query state always has content — there is no true \"no results\" state at the empty-query step.\n- **Open, typed query**: `getSearchSuggestions` computes up to 8 ranked matches from `terms` against `draftQuery` and the active `filter`. If a typed query yields zero suggestions, `suggestions.length === 0` forces `open` to `false` — the popover disappears entirely rather than showing an explicit empty state.\n- **Keyboard navigation** (`handleKeyDown`, bound to the input's `onKeyDown`):\n - `ArrowDown` / `ArrowUp`: move `activeIndex` forward/backward through `suggestions` with wraparound (`(current + 1) % suggestions.length`), `preventDefault()`'d so the caret doesn't move.\n - `Enter`: commits the currently active suggestion (`selectSuggestion(suggestions[activeIndex])`) if one exists, otherwise commits the raw typed query (`commitQuery()`).\n - `Escape`: calls `collapse()` — closes the popover, resets `activeIndex` to 0, and re-collapses the topbar to icon width via `onExpandedChange(false)`.\n - Mouse `onMouseEnter` on a row also sets `activeIndex`, so hover and keyboard arrows share the same highlight state.\n- **Outside pointerdown**: a `document` listener collapses the field whenever a pointerdown lands outside the root container while expanded (checked via `rootRef.current?.contains(target)`).\n- **Selecting a suggestion**: `category`/`group` suggestions call `onFilterChange` and clear the query; term suggestions commit the suggestion's `value` as the query. Either path calls `collapse()` afterward.\n- **Focus behavior on the popover itself**: `onOpenAutoFocus` and `onCloseAutoFocus` both call `preventDefault()` — focus is deliberately kept on the input, never stolen by the popover content.\n\n## Variants\n\n- Desktop shows a `Ctrl F` hint label next to the collapsed icon (`hidden md:inline`); mobile hides it.\n- Expanded width differs by breakpoint: `w-[calc(100vw-2rem)]` on mobile vs. fixed `md:w-[22rem]` on desktop, so the field goes near-fullscreen on small viewports instead of overflowing.\n- Popover content width is itself clamped (`w-[min(28rem,calc(100vw-2rem))]`) independent of the trigger width.\n\n## Code\n\n```tsx\nfunction handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if (event.key === \"ArrowDown\" && suggestions.length > 0) {\n event.preventDefault()\n setActiveIndex((current) => (current + 1) % suggestions.length)\n }\n if (event.key === \"ArrowUp\" && suggestions.length > 0) {\n event.preventDefault()\n setActiveIndex((current) => (current - 1 + suggestions.length) % suggestions.length)\n }\n if (event.key === \"Enter\") {\n event.preventDefault()\n const activeSuggestion = suggestions[activeIndex]\n if (activeSuggestion) {\n selectSuggestion(activeSuggestion)\n } else {\n commitQuery()\n }\n }\n if (event.key === \"Escape\") {\n collapse()\n }\n}\n```\n\n```tsx\nuseEffect(() => {\n if (!expanded) {\n return\n }\n\n function closeOnOutsidePointer(event: PointerEvent) {\n const target = event.target as Node | null\n if (target && rootRef.current?.contains(target)) {\n return\n }\n\n collapse()\n }\n\n document.addEventListener(\"pointerdown\", closeOnOutsidePointer)\n\n return () => {\n document.removeEventListener(\"pointerdown\", closeOnOutsidePointer)\n }\n}, [collapse, expanded])\n```\n\nFull implementation, including the starter suggestion list and popover markup, lives in `code_asset`. Suggestion ranking is delegated to `examples/ui-vocabulary-site/src/lib/search-suggestions.ts` (`getSearchSuggestions`), which this recipe treats as an implementation detail of the block, not part of its own contract.\n\n## Checks\n\n- Input has `aria-autocomplete=\"list\"`, `aria-expanded={open}`, and an explicit `aria-label` (Korean copy in the current asset — localize per product).\n- Collapsed and close buttons both have `aria-label` (검색 열기 / 검색 닫기); icons are `aria-hidden`.\n- Active suggestion row is reachable and distinguishable by both keyboard (`activeIndex`) and mouse (`onMouseEnter`) — verify the highlighted state (`bg-secondary`) has sufficient contrast against `hover:bg-muted` rows.\n- Confirm the outside-pointerdown collapse does not fire when clicking inside the popover (popover is inside `rootRef` via anchor, but verify after any DOM restructuring).\n- Confirm `Escape` collapses even when focus is inside the popover list, not just the input.\n- Motion: width transition must respect `motion-reduce:transition-none`.\n\n## Anti-patterns\n\n- **No keyboard path, mouse-only rows**: rendering suggestion rows as plain `<div onClick>` instead of tracking an `activeIndex` reachable via `ArrowUp`/`ArrowDown` and committed via `Enter`. AI-generated search overlays frequently wire only `onClick`, which strands keyboard users and violates the command-palette expectation this pattern borrows from.\n- **No empty-results state for typed queries**: silently closing the popover when `suggestions.length === 0` (as this asset does) is acceptable only if the input itself signals \"no matches\" some other way (e.g. inline text). A common AI mistake is to leave zero-result typing completely silent with no feedback that the query is being processed or matched at all — always give the user *some* observable response to a search with no hits.\n- **Autofocus stealing on open**: letting the popover's default `onOpenAutoFocus` run moves focus off the input onto the first list item, breaking normal typing. This pattern must explicitly `preventDefault()` both `onOpenAutoFocus` and `onCloseAutoFocus`, or typed input gets interrupted the moment suggestions appear.\n- **Outside-click without outside-focus handling**: only listening for `pointerdown` to collapse the field ignores keyboard-only users tabbing away from the component (e.g. Shift+Tab out of the input). A robust version should also collapse on blur when focus moves outside `rootRef`, not just on pointer events.\n\n## Agent notes\n\n- `prompt_phrases`: \"topbar 검색창에 자동완성 붙여줘\", \"헤더 검색 아이콘 눌렀을 때 커맨드 팔레트처럼 확장되게 해줘\", \"검색 결과 없을 때 화면 처리도 해줘\"\n- Fallback: if the host app has no `terms`-shaped dataset to rank against, keep the starter-suggestion fallback (`topbarStarterSuggestions`-equivalent) so the popover never opens empty; wire the ranking function later without changing the keyboard/state contract above.\n- If the product needs a true modal command palette (Cmd+K style, full-screen overlay, always-available global shortcut) rather than an inline topbar expand/collapse, use the `command-palette` term instead — this recipe assumes an anchored, dismissible, topbar-local field, not a global overlay."},{"id":"versioned-docs-switcher-navbar-sidebar-swap","name":"Versioned Docs Switcher With Navbar Dropdown And Sidebar Swap","pattern_group":"navigation","kind":"block","status":"draft","surface_refs":["documentation","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.space.2","dimension.space.4","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/versioned-docs-switcher-navbar-sidebar-swap.tsx","component_refs":[],"term_refs":["versioned-docs-switcher"],"source_refs":["mintlify-docs-navigation"],"last_verified":"2026-07-12","source_path":"recipes/navigation/versioned-docs-switcher-navbar-sidebar-swap.md","body":"## Intent\n\nThe `versioned-docs-switcher` term already names the dropdown-plus-stale-banner concept; this recipe is the actual three-part state contract behind it — picking a version in the navbar dropdown does not just relabel the page, it swaps the sidebar tree's entire data source to that version's structure, and shows a stale-version banner whenever the selected version is not the latest. The dropdown, banner, and sidebar all read from one `activeVersionId` value instead of three independently-set flags.\n\n## Anatomy\n\n- Navbar row: site/docs label plus a version `Select` dropdown, latest version selected by default.\n- Stale banner: conditional strip below the navbar, visible only when the active version is not the latest.\n- Sidebar tree: full nested navigation list, sourced from `sidebarByVersion[activeVersionId]` — a different version renders a structurally different tree, not the same tree with different labels.\n\n## States\n\n- Latest version selected: no stale banner, sidebar shows the latest version's tree.\n- Older version selected: stale banner visible, sidebar swaps to that version's own tree (which may have fewer or differently-organized pages).\n- Version with no matching page for the reader's current location: out of scope for this component — the caller re-maps the current page to its nearest equivalent in the target version before rendering, this component only renders whatever tree it is given.\n\n## Variants\n\n- Dropdown-only (no sidebar swap): a lighter variant where only the banner+dropdown apply and the sidebar stays version-agnostic — not implemented here; if the product never has version-specific navigation structure, `versioned-docs-switcher`'s dropdown+banner alone is enough and this heavier recipe is unnecessary.\n\n## Code\n\n```tsx\n<Select value={activeVersionId} onValueChange={onVersionChange}>\n <SelectTrigger size=\"sm\"><SelectValue /></SelectTrigger>\n <SelectContent>\n {versions.map((v) => <SelectItem key={v.id} value={v.id}>{v.label}{v.isLatest ? \" (latest)\" : \"\"}</SelectItem>)}\n </SelectContent>\n</Select>\n{activeVersion && !activeVersion.isLatest ? (\n <div className=\"flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-sm\">\n <TriangleAlert className=\"size-4\" />\n <span>You are viewing docs for {activeVersion.label}, which is not the latest version.</span>\n </div>\n) : null}\n<nav>{sidebarGroups.map((group) => /* group.pages rendered as links */ null)}</nav>\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/versioned-docs-switcher-navbar-sidebar-swap.tsx`. Built on the `select` shadcn primitive.\n\n## Checks\n\n- Selecting a version updates the sidebar tree, not only the dropdown's displayed value.\n- The stale banner's visibility is derived from `activeVersion.isLatest`, never a separately-tracked boolean that could drift out of sync with the dropdown selection.\n- Sidebar groups render from `sidebarByVersion[activeVersionId]` only — no merging of the previous version's tree with the new one.\n- Latest version never shows the stale banner, even immediately after switching away from and back to it.\n\n## Anti-patterns\n\n- **Dropdown without a data swap**: changing the selected label but leaving the sidebar tree pointing at the previous version's data — the two must read the same `activeVersionId`.\n- **Banner tracked separately from the dropdown**: a standalone \"is this stale\" flag that can fall out of sync with the actual selected version, instead of deriving it from `isLatest` on the currently selected version.\n- **Silent page 404 on switch**: switching version and leaving the reader on a URL that doesn't exist in the new version's tree without any re-map or redirect.\n\n## Agent notes\n\n- prompt_phrases: \"navbar 버전 드롭다운을 고르면 사이드바 전체가 그 버전 구조로 바뀌는 문서 내비게이션을 만들어줘\", \"오래된 버전을 보고 있으면 상단에 경고 배너가 뜨고 사이드바도 그 버전 트리로 바뀌게 해줘\"\n- fallbacks: if a version has no entry in `sidebarByVersion`, render an empty sidebar rather than falling back to another version's tree silently.\n- related: this recipe implements the existing `versioned-docs-switcher` term's dropdown+banner+sidebar-swap description as actual composition; it does not introduce a new term."},{"id":"action-sheet-destructive-confirmation","name":"Action Sheet Destructive Confirmation","pattern_group":"overlays","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.action.destructive","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/action-sheet-destructive-confirmation.tsx","component_refs":[],"term_refs":["action-sheet"],"source_refs":["apple-hig-components"],"last_verified":"2026-07-12","source_path":"recipes/overlays/action-sheet-destructive-confirmation.md","body":"## Intent\n\nAn `action-sheet` (vocabulary term) variant tuned for the common case where one of the listed actions is destructive and irreversible (delete). It separates the destructive row visually (warn-tone text) from the neutral rows, and separates Cancel from the whole action group by a gap, so an upward thumb-sweep from the bottom edge naturally lands on Cancel rather than on the destructive row.\n\n## Anatomy\n\n- Scrim: full-bleed tappable layer behind the sheet; tapping it dismisses without acting (same as tapping Cancel).\n- Action group: a single rounded card containing one row per action, divided by hairlines.\n- Destructive row: same row shape as the others, text colored with `color.semantic.action.destructive` instead of default foreground — no separate container or icon treatment beyond color.\n- Cancel row: its own separate rounded card, placed below the action group with a visible gap — never merged into the same list as the actions.\n\n## States\n\n- **Closed**: sheet not rendered (`open === false`).\n- **Open**: scrim + action group + Cancel row all rendered; `role=\"dialog\"` and `aria-modal=\"true\"` on the sheet region.\n- **Destructive row**: text-only color change (`text-destructive`), no separate background — the only affordance distinguishing it from a neutral action is the warn-tone color plus its position (not the top row).\n\n## Variants\n\n- Action count: this recipe demonstrates 2 neutral + 1 destructive; the same divided-card shape holds for any small number of rows (HIG guidance keeps the list short — long lists should reconsider action sheet as the right pattern).\n- Optional title/message above the action group (not implemented in the code asset) — add a short heading row inside the same card when the destructive action needs a one-line consequence statement (\"This can't be undone\").\n\n## Code\n\n```tsx\n<button\n key={row.id}\n type=\"button\"\n onClick={() => onAction(row.id)}\n className={cn(\n \"flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium\",\n isDestructive ? \"text-destructive\" : \"text-foreground\"\n )}\n>\n <Icon className=\"size-4\" aria-hidden=\"true\" />\n {row.label}\n</button>\n```\n\nFull sheet shell (scrim, action group card, separated Cancel row) and the colocated `ActionSheetDestructiveConfirmationDemo` (rendered inside `DeviceFrame`) live in `code_asset`.\n\n## Checks\n\n- The destructive action's row is text-color-only distinguished (`color.semantic.action.destructive`) — not the sole differentiator relying on position, since color-blind or low-vision users need the position/grouping cue too (Cancel is always last and separated).\n- Cancel is visually separated (its own card, gapped) from the action rows, never the first or a middle row in the same list.\n- Scrim tap dismisses the same way Cancel does — there is no action taken by tapping outside.\n- `role=\"dialog\"` + `aria-modal=\"true\"` are present on the sheet region.\n\n## Anti-patterns\n\n- **Putting Cancel inside the same divided list as the actions**: this removes the visual separation that prevents a fast thumb-swipe from accidentally landing on Cancel expecting it, and also risks landing on the destructive row instead.\n- **Using icon/background color alone on the destructive row**: styling only the icon (not the text) or adding a full destructive-colored background reads as a difference in emphasis (like a primary button) rather than a warning — keep it text-color-only, matching the neutral rows' shape.\n- **Reusing this for a single confirm/cancel decision**: if there's only one yes/no decision (not a list of alternative actions), use `alert`/`dialog` instead of an action sheet — action sheet is for choosing among multiple actions.\n\n## Agent notes\n\n- `prompt_phrases`: \"메시지를 더보기 누르면 삭제가 빨간 글씨인 action sheet가 아래에서 올라오게 해줘\", \"취소 버튼은 액션 목록과 떨어뜨려서 보여줘\", \"삭제 행동은 destructive 색으로 구분해줘\"\n- If the request needs a single confirm/deny decision instead of choosing among several actions, use `dialog`/`alert` instead of this recipe.\n- Fallback: if no destructive semantic color token is themed for a target product, keep the row order/grouping contract (destructive last among actions, Cancel separated) even if the exact color differs — the grouping is the part that must not regress."},{"id":"bottom-sheet-detents","name":"Bottom Sheet Detents","pattern_group":"overlays","kind":"block","status":"draft","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.xl"],"code_asset":"examples/ui-vocabulary-site/src/components/bottom-sheet-detents.tsx","component_refs":[],"term_refs":["mobile-bottom-sheet","standard-bottom-sheet","modal-bottom-sheet"],"source_refs":["material-m3-components"],"last_verified":"2026-07-12","source_path":"recipes/overlays/bottom-sheet-detents.md","body":"## Intent\n\nA bottom sheet that carries two collapsed/expanded height presets (\"detents\") toggled from a drag handle, and can run in either of two structural modes: `standard-bottom-sheet` (no scrim — the screen behind stays visible and interactive) or `modal-bottom-sheet` (a scrim blocks the background until the sheet is dismissed). Both existing vocabulary terms describe one of these modes each; this recipe is the single component contract that implements both, selected via a `variant` prop, so a product doesn't need two separate implementations for what is the same detent-cycling shell.\n\n## Anatomy\n\n- Root: `absolute inset-0` layer scoped to the screen's viewport (not the whole page), so the sheet stays inside the device frame/screen bounds rather than portaling to the document body.\n- Scrim (`modal` variant only): full-bleed tappable layer behind the sheet; tapping it dismisses the sheet. Omitted entirely in `standard` variant.\n- Sheet surface: `rounded-t-2xl` panel pinned to the bottom, height driven by the current detent.\n- Drag handle: a small pill button at the top of the sheet. Doubles as the visual affordance and the tap target that cycles detents — required because not every dismiss/resize input is a drag gesture.\n- Content: scrollable body below the handle.\n\n## States\n\n- **Closed**: sheet not rendered (`open === false`).\n- **Open, collapsed**: `detent === \"collapsed\"`, sheet occupies a smaller height (45% of viewport in the code asset).\n- **Open, expanded**: `detent === \"expanded\"`, sheet occupies a larger height (85%).\n- **Standard variant**: no scrim rendered; background content stays visually present and (in a real screen) interactive.\n- **Modal variant**: scrim rendered (`bg-black/50`), `aria-modal=\"true\"` on the sheet's `role=\"dialog\"`, background interaction is blocked until dismissed.\n\n## Variants\n\n- `variant=\"standard\"` — non-blocking, coexists with the current screen (e.g. a map filter panel).\n- `variant=\"modal\"` — blocking, used for a decision the user must resolve before continuing (e.g. a short confirmation or option picker).\n- On desktop/wide viewports, the vocabulary recommends a side sheet instead of a bottom sheet — this recipe is mobile-viewport-scoped and does not implement that fallback itself.\n\n## Code\n\n```tsx\nfunction cycleDetent() {\n onDetentChange(detent === \"collapsed\" ? \"expanded\" : \"collapsed\")\n}\n\nreturn (\n <div className=\"absolute inset-0 z-10\">\n {variant === \"modal\" ? (\n <button\n type=\"button\"\n aria-label=\"Dismiss sheet\"\n onClick={onClose}\n className=\"absolute inset-0 bg-black/50\"\n />\n ) : null}\n\n <div\n role=\"dialog\"\n aria-modal={variant === \"modal\"}\n className={cn(\n \"absolute inset-x-0 bottom-0 flex flex-col rounded-t-2xl border-t bg-background shadow-xl transition-[height] duration-200\",\n detent === \"expanded\" ? \"h-[85%]\" : \"h-[45%]\"\n )}\n >\n <button type=\"button\" onClick={cycleDetent} aria-label={detent === \"collapsed\" ? \"Expand sheet\" : \"Collapse sheet\"}>\n <span aria-hidden=\"true\" className=\"h-1.5 w-10 rounded-full bg-muted-foreground/40\" />\n </button>\n {/* content */}\n </div>\n </div>\n)\n```\n\nFull component, including the standard/modal open triggers and the colocated `BottomSheetDetentsDemo` (rendered inside `DeviceFrame`), lives in `code_asset`.\n\n## Checks\n\n- Drag handle has an `aria-label` that reflects the next action (\"Expand sheet\" / \"Collapse sheet\"), not just a decorative pill.\n- At least one non-drag way to change detent exists (here: tapping the handle) — do not ship a version where the only way to expand/collapse is a physical drag gesture.\n- `modal` variant sets `aria-modal=\"true\"` and provides a way to dismiss without a drag (scrim tap here; also wire `Escape` if adapting to a real dialog primitive).\n- `standard` variant must not render a scrim — verify background rows remain visually present and are not obscured.\n- Detent height transition respects `motion-reduce` if adapted to use CSS transitions beyond the current `transition-[height]`.\n\n## Anti-patterns\n\n- **Modal without a non-scrim-tap dismiss path**: if adapting this into a real dialog/portal implementation, don't rely on scrim-tap alone — keyboard/assistive-tech users need `Escape` or an explicit close control too.\n- **Standard variant with a scrim**: adding a scrim to the `standard` variant turns it into a modal sheet in disguise and blocks background interaction the term explicitly says should stay available.\n- **Drag-only detent control**: implementing detent cycling purely via `pointermove`/drag tracking with no tap/keyboard alternative strands VoiceOver and switch-control users — the handle must remain a real, labeled button.\n- **Full-page portal escaping the screen bounds**: portaling the sheet to `document.body` (as a generic dialog primitive would) breaks the \"sheet is scoped to this screen\" contract used in a device-framed mobile context; keep it `absolute` within the screen's own relatively-positioned container.\n\n## Agent notes\n\n- `prompt_phrases`: \"필터를 누르면 bottom sheet가 올라오게 해줘\", \"배경을 어둡게 깔고 아래에서 modal bottom sheet가 올라오게 해줘\", \"드래그 핸들로 시트 높이를 늘렸다 줄였다 하게 해줘\"\n- If the product needs the screen behind to stay interactive (e.g. a map with a filter/detail panel), use `variant=\"standard\"`. If it needs to force a decision before continuing, use `variant=\"modal\"`.\n- On a wide/desktop layout, prefer a side sheet over stretching this component — this recipe does not include that responsive fallback.\n- Fallback: if a proper drag/pointer-tracking library is unavailable, tap-to-cycle (as implemented) is an acceptable baseline as long as it remains the case that at least one non-drag path exists — do not regress to drag-only."},{"id":"glass-panel","name":"Glass Panel","pattern_group":"overlays","kind":"visual-treatment","status":"draft","surface_refs":["websites","mobile-apps","saas-dashboards","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.border.default","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/glass-panel.tsx","component_refs":["mesh-gradient-surface","popover"],"term_refs":[],"source_refs":[],"last_verified":"2026-07-17","source_path":"recipes/overlays/glass-panel.md","body":"## Intent\n\nA glass panel keeps foreground content legible over a busy backdrop by blurring what is behind it (`backdrop-filter`), instead of covering it with an opaque fill. Tier ① — the blur is GPU-composited from a CSS declaration, no JS. Use for navigation bars over scrolling content, overlay cards on rich backgrounds, and floating toolbars. It is a *treatment* of a surface, not a component contract: the surface underneath keeps its own semantics.\n\n## Anatomy\n\n- Translucent token-derived fill (`bg-background/60`-class opacity on the surface token) — the opacity is the legibility knob.\n- `backdrop-blur` of medium radius; heavier blur reads as frosted, lighter as tinted.\n- Hairline border at reduced opacity so the panel keeps an edge in both themes.\n- Optional subtle shadow for lift; never heavy drop-shadows (signature dislike: sloppy CSS defaults).\n\n## States\n\n- Over busy backdrop (primary case): blur carries legibility.\n- Over flat backdrop: panel degrades gracefully to a translucent card — acceptable, but glass is unnecessary there.\n- Dark mode: fill derives from the dark surface token; verify the border remains visible against dark backdrops.\n- No-support fallback: browsers without `backdrop-filter` render the translucent fill only — raise fill opacity via `@supports not (backdrop-filter: blur(1px))` when content must stay readable.\n\n## Variants\n\n- Sticky navigation bar over scrolling page content.\n- Floating card over media/gradient hero.\n- Toolbar/HUD over canvas or map surfaces.\n\n## Code\n\n```tsx\nexport function GlassPanel({ children, className }: GlassPanelProps) {\n return (\n <div className={`rounded-lg border border-border/60 bg-background/60 shadow-sm backdrop-blur-md ${className ?? \"\"}`}>\n {children}\n </div>\n )\n}\n```\n\n## Checks\n\n- Text on the panel passes WCAG contrast against the *worst-case* backdrop, not the average one.\n- Fill and border derive from surface/border tokens with opacity — no literal white/black fills.\n- `@supports` fallback path defined when the panel carries essential content.\n- Blur radius is consistent across all glass surfaces in one product (one treatment, not per-component improvisation).\n\n## Anti-patterns\n\n- **Glass as decoration everywhere**: applying blur to surfaces that sit on flat backgrounds — cost without function.\n- **Contrast by text color**: fixing legibility by changing text color per backdrop instead of adjusting fill opacity — breaks token discipline and dark mode.\n- **Opaque imitation**: a solid light-gray fill pretending to be glass — if nothing shows through, use a normal card token.\n- **Blur stacking**: nesting glass panels compounds GPU cost and visual mud — one glass layer per stacking context.\n\n## Agent notes\n\n- prompt_phrases: \"token-derived glass panel with backdrop-blur, hairline border, and @supports opacity fallback\", \"sticky glass navigation bar that stays legible over scrolling content\"\n- fallbacks: no `backdrop-filter` support → raise fill opacity to solid-enough via `@supports not`; content-critical panels may switch to the plain card treatment.\n- canonical guidance: `knowledge/expressive-stack.md` tier ① decision row \"유리 질감(글래스모피즘)·배경 블러\"; contrast rules in `docs/design-system/principles.md`."},{"id":"popover","name":"Popover","pattern_group":"overlays","kind":"component","status":"draft","surface_refs":["websites","saas-dashboards","mobile-apps"],"tokens_used":["color.semantic.surface.overlay","color.semantic.border.default","color.semantic.border.focus","color.semantic.text.default","color.semantic.text.muted","dimension.radius.md","dimension.space.2"],"code_asset":"examples/ui-vocabulary-site/src/components/ui/popover.tsx","component_refs":[],"term_refs":["popover","popover-form","dropdown-menu","tooltip","dialog"],"source_refs":["radix-ui-primitives"],"last_verified":"2026-07-07","source_path":"recipes/overlays/popover.md","body":"## Intent\n\nA small floating panel anchored to a trigger, used for compact interactive content (a form field, a date picker, a short settings block) that does not need the modal weight of a `dialog` or the pure-text weight of a `tooltip`.\n\nThis recipe is a **composition atom**, not a standalone page pattern. It is consumed by higher-order recipes — for example `topbar-command-search` uses this popover as its floating suggestion surface via `PopoverAnchor` + `PopoverContent` around an input. When another recipe lists `component_refs: [popover, ...]`, it means \"assemble your overlay behavior from this recipe\" rather than duplicating open/close/dismiss logic.\n\n## Anatomy\n\n- **Trigger** — the element the popover is anchored to (`PopoverTrigger` or `PopoverAnchor` when the visual anchor differs from the interactive control, e.g. an input field).\n- **Floating panel** — `PopoverContent`, portaled, positioned relative to the trigger with `align`/`sideOffset`.\n- **Content** — free-form (form fields, list, `PopoverHeader`/`PopoverTitle`/`PopoverDescription` for structured copy).\n- **Optional arrow** — not implemented in the current code asset; add via Radix `Popover.Arrow` only if the design calls for it.\n\n## States\n\n- **closed** — panel unmounted (portal not rendered); trigger visible and focusable.\n- **open** — panel mounted, positioned, `data-state=\"open\"` drives the fade/zoom-in animation classes.\n- **focus-within** — focus lives inside the panel (e.g. a form field); the panel must not close while focus moves between its own children.\n- **dismiss** — triggered by `Escape`, an outside pointer/click, or explicit close action. On dismiss, focus must return to the trigger (Radix handles this by default — do not override `onCloseAutoFocus` without re-implementing the focus return).\n\n## Variants\n\n- **Anchor-decoupled trigger** — `PopoverAnchor` wraps a different element than the interactive control that opens the popover (see `topbar-search.tsx`: the anchor is the search input container, not a dedicated button).\n- **Structured content** — `PopoverHeader` + `PopoverTitle` + `PopoverDescription` for a short label/description block instead of raw children.\n- **Controlled open state** — pass `open`/`onOpenChange` to drive visibility from external state (e.g. only open when there are suggestions), rather than relying on uncontrolled trigger clicks.\n\n## Code\n\n```tsx\nfunction PopoverContent({\n className,\n align = \"center\",\n sideOffset = 4,\n ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n return (\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n data-slot=\"popover-content\"\n align={align}\n sideOffset={sideOffset}\n className={cn(\n \"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95\",\n className\n )}\n {...props}\n />\n </PopoverPrimitive.Portal>\n )\n}\n\nfunction PopoverAnchor({\n ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />\n}\n```\n\n`bg-popover` / `text-popover-foreground` / `border` map to `color.semantic.surface.overlay`, `color.semantic.text.default`, `color.semantic.border.default` at the Tailwind theme layer — do not hardcode hex when composing new popover content.\n\n## Checks\n\n- Panel is portaled (not clipped by an `overflow-hidden` ancestor).\n- `Escape` and outside pointerdown both close the panel; verify with keyboard-only navigation.\n- Closing returns focus to the trigger element (tab order resumes where the user left off).\n- Panel repositions/flips when the trigger is near a viewport edge (Radix `Popper` collision handling — do not disable it).\n- Panel does not trap focus like a modal; background content stays operable unless the composing recipe explicitly needs modal behavior (in which case use `dialog`, not `popover`).\n\n## Anti-patterns\n\n- **No focus return on dismiss** — overriding `onCloseAutoFocus`/`onOpenAutoFocus` (as `topbar-search.tsx` does for a specific UX reason) without re-implementing focus return elsewhere leaves keyboard users stranded. Only suppress autofocus when the surrounding component manages focus itself.\n- **Scroll-lock overreach** — locking `document.body` scroll for a popover (copying `dialog`/`drawer` behavior) is unnecessary weight; a popover is non-modal and background content should stay scrollable and interactive.\n- **Unhandled viewport overflow** — hardcoding `align`/`side` without letting Radix's collision detection flip the panel causes it to render off-screen on small viewports or near edges; do not disable collision padding.\n- **Using popover for content that needs a dialog** — stuffing a multi-step form or a destructive confirmation into a popover because \"it's already wired up\" ignores the `anti_use` guidance in `terms.yml` (`popover`): long or high-stakes content belongs in `dialog`.\n\n## Agent notes\n\n- `prompt_phrases`: \"버튼을 누르면 작은 popover가 열리게 해줘\", \"날짜 선택기를 팝오버로 띄워줘\", \"필터 저장 버튼을 누르면 이름을 입력하는 popover form을 열어줘\".\n- If the request is a simple command list, prefer `dropdown-menu` over `popover` (see `terms.yml` `popover` → `related: dropdown-menu`).\n- If the request is hover-only, short, non-interactive text, prefer `tooltip`.\n- If the request needs a compact input/submit flow inside the floating panel, compose `popover` + a form (see `popover-form` term) rather than inventing a new overlay primitive.\n- Fallback when Radix is unavailable: implement with native `popover` attribute / `anchor-name` CSS only if the target browser matrix supports it; otherwise keep Radix as the default dependency."}]
|