@askewly/design 0.4.4 → 0.4.5

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 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 통합·번들 전략).\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":"adaptive-navigation-container","name":"Adaptive Navigation Container","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"],"code_asset":"examples/ui-vocabulary-site/src/components/adaptive-navigation-container.tsx","component_refs":[],"term_refs":["tab-bar","navigation-rail","tabs"],"source_refs":["apple-hig-components"],"last_verified":"2026-08-01","source_path":"recipes/navigation/adaptive-navigation-container.md","body":"## Intent\n\nOne destination set, three possible primary-navigation containers — resolved by rule, not taste. This recipe implements the container-selection decision table from `knowledge/mobile-navigation.md` §1: destination count and width class determine whether the screen gets in-page tabs (fewer than 3 destinations — no primary nav), a bottom tab bar (3-5, compact), or a navigation rail (more than 5, or any expanded layout). Unlike `bottom-tab-bar`, which encodes one container's contract, this recipe encodes the *choice between containers* and keeps exactly one primary navigation component per screen.\n\n## Anatomy\n\n- Resolver: a pure function `(destinationCount, widthClass) → container` that mirrors the knowledge decision table — the rule lives in one place, not scattered through JSX.\n- Bottom tab bar branch: full-width bottom row, icon + 1-2 word label per destination, active destination filled + full-emphasis.\n- Navigation rail branch: leading vertical column (icon + label stacked), active destination carries a shape fill; replaces the bottom bar entirely — never both at once.\n- In-page tabs branch: an underline tab row inside the content area; deliberately *not* a primary navigation container.\n- Rule readout: the demo surfaces which table row fired, so the mapping from state to container is inspectable.\n\n## States\n\n- **Active destination**: `aria-current=\"page\"` (bar/rail) or `aria-selected` (in-page tabs); emphasis carried by fill + color, never color alone.\n- **Container transition**: changing destination count or width class re-resolves the container; active destination is preserved when it still exists in the set.\n- **Compact / expanded**: `expanded` always resolves to the rail — a bottom bar on desktop-width layouts is a rule violation, not a variant.\n\n## Variants\n\n- Destination count 2 / 4 / 6 exercises all three table rows; real products pin one count and get one container — the variant axis exists to make the rule visible.\n- More than 5 destinations on compact may alternatively keep a bottom bar with tab customization (≤5 visible) — this asset promotes to a rail, the other lawful branch.\n- Navigation drawer is intentionally absent: new adoption is discouraged (expanded rail replaces it); existing products keep theirs, new screens don't add one.\n\n## Code\n\n```tsx\nfunction resolveContainer(count: number, width: WidthClass) {\n if (width === \"expanded\") {\n return { container: \"rail\", rule: \"expanded → rail/사이드바 — bottom bar 금지\" }\n }\n if (count < 3) {\n return { container: \"in-page-tabs\", rule: \"목적지 3개 미만 → 페이지 내 tabs (주 내비 아님)\" }\n }\n if (count <= 5) {\n return { container: \"bottom-bar\", rule: \"최상위 목적지 3~5 · compact → bottom tab bar\" }\n }\n return { container: \"rail\", rule: \"5개 초과 → rail 로 승격 (compact 는 탭 커스터마이즈 ≤5 도 가능)\" }\n}\n```\n\nContainer components (bar, rail, in-page tabs), the shared screen body, and the interactive `AdaptiveNavigationContainerDemo` live in `code_asset`.\n\n## Checks\n\n- Exactly one primary navigation container renders per screen state — never bar + rail, never bar + drawer.\n- The resolver is the only place that decides the container; JSX branches only on its output.\n- Every destination shows icon + visible label in bar and rail; active state readable without color (fill change).\n- `aria-current=\"page\"` on the active destination in bar/rail; `role=\"tab\"` + `aria-selected` only in the in-page tabs branch.\n- Switching containers preserves the active destination when it survives the set change.\n\n## Anti-patterns\n\n- **Hamburger-by-default**: reaching for a drawer \"because it's mobile\" — the decision table has no row that lands on a drawer for new adoption.\n- **Bar + drawer double navigation**: shipping a bottom bar and a drawer on the same screen gives two competing primary navs.\n- **Bottom bar on expanded layouts**: keeping the phone container on tablet/desktop widths instead of promoting to a rail or sidebar.\n- **Promoting in-page tabs to primary nav**: styling a 2-item underline tab row as if it switched app-level destinations.\n\n## Agent notes\n\n- `prompt_phrases`: \"목적지 수에 따라 하단 탭바나 레일로 자동 전환되는 내비게이션\", \"태블릿에서는 레일로 바뀌는 모바일 내비게이션 셸\"\n- Before building any mobile screen, run the resolver logic mentally with the real destination count and target breakpoints — the container is an output, not an input.\n- Judgment canon: `knowledge/mobile-navigation.md` §1 (container table) and §2 (behavior contract — tap-only switching, state preservation, label rules). Single-container contracts live in `bottom-tab-bar`.\n- If the request insists on a drawer for a new product, surface the rule and record the deviation in one line — platform-norm departures must be intentional."},{"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":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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-08-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 **hash** seeding (`fract(sin(a·i + b) · c)`) so renders are stable across mounts without the visible rows that modular index arithmetic produces.\n- A per-particle phase driving its own radius and alpha pulse, so the field has internal texture instead of one uniform drift.\n- A pointer influence radius read from `window` (never from the canvas, which stays `pointer-events: none`): particles are pushed out with `(1 - d/r)² · strength` and lerped back to rest.\n\n## States\n\n- Animating (default when motion allowed): slow drift plus a per-particle twinkle.\n- Cursor near: particles inside the influence radius displace away and return once it leaves.\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- **Modular-arithmetic placement**: `(i * 83) % 97` and friends read as a lattice, not as scatter — the field looks generated rather than sown.\n- **Per-frame allocation, shadows, or blur**: this is where a particle field starts to stutter; the loop must stay allocation-free.\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":"card-flip-3d","name":"3D Card Flip","pattern_group":"application-ui","kind":"motion-pattern","status":"draft","surface_refs":["websites","mobile-apps","commerce","saas-dashboards"],"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.radius.md","typography.scale.sm","typography.weight.medium"],"mobile":{"platforms":{"ios":"none","android":"none"},"gestures":["tap"],"touch_targets":["flip toggle button"],"safe_area":"none","reduced_motion":"회전 트윈 없이 두 면의 전환(상태)은 그대로 일어난다","modal_depth":0,"port_notes":"`preserve-3d`·`backface-visibility` 는 RN 에 대응물이 없다 — 두 면의 opacity 교차(Animated) 또는 Flutter `Transform`+`AnimatedSwitcher` 로 바꾼다. hover 프리뷰는 터치에서 사라지므로 토글 버튼이 유일한 경로가 된다"},"code_asset":"examples/ui-vocabulary-site/src/components/card-flip-3d.tsx","component_refs":["button","showcase-card"],"term_refs":["card"],"source_refs":[],"last_verified":"2026-08-10","source_path":"recipes/application-ui/card-flip-3d.md","body":"## Intent\n\nA 3D card flip presents two faces of one object — summary and detail — rotating in perspective so the second face reads as the back of the same thing, not a new panel. The recipe exists because the common implementation is `:hover`-only: it demos beautifully on the author's mouse and is unreachable forever on touch devices and keyboards. Here the flip is a stated, toggleable state with hover as pure preview enhancement. (The demo *shell* around interactive cards is `showcase-card`; this recipe owns the flip interaction itself.)\n\n## Anatomy\n\n- Perspective wrapper: `perspective` on the parent so rotation reads as depth.\n- Rotor: the element that carries `transform-style: preserve-3d` and rotates `rotateY(180deg)` on flip.\n- Two faces: absolutely stacked, each `backface-visibility: hidden`; the back face pre-rotated `rotateY(180deg)`.\n- Flip control: a real `<button type=\"button\" aria-pressed>` — the state owner every input can reach.\n- Hover (fine pointers only): may preview the flip under `@media (hover: hover)` — an enhancement layered on top of the toggle, never the only path.\n\n## States\n\n- Front: summary face visible; back face `aria-hidden` so assistive tech reads one card, not two overlapped ones.\n- Flipped (`aria-pressed=\"true\"`): back face visible and exposed to AT; front hidden.\n- Hover preview (hover-capable pointers): a slight tilt hinting flippability — decorative, gated behind `(hover: hover)`.\n- Reduced motion: the faces still switch (the flip is state), but instantly — the rotation tween is the decoration that drops.\n\n## Variants\n\n- Y-axis flip (default) vs X-axis for vertically stacked metaphors.\n- Tap-anywhere flip for pure-content cards (the whole card as the button) vs a discrete corner toggle when the card contains other interactive elements.\n\n## Code\n\n```css\n.scene { perspective: 800px; }\n.rotor { transform-style: preserve-3d; transition: transform 400ms; }\n.rotor[data-flipped=\"true\"] { transform: rotateY(180deg); }\n.face { backface-visibility: hidden; }\n.face--back { transform: rotateY(180deg); }\n@media (prefers-reduced-motion: reduce) { .rotor { transition: none; } }\n```\n\n## Checks\n\n- The flip is reachable by every input: keyboard (Tab to the toggle, Enter/Space flips), touch (tap flips), and mouse — `:hover` alone is never the only path to the back face.\n- The toggle is a `<button type=\"button\">` with `aria-pressed` (or the whole card is the button when it contains no other controls).\n- The hidden face is `aria-hidden` and unfocusable — no screen-reader double-read, no focus landing behind the visible face.\n- Hover tilt/preview is wrapped in `@media (hover: hover)` so touch devices never get a sticky half-state on first tap.\n- Under reduced motion the state still switches instantly — the flip's meaning survives without its tween.\n- Both faces pass contrast on both themes; the back face is not a lower-contrast \"secondary\" surface by accident.\n\n## Anti-patterns\n\n- **`:hover`-only flip**: the back face exists only while a mouse hovers — keyboard and touch users never see it (the reference book's card flip demo, verbatim).\n- **Focusables behind the card**: leaving the hidden face's links/buttons in the Tab order so keyboard focus disappears \"into\" the card.\n- **Flip as navigation**: putting the primary action only on the back face, making every task cost an animation.\n- **Perspective on the rotor**: applying `perspective` to the rotating element instead of the parent — the rotation flattens and reads as a squash, not a turn.\n\n## Agent notes\n\n- prompt_phrases: \"two-faced card flip with a button-owned state and hover as preview only\", \"3D flip reachable by tap and keyboard, instant under reduced motion\"\n- fallbacks: where 3D transforms are unavailable, a crossfade between the two faces preserves the exact same state contract.\n- canonical guidance: `knowledge/input-modality.md` §1 (hover 이분법 — 도달 경로 hover 는 대체 경로 의무), `knowledge/expressive-stack.md` 티어①."},{"id":"chat-conversation-panel","name":"Chat Conversation Panel","pattern_group":"application-ui","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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"],"mobile":{"platforms":{"ios":"message list with input accessory bar","android":"message list with bottom input bar"},"gestures":["scroll","tap"],"touch_targets":["send button","retry action","input field"],"safe_area":"bottom","reduced_motion":"타이핑 인디케이터 애니메이션 없이 정적 표시 — 스레드 항목으로서의 위치는 동일","modal_depth":0,"port_notes":"소프트 키보드 회피는 웹에 없던 요구다 — RN `KeyboardAvoidingView`, Flutter `resizeToAvoidBottomInset`. `aria-live` 로그는 RN `accessibilityLiveRegion` · Flutter `Semantics(liveRegion:)` 로 바뀐다"},"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":"drag-scrub-handle","name":"Drag Scrub Handle","pattern_group":"application-ui","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m67","surface_refs":["websites","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.action.primary","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.border.focus"],"code_asset":"examples/ui-vocabulary-site/src/components/drag-scrub-handle.tsx","component_refs":[],"term_refs":["scrubber","drag-state"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/application-ui/drag-scrub-handle.md","body":"## Intent\n\n같은 재생바에서 **클릭과 드래그는 다른 계산이다.** 클릭은 절대 좌표를 진행률로 바로 매핑하지만, 드래그는 잡은 지점을 기억하고 그 뒤의 누적 이동량을 매핑해야 한다. 이 구분을 빠뜨리면 핸들 가운데가 아닌 곳을 잡는 순간 값이 튄다 — 스크러버에서 가장 흔한 결함이다.\n\n## Anatomy\n\n- **트랙** — 전체 길이. 빈 곳 클릭은 **절대 좌표** 시크다.\n- **채워진 구간** — 현재 진행률.\n- **핸들** — 끌면 **누적 이동량** 스크럽이다. `role=\"slider\"`.\n- **읽기값** — 현재 시간과 전체 길이. 클램프가 없으면 여기서 음수·초과가 드러난다.\n- **토글 3종** — 시작 좌표 고정 / 누적 이동량 정규화 / 경계 클램프. 학습용 장치다.\n\n## States\n\n- **Idle** — 핸들이 진행률 위치에 있다.\n- **Dragging** — 재생을 멈추고 값이 실시간으로 따라온다. 종료 시 이전 재생 상태를 복원한다.\n- **Focus-visible** — 포커스 링. 좌우 방향키로 이동 가능.\n- **경계** — 0 과 1 에서 멈춘다. 클램프를 끄면 이 상태가 사라진다.\n\n## Variants\n\n- **클릭 시크** — 절대 좌표 매핑. 트랙의 빈 곳을 누르면 그 비율로 즉시 이동.\n- **드래그 스크럽** — 잡은 지점을 고정하고 이후 누적 이동량을 매핑.\n- **키보드** — 방향키 스텝(이 데모는 2%).\n\n## Code\n\n```ts\n// 클릭 시크 (pz2-06) — 절대 좌표\nconst seek = (clientX: number) => {\n const p = mapRange(rect.left, rect.right, 0, 1, clientX)\n media.currentTime = media.duration * p\n}\n\n// 드래그 스크럽 (pz2-07) — 잡은 지점 고정 + 누적 이동량 + 클램프\nconst onDown = (e: PointerEvent) => {\n drag.start = handle.getBoundingClientRect().left\n drag.end = drag.start - trackBounds.left\n handle.setPointerCapture(e.pointerId)\n}\nconst onMove = (e: PointerEvent) => {\n drag.current = Math.round(drag.end + e.clientX - drag.start)\n const p = clamp(0, 1, mapRange(0, trackBounds.width, 0, 1, drag.current))\n media.currentTime = (media.duration - 0.05) * p\n}\n```\n\n## Checks\n\n- 핸들의 **가장자리를 잡고** 끌어 본다 — 값이 튀면 누적 이동량 정규화가 빠진 것이다.\n- 트랙 끝을 넘겨 끌어 본다 — 시간이 음수·초과로 표시되면 클램프가 빠진 것이다.\n- 잡는 순간 핸들이 커서로 순간이동하지 않는지 확인 — 하면 시작 좌표 고정이 빠진 것이다.\n- 방향키로 움직이는지, `aria-valuenow`·`aria-valuetext` 가 갱신되는지 확인.\n- 터치에서 스크롤과 다투지 않는지 확인(`touch-action: none`).\n\n## Anti-patterns\n\n- **클릭과 드래그를 같은 계산으로 처리하는 것.** 이 recipe 가 존재하는 이유 자체다.\n- **클램프를 표시 단계에서만 하는 것.** 내부 값이 범위를 벗어난 채로 남으면 다음 조작이 어긋난다.\n- **드래그 중에 재생을 계속 두는 것.** 사용자가 잡은 지점과 실제 위치가 계속 어긋난다.\n- **`pointerup` 을 핸들에서만 듣는 것.** 커서가 벗어난 채 놓으면 드래그가 안 끝난다 — `setPointerCapture` 를 쓴다.\n- **마우스 전용으로 만드는 것.** 키보드와 터치가 빠지면 스크러버는 접근 불가 컨트롤이 된다.\n\n## Agent notes\n\n- `prompt_phrases`: \"재생바 만들어줘\", \"타임라인 스크럽\", \"슬라이더 끌면 값이 튀어\", \"비디오 진행바 드래그\".\n- 드래그가 있는 컨트롤이면 **세 조각을 전부** 넣는다. 하나만 빠져도 특정 조작에서만 깨진다.\n- 스냅백·관성이 필요하면 `spring-drag-snap-card` 가 다른 답이다 — 이쪽은 값을 훑는 컨트롤이다.\n- `role=\"slider\"` 와 키보드는 선택이 아니다.\n\n## 관측 (Partizan · 2026-08-17)\n\n**클릭 시크** (`pz2-06`) — 절대 좌표 매핑:\n```\nprogress = mapRange(rect.left, rect.right, 0, 1, clientX)\ncurrentTime = duration * progress\n```\n\n**드래그 스크럽** (`pz2-07`) — 누적 이동량 매핑:\n```\ndrag.start = handle.getBoundingClientRect().left // 잡은 순간 고정\ndrag.end = drag.start - timelineBounds.left\ndrag.current = round(drag.end + clientX - drag.start)\nprogress = clamp(0, 1, mapRange(0, timelineBounds.width, 0, 1, drag.current))\ncurrentTime = (duration - .05) * progress\n```\n드래그 중에는 재생을 멈추고, 종료 시 이전 재생 상태를 복원한다.\n\n## 규칙 — 세 조각을 다 갖춰야 한다\n\n이 데모는 셋을 각각 끄고 무엇이 깨지는지 보게 한다.\n\n1. **시작 좌표 고정** — 끄면 잡는 순간 핸들이 커서로 순간이동하고 이후 계속 달라붙는다.\n2. **누적 이동량 정규화** — 끄고 절대 좌표로 대체하면 핸들 가장자리를 잡았을 때 그 오프셋만큼 값이 즉시 튄다.\n3. **경계 클램프** — 끄면 진행률이 0~1 밖으로 나가 시간이 음수·초과로 표시되고 핸들이 트랙을 벗어난다.\n\n`duration - .05` 의 여유값은 끝에 정확히 붙었을 때 재생이 종료 이벤트로 넘어가지 않게 하는 보정이다.\n\n## 접근성\n\n핸들은 `role=\"slider\"` + `aria-valuenow`/`min`/`max`/`valuetext` 를 갖고 좌우 방향키로도 움직인다(재판정 시 실측: 방향키 5회에 `aria-valuenow` 28→38, `valuetext` \"4.54초\"). 핸들과 트랙에 `touch-action: none` 을 걸어 터치에서도 스크롤과 다투지 않는다.\n\n## 우리가 정한 값\n\n실제 미디어 대신 가짜 재생 상태를 쓴다(에셋을 복제하지 않는다). 방향키 스텝 2%, 세 토글 UI 는 우리가 만든 학습 장치다.\n\n## term 대응\n\n- `scrubber` — 「현재 위치·전체 길이·thumb 를 보여주며 원하는 시점으로 이동한다」는 정의에 **그 이동을 어떻게 계산하는가**를 더한다.\n- `drag-state` — 드래그 중 재생 정지와 종료 시 복원이 이 term 이 말하는 상태 표시의 동작 쪽 짝이다.\n\n## 관련\n\n[[spring-drag-snap-card]](같은 드래그 계열이지만 스냅백이고 스크럽이 아니다) · `research/m67-batch2-css/p2-ledger.json` 행 `pz2-06`·`pz2-07`\n\n## Changelog\n\n- 2026-08-18 — m67-batch2-css 회차 신설. 사용자 관측 게이트 통과."},{"id":"exclusive-audio-playback","name":"Exclusive Audio Playback","pattern_group":"application-ui","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m66","surface_refs":["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","dimension.radius.md"],"mobile":{"platforms":{"ios":"list with single audio session owner","android":"list with single media session owner"},"gestures":["tap"],"touch_targets":["play button","mute toggle"],"safe_area":"none","reduced_motion":"회전 디스크 정지 — 재생 소유권은 라벨·진행 표시로 계속 읽힌다","modal_depth":0,"port_notes":"네이티브는 오디오 세션을 OS 가 단일 소유한다(`AVAudioSession` · `AudioManager` focus) — 웹의 stop-all 루프 대신 세션 API 에 위임하고, 직접 구현하면 시스템 재생과 이중이 된다"},"code_asset":"examples/ui-vocabulary-site/src/components/exclusive-audio-playback.tsx","component_refs":[],"term_refs":["audio-player-controls","list","media-card"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/application-ui/exclusive-audio-playback.md","body":"## Intent\n\nA list of cards that each carry their own audio will happily play all of them at once, and the usual bug report is \"two songs are playing.\" The fix is a single owner: before starting a track, stop every other one, then start. The observed origin pairs that with a state class on the owning card, so the handover is visible even with the sound off — the previous card's indicator stops at the same moment the new one begins. Sources are also loaded lazily, so a long list costs nothing until something is played. Observed at No Art (https://www.noartmusic.com/label) — ledger rows noart#7 in `research/m66-round8/p2-ledger.json`, disposition in `research/m66-round8/disposition.json`; user gate pass frozen in `research/2026-08-17-m66-gate-verdicts.md`.\n\n## Anatomy\n\n- **Track list** — cards each owning one audio source.\n- **Stop-all function** — a single entry point that pauses every registered player except an optional exception.\n- **Play sequence** — ensure the source is loaded, stop all others, then start.\n- **Playing indicator** — a visual state bound to the owning card (rotating disc, progress bar, or button label) so playback ownership is legible without audio.\n- **Mute control** — a global toggle that silences output without changing playback state.\n\n## States\n\n- **Idle** — no source loaded, no indicator.\n- **Loading** — the source is being fetched after the first interaction.\n- **Playing** — exactly one card carries the playing state.\n- **Stopped by handover** — the previous card returns to idle at the instant the next one starts.\n\n## Variants\n\n- **Exclusive off (contrast)** — disabling the stop-all step reproduces the overlapping-playback bug on purpose, which makes the rule visible.\n- **Synthesized vs file sources** — the pattern is identical whether each track is a media element or a synthesized tone; only the stop call differs.\n- **Pause vs stop** — pausing preserves position for resume; stopping resets it. Pick one and apply it to the whole list.\n\n## Code\n\n전체 구현은 `code_asset` 이 정본이다. 핵심만 발췌한다 — 이 패턴을 특징짓는 것은 분량이 아니라 아래 규칙 한 줄이다.\n\n```tsx\n// Exclusive Audio Playback — 핵심 규칙\n// 구현 전문: examples/ui-vocabulary-site/src/components/exclusive-audio-playback.tsx\n// 관측 파라미터의 출처: research/m66-round8/p2-ledger.json (noart#7)\n```\n\n## Checks\n\n- Starting a second track visibly stops the first, including its indicator.\n- With audio muted, the indicator handover still happens.\n- No audio context or media element is created before the first user gesture.\n- Unmounting the list releases every audio resource.\n\n## Anti-patterns\n\n- **Tracking the current track only in the clicked card.** The card that must stop is a different component; the owner has to be shared state.\n- **Autoplaying on mount.** Browsers block it, and when they do not, it is hostile.\n- **Eagerly loading every source.** A list of thirty tracks then costs thirty requests before anyone presses play.\n- **Showing playback state only through sound.** Muted and hard-of-hearing users lose the entire feedback channel.\n\n## Agent notes\n\nprompt_phrases: 목록에서 한 번에 하나만 재생되게 해줘 · 다른 트랙을 누르면 이전 트랙이 멈추게 · 카드마다 오디오 미리듣기 붙여줘\n\nfallbacks: 실제 오디오 파일이 없는 데모 환경에서는 Web Audio 합성음으로 대체할 수 있다(이 자산의 구현이 그렇다). 자동재생 정책 때문에 오디오 컨텍스트는 반드시 사용자 제스처 이후에 만든다."},{"id":"forgiving-pointer-feel","name":"Forgiving Pointer Feel","pattern_group":"application-ui","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.border.default","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/forgiving-pointer-feel.tsx","component_refs":[],"term_refs":["slider","sheet-drag-handle"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/application-ui/forgiving-pointer-feel.md","body":"## Intent\n\nFast pointer input is met halfway by three cooperating rules rather than one. The hit box grows with pointer velocity, the handle blends a trailing position into direct tracking as the drag becomes active, and the contact offset plus handle velocity decide the reflected direction.\n\nObserved at https://members-play.lacoste.com/ace-breaker-rg/ (LAC-04 + LAC-05 + LAC-14, accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Hit box: height and width each add smoothstep terms of the handle's velocity, so a fast swipe is caught by a larger target than a slow one.\n- Two-stage easing: an instant factor eases toward 1 while the drag is active with a .08s time constant, mixing a trailing position into the raw target. Keyboard input pins it to 0 so keys stay in the trailing mode.\n- Reflection: the contact offset from center sets the angle across a quarter-turn range, and the handle's velocity vector is mixed in by a smoothstep weight, so a moving handle imparts direction.\n- A single toggle neutralizes all three at once, which is how the feel is judged.\n\n## Pitfalls\n\n- Draw the hit box. If the swelling judgment area is invisible, the demo looks like an ordinary drag and the technique cannot be evaluated.\n\n## States\n\n- 정지 — 판정 박스가 기본 크기.\n- 빠른 드래그 — 판정 박스가 부풀고 즉시 추종 비중이 오른다.\n- 관대함 끔 — 세 규칙이 동시에 죽는다.\n- 키보드 — 즉시 추종이 0 으로 고정된다.\n\n## Variants\n\n- 관대함 on/off.\n- 포인터·키보드 두 입력 경로.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/forgiving-pointer-feel.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 판정 영역을 숨겨 두어 관대함이 눈에 안 보이게 두는 것.\n- 키보드 입력에도 즉시 추종을 걸어 커서가 튀게 하는 것.\n\n## Agent notes\n\n세 규칙은 따로 떼면 흔한 드래그다. 합성으로 유지하고, 토글로 한꺼번에 끄는 대조를 남긴다.\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--foreground`\n- `--muted`\n- `--muted-foreground`\n- `--radius`","code_source":"// 출처: research/m68/p2-lacoste-ace.json LAC-04 + LAC-05 + LAC-14 (합성)\n// (Lacoste Ace Breaker https://members-play.lacoste.com/ace-breaker-rg/ 접근일 2026-08-17)\n// LAC-05 판정 박스 팽창: height = .2 + .05*smoothstep(|velX|,.05,.14) + .06*smoothstep(-velY,0,1)\n// width = size*2 + .05 + .1*smoothstep(|velX|,.2,.5) + .5*smoothstep(-velY,0,1.5)\n// LAC-14 2단 이징: instant = ease(0→1, tau .08, isActive 여부) 로 \"추종 위치 ↔ 목표 위치\" 를 섞는다.\n// 키보드 조작 중에는 instant 를 0 으로 강제(추종만).\n// LAC-04 반사각: angle = π/4*clamp(offset,-1,1) + π/2 (offset = (중심x-접촉x)/(halfWidth*.5))\n// 파들 속도 벡터를 x = smoothstep(패들속도,.005,.05) 가중치로 혼합.\n// visual: 손잡이(정사각 핸들) 하나 + 튕기는 공 하나. 판정 박스(반투명 외곽선)가 손잡이 속도에 맞춰\n// 부풀고, 「관대함 끄기」 토글로 세 관측 행을 동시에 무력화해 손맛 차이를 비교한다.\nimport { useEffect, useRef, useState } from \"react\"\n\n// ---- 관측값 그대로 ----\nconst HEIGHT_BASE = 0.2\nconst HEIGHT_VELX_COEF = 0.05\nconst HEIGHT_VELX_EDGE0 = 0.05\nconst HEIGHT_VELX_EDGE1 = 0.14\nconst HEIGHT_VELY_COEF = 0.06\nconst HEIGHT_VELY_EDGE1 = 1\n\nconst WIDTH_EXTRA = 0.05\nconst WIDTH_VELX_COEF = 0.1\nconst WIDTH_VELX_EDGE0 = 0.2\nconst WIDTH_VELX_EDGE1 = 0.5\nconst WIDTH_VELY_COEF = 0.5\nconst WIDTH_VELY_EDGE1 = 1.5\n\nconst INSTANT_TAU_S = 0.08 // LAC-14 시상수\nconst REFLECT_ANGLE_SPAN = Math.PI / 4\nconst REFLECT_ANGLE_BASE = Math.PI / 2\nconst MIX_EDGE0 = 0.005\nconst MIX_EDGE1 = 0.05\nconst MIX_WEIGHT_SCALE = 0.915\n\n// ---- 우리가 정한 값 (화면 배율·손잡이 크기·공 속도 — 관측값 아님) ----\nconst UNIT = 220 // px per world unit\nconst SIZE_WORLD = 0.08 // LAC-05 의 size (손잡이 half-width)\nconst HANDLE_W_WORLD = SIZE_WORLD * 2\nconst HANDLE_H_WORLD = 0.16\nconst SLOW_FOLLOW_TAU_S = 0.22 // \"추종 위치\" 자체가 목표를 쫓는 속도\nconst KEY_STEP_PX = 18\nconst BALL_SPEED_PX_S = 260\nconst COLLISION_COOLDOWN_MS = 140\n\nfunction clamp(v: number, lo: number, hi: number) {\n return Math.min(hi, Math.max(lo, v))\n}\nfunction smoothstep(x: number, edge0: number, edge1: number) {\n const t = clamp((x - edge0) / (edge1 - edge0), 0, 1)\n return t * t * (3 - 2 * t)\n}\n// 프레임 독립 지수 이징: tau(초) 시상수로 target 을 향해 dt(ms) 만큼 전진\nfunction ease(current: number, target: number, tauS: number, dtMs: number) {\n const rate = 1 - Math.exp(-dtMs / 1000 / tauS)\n return current + (target - current) * rate\n}\n\ntype Vec = { x: number; y: number }\n\nexport default function ForgivingPointerFeel() {\n const rootRef = useRef<HTMLDivElement>(null)\n const stageRef = useRef<HTMLDivElement>(null)\n const handleElRef = useRef<HTMLDivElement>(null)\n const boxElRef = useRef<HTMLDivElement>(null)\n const ballElRef = useRef<HTMLDivElement>(null)\n const [forgiving, setForgiving] = useState(true)\n const forgivingRef = useRef(forgiving)\n forgivingRef.current = forgiving\n const [live, setLive] = useState({ velWorld: 0, boxW: 0, boxH: 0 })\n\n useEffect(() => {\n const root = rootRef.current\n const stage = stageRef.current\n const handleEl = handleElRef.current\n const box = boxElRef.current\n const ball = ballElRef.current\n if (!root || !stage || !handleEl || !box || !ball) return\n\n let stageW = 0\n let stageH = 0\n const syncSize = () => {\n const r = stage.getBoundingClientRect()\n stageW = r.width\n stageH = r.height\n }\n syncSize()\n const ro = new ResizeObserver(syncSize)\n ro.observe(stage)\n\n const handleHalfW = (HANDLE_W_WORLD * UNIT) / 2\n const handleHalfH = (HANDLE_H_WORLD * UNIT) / 2\n\n // 손잡이: target(목표=포인터/키보드) → slowPos(추종) → displayPos(instant 로 섞은 최종)\n const target: Vec = { x: 0, y: 0 }\n const slowPos: Vec = { x: 0, y: 0 }\n const displayPos: Vec = { x: 0, y: 0 }\n let prevDisplay: Vec = { x: 0, y: 0 }\n let instant = 0\n let pointerActive = false\n let initialized = false\n\n const ballPos: Vec = { x: 0, y: 0 }\n const ballVel: Vec = { x: BALL_SPEED_PX_S * 0.5, y: -BALL_SPEED_PX_S * 0.86 }\n let collisionCooldown = 0\n\n const clampToStage = (v: Vec) => {\n v.x = clamp(v.x, handleHalfW, Math.max(handleHalfW, stageW - handleHalfW))\n v.y = clamp(v.y, handleHalfH, Math.max(handleHalfH, stageH - handleHalfH))\n }\n\n const setTargetFromClient = (clientX: number, clientY: number) => {\n const r = stage.getBoundingClientRect()\n target.x = clientX - r.left\n target.y = clientY - r.top\n clampToStage(target)\n }\n\n const onPointerDown = (e: PointerEvent) => {\n handleEl.setPointerCapture(e.pointerId)\n pointerActive = true\n setTargetFromClient(e.clientX, e.clientY)\n }\n const onPointerMove = (e: PointerEvent) => {\n if (!pointerActive) return\n setTargetFromClient(e.clientX, e.clientY)\n }\n const onPointerUp = (e: PointerEvent) => {\n pointerActive = false\n try {\n handleEl.releasePointerCapture(e.pointerId)\n } catch {\n // 이미 해제된 경우 무시\n }\n }\n\n const onKeyDown = (e: KeyboardEvent) => {\n const step: Record<string, [number, number]> = {\n ArrowLeft: [-KEY_STEP_PX, 0],\n ArrowRight: [KEY_STEP_PX, 0],\n ArrowUp: [0, -KEY_STEP_PX],\n ArrowDown: [0, KEY_STEP_PX],\n }\n const d = step[e.key]\n if (!d) return\n e.preventDefault()\n pointerActive = false // 키보드 조작 중에는 instant 를 0 으로 강제(추종만)\n target.x += d[0]\n target.y += d[1]\n clampToStage(target)\n }\n\n handleEl.addEventListener(\"pointerdown\", onPointerDown)\n handleEl.addEventListener(\"pointermove\", onPointerMove)\n handleEl.addEventListener(\"pointerup\", onPointerUp)\n handleEl.addEventListener(\"pointercancel\", onPointerUp)\n root.addEventListener(\"keydown\", onKeyDown)\n root.style.touchAction = \"none\"\n\n let raf = 0\n let prevT = performance.now()\n\n const loop = (t: number) => {\n const dt = clamp(t - prevT, 0, 48)\n prevT = t\n const forgivingNow = forgivingRef.current\n\n if (!initialized && stageW > 0) {\n target.x = slowPos.x = displayPos.x = prevDisplay.x = stageW / 2\n target.y = slowPos.y = displayPos.y = prevDisplay.y = stageH * 0.7\n ballPos.x = stageW / 2\n ballPos.y = stageH * 0.25\n initialized = true\n }\n\n // LAC-14: instant 를 활성 여부로 이징, 그 값으로 추종↔목표 를 섞는다\n const instantTarget = forgivingNow && pointerActive ? 1 : 0\n instant = ease(instant, instantTarget, INSTANT_TAU_S, dt)\n slowPos.x = ease(slowPos.x, target.x, SLOW_FOLLOW_TAU_S, dt)\n slowPos.y = ease(slowPos.y, target.y, SLOW_FOLLOW_TAU_S, dt)\n const mix = forgivingNow ? instant : 0 // 끄면 즉시 추종 없음 — 항상 추종 위치만\n displayPos.x = slowPos.x + (target.x - slowPos.x) * mix\n displayPos.y = slowPos.y + (target.y - slowPos.y) * mix\n\n // 손잡이 속도(world units/sec) — LAC-05 입력\n const velWorldX = (displayPos.x - prevDisplay.x) / UNIT / (dt / 1000 || 1)\n const velWorldY = (displayPos.y - prevDisplay.y) / UNIT / (dt / 1000 || 1)\n prevDisplay = { x: displayPos.x, y: displayPos.y }\n\n // LAC-05: 판정 박스 팽창 (끄면 baseline 고정)\n const heightWorld = forgivingNow\n ? HEIGHT_BASE +\n HEIGHT_VELX_COEF * smoothstep(Math.abs(velWorldX), HEIGHT_VELX_EDGE0, HEIGHT_VELX_EDGE1) +\n HEIGHT_VELY_COEF * smoothstep(-velWorldY, 0, HEIGHT_VELY_EDGE1)\n : HEIGHT_BASE\n const widthWorld = forgivingNow\n ? SIZE_WORLD * 2 +\n WIDTH_EXTRA +\n WIDTH_VELX_COEF * smoothstep(Math.abs(velWorldX), WIDTH_VELX_EDGE0, WIDTH_VELX_EDGE1) +\n WIDTH_VELY_COEF * smoothstep(-velWorldY, 0, WIDTH_VELY_EDGE1)\n : SIZE_WORLD * 2 + WIDTH_EXTRA\n const boxWPx = widthWorld * UNIT\n const boxHPx = heightWorld * UNIT\n\n // 공 이동 + 벽 반사\n ballPos.x += (ballVel.x * dt) / 1000\n ballPos.y += (ballVel.y * dt) / 1000\n const ballR = 8\n if (ballPos.x < ballR) {\n ballPos.x = ballR\n ballVel.x *= -1\n } else if (ballPos.x > stageW - ballR) {\n ballPos.x = stageW - ballR\n ballVel.x *= -1\n }\n if (ballPos.y < ballR) {\n ballPos.y = ballR\n ballVel.y *= -1\n } else if (ballPos.y > stageH - ballR) {\n ballPos.y = stageH - ballR\n ballVel.y *= -1\n }\n\n // 손잡이 판정 박스와 충돌 → LAC-04 반사\n collisionCooldown = Math.max(0, collisionCooldown - dt)\n const withinX = Math.abs(ballPos.x - displayPos.x) < boxWPx / 2 + ballR\n const withinY = Math.abs(ballPos.y - displayPos.y) < boxHPx / 2 + ballR\n if (withinX && withinY && collisionCooldown === 0) {\n collisionCooldown = COLLISION_COOLDOWN_MS\n const halfWidthPx = boxWPx / 2\n const offset = clamp((displayPos.x - ballPos.x) / (halfWidthPx * 0.5), -1, 1)\n const angle = forgivingNow ? REFLECT_ANGLE_SPAN * offset + REFLECT_ANGLE_BASE : REFLECT_ANGLE_BASE\n const speed = Math.hypot(ballVel.x, ballVel.y)\n const reflected: Vec = { x: speed * Math.cos(angle), y: -speed * Math.sin(angle) }\n const paddleSpeedWorld = Math.hypot(velWorldX, velWorldY)\n const mixWeight = forgivingNow\n ? smoothstep(paddleSpeedWorld, MIX_EDGE0, MIX_EDGE1) * MIX_WEIGHT_SCALE\n : 0\n const paddleVelPx: Vec = { x: velWorldX * UNIT, y: velWorldY * UNIT }\n ballVel.x = reflected.x + (paddleVelPx.x - reflected.x) * mixWeight\n ballVel.y = reflected.y + (paddleVelPx.y - reflected.y) * mixWeight\n // 밀어내 다중 충돌 방지\n ballPos.y = displayPos.y - boxHPx / 2 - ballR - 1\n }\n\n handleEl.style.transform = `translate3d(${displayPos.x - handleHalfW}px, ${\n displayPos.y - handleHalfH\n }px, 0)`\n box.style.width = `${boxWPx}px`\n box.style.height = `${boxHPx}px`\n box.style.transform = `translate3d(${displayPos.x - boxWPx / 2}px, ${displayPos.y - boxHPx / 2}px, 0)`\n ball.style.transform = `translate3d(${ballPos.x - ballR}px, ${ballPos.y - ballR}px, 0)`\n\n setLive({ velWorld: Math.hypot(velWorldX, velWorldY), boxW: boxWPx, boxH: boxHPx })\n\n raf = window.requestAnimationFrame(loop)\n }\n raf = window.requestAnimationFrame(loop)\n\n return () => {\n window.cancelAnimationFrame(raf)\n ro.disconnect()\n handleEl.removeEventListener(\"pointerdown\", onPointerDown)\n handleEl.removeEventListener(\"pointermove\", onPointerMove)\n handleEl.removeEventListener(\"pointerup\", onPointerUp)\n handleEl.removeEventListener(\"pointercancel\", onPointerUp)\n root.removeEventListener(\"keydown\", onKeyDown)\n }\n }, [])\n\n return (\n <div\n ref={rootRef}\n data-testid=\"forgiving-pointer-feel\"\n tabIndex={0}\n className=\"flex w-full max-w-3xl flex-col gap-3 outline-none\"\n >\n <div className=\"flex items-center justify-between gap-3\">\n <p className=\"break-keep text-sm text-muted-foreground\">\n 손잡이를 좌우로 빠르게 끌어 보세요 (화살표 키로도 조작 가능)\n </p>\n <button\n type=\"button\"\n onClick={() => setForgiving((v) => !v)}\n className=\"shrink-0 rounded-md border border-border bg-background px-2.5 py-1 text-xs text-foreground hover:bg-muted\"\n >\n 관대함 {forgiving ? \"켜짐\" : \"꺼짐\"}\n </button>\n </div>\n <div\n ref={stageRef}\n className=\"relative h-64 w-full overflow-hidden rounded-lg border border-border bg-muted/30\"\n >\n <div\n ref={boxElRef}\n className=\"pointer-events-none absolute left-0 top-0 rounded-md border border-foreground/40 bg-foreground/5\"\n />\n <div\n ref={handleElRef}\n className=\"absolute left-0 top-0 h-4 w-4 cursor-grab touch-none rounded-sm bg-foreground active:cursor-grabbing\"\n style={{ width: HANDLE_W_WORLD * UNIT, height: HANDLE_H_WORLD * UNIT }}\n />\n <div ref={ballElRef} className=\"absolute left-0 top-0 h-4 w-4 rounded-full bg-foreground/70\" />\n </div>\n <dl className=\"grid grid-cols-3 gap-x-4 gap-y-1 font-mono text-xs text-muted-foreground\">\n <div>\n <dt className=\"inline\">손잡이 속도 </dt>\n <dd className=\"inline text-foreground\">{live.velWorld.toFixed(2)}</dd>\n </div>\n <div>\n <dt className=\"inline\">판정 박스 </dt>\n <dd className=\"inline text-foreground\">\n {Math.round(live.boxW)}×{Math.round(live.boxH)}px\n </dd>\n </div>\n </dl>\n <p className=\"break-keep text-sm text-muted-foreground\">\n 손잡이가 목표 위치를 <strong>2단 이징</strong>으로 쫓아가다가 계속 붙잡고 있으면 곧 손가락 위치를\n 그대로 따라갑니다. 빠르게 움직일수록 <strong>판정 박스가 부풀어</strong> 공을 더 관대하게 잡아내고,\n 맞은 지점과 손잡이가 움직이던 방향이 섞여 튕겨나가는 각도를 정합니다. 토글을 끄면 박스가 고정되고,\n 즉시 추종이 사라지고, 반사도 순수 반사로 바뀝니다.\n </p>\n </div>\n )\n}\n"},{"id":"idle-attract-demo","name":"Idle Attract Demo","pattern_group":"application-ui","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.border.default","color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/idle-attract-demo.tsx","component_refs":[],"term_refs":["list","menu-item"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/application-ui/idle-attract-demo.md","body":"## Intent\n\nAn attract mode that drives the product's own UI while nobody is touching it. Each decision draws from an observed probability mix with randomized easing and target offset, so the automation reads as a hand rather than a timer.\n\nObserved at https://members-play.lacoste.com/ace-breaker-rg/ (LAC-06, accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Each decision picks a branch by probability: 4% snap (next decision in 750-1250ms), 15% mid (150-300ms), rest slow (500-1000ms).\n- Easing coefficient is redrawn per decision inside .03-.07 and the target carries a random offset, so two passes over the same list never trace the same path.\n- Real pointer input takes over immediately and resets the idle timer; the demo resumes only after the surface goes quiet again.\n- A human/mechanical toggle swaps the probability mix for a fixed interval and fixed easing, which is what makes the technique visible at all.\n\n## Pitfalls\n\n- Without the contrast toggle the effect is invisible: a well-tuned attract mode simply looks like nothing is wrong.\n\n## States\n\n- 유휴 — 커서가 스스로 목록을 훑는다.\n- 사용자 조작 — 시연이 즉시 멈추고 포인터를 따른다.\n- 기계 모드 — 확률 혼합을 끄고 고정 간격으로 움직인다.\n\n## Variants\n\n- 사람처럼 / 기계적 두 모드.\n- 시연 대상은 목록이 아니라 슬라이더·탭 등으로 바꿔도 규칙은 같다.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/idle-attract-demo.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 고정 간격·고정 이징으로 시연해 데모 자체가 기계로 보이게 두는 것.\n- 사용자 입력 중에도 시연이 계속돼 조작을 빼앗는 것.\n\n## Agent notes\n\n확률·간격 수치는 관측값이고, 픽셀·ms 로 옮긴 값은 비율만 보존한 우리 값이다. 파일 주석이 둘을 갈라 적는다.\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--foreground`\n- `--muted-foreground`\n- `--radius`","code_source":"// 출처: research/m68/p2-lacoste-ace.json LAC-06 (\"사람처럼 흔들리는 대기 시연 AI (attract mode)\")\n// (https://members-play.lacoste.com/ace-breaker-rg/ — updateAutoplay() 본문 직독, 접근일 2026-08-17)\n// 판단마다 분기: Math.random()<.04 → 스냅(다음 판단까지 750~1250ms) · <.15 → 중간(150~300ms) ·\n// 나머지 → 기본(500~1000ms). 이징 계수 .03~.07, 목표 오프셋 랜덤 ±0.6~1.2 를 판단마다 새로 뽑는다.\n// visual: 유휴 상태에서 리스트 커서가 스스로 항목을 훑는다 — \"사람처럼\" 모드는 이 확률 혼합을\n// 그대로 쓰고, \"기계적으로\" 모드는 고정 간격·고정 이징·오프셋 0 으로 대체해 뻣뻣함을 대조한다.\n// ⚠ JITTER_PX, EASE_ROBOTIC, WAIT_ROBOTIC_MS 는 원본 좌표계 수치가 아니라 이 데모가 픽셀·ms 로\n// 옮기기 위해 우리가 정한 값이다(비율만 보존).\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\nconst ITEMS = [\"대시보드\", \"프로젝트\", \"팀\", \"보고서\", \"설정\", \"알림\"]\nconst ROW_H = 56\n\nconst P_SNAP = 0.04\nconst P_MID = 0.15\nconst SNAP_WAIT: [number, number] = [750, 1250]\nconst MID_WAIT: [number, number] = [150, 300]\nconst DEFAULT_WAIT: [number, number] = [500, 1000]\nconst EASE_RANGE: [number, number] = [0.03, 0.07]\nconst OFFSET_RANGE: [number, number] = [0.6, 1.2]\nconst JITTER_PX = 6 // ⚠ 우리가 정한 환산 상수 — 오프셋 1.0 이 만드는 흔들림 폭\nconst EASE_ROBOTIC = 0.05 // ⚠ EASE_RANGE 중앙값 — 기계적 모드는 이 값 하나만 쓴다\nconst WAIT_ROBOTIC_MS = 750 // ⚠ DEFAULT_WAIT 중앙값 — 기계적 모드는 매번 이 간격만 쓴다\nconst IDLE_RESUME_MS = 1500 // 사용자 입력 후 다시 유휴로 판정하는 대기\n\ntype Branch = \"snap\" | \"mid\" | \"default\" | \"fixed\"\n\nconst BRANCH_LABEL: Record<Branch, string> = {\n snap: \"스냅 (4%, 750~1250ms)\",\n mid: \"중간 (15%, 150~300ms)\",\n default: \"기본 (81%, 500~1000ms)\",\n fixed: \"고정 (750ms)\",\n}\n\nfunction randRange([a, b]: [number, number]) {\n return a + Math.random() * (b - a)\n}\n\nexport default function IdleAttractDemo() {\n const rootRef = useRef<HTMLDivElement>(null)\n const listRef = useRef<HTMLDivElement>(null)\n const highlightRef = useRef<HTMLDivElement>(null)\n\n const humanModeRef = useRef(true)\n const [humanMode, setHumanMode] = useState(true)\n const [state, setState] = useState<{ status: \"auto\" | \"user\"; branch: Branch; ease: number }>({\n status: \"auto\",\n branch: \"default\",\n ease: 0.05,\n })\n\n const applyToggle = useCallback((next: boolean) => {\n humanModeRef.current = next\n setHumanMode(next)\n }, [])\n\n useEffect(() => {\n const root = rootRef.current\n const list = listRef.current\n const highlight = highlightRef.current\n if (!root || !list || !highlight) return\n\n let posY = ROW_H / 2\n let targetY = ROW_H / 2\n let easeCoeff = EASE_ROBOTIC\n let currentRow = 0\n let status: \"auto\" | \"user\" = \"auto\"\n let lastInteraction = 0\n let timerId: ReturnType<typeof setTimeout> | null = null\n let raf = 0\n let alive = true\n\n const report = (branch: Branch) => {\n root.setAttribute(\"data-status\", status)\n root.setAttribute(\"data-branch\", branch)\n root.setAttribute(\"data-ease\", easeCoeff.toFixed(3))\n setState({ status, branch, ease: easeCoeff })\n }\n\n const pickTarget = () => {\n let nextRow = Math.floor(Math.random() * ITEMS.length)\n if (ITEMS.length > 1 && nextRow === currentRow) {\n nextRow = (nextRow + 1) % ITEMS.length\n }\n currentRow = nextRow\n const base = currentRow * ROW_H + ROW_H / 2\n if (humanModeRef.current) {\n const sign = Math.random() < 0.5 ? -1 : 1\n const offset = sign * randRange(OFFSET_RANGE) * JITTER_PX\n targetY = base + offset\n } else {\n targetY = base\n }\n }\n\n const scheduleNext = () => {\n if (timerId) clearTimeout(timerId)\n let wait: number\n let branch: Branch\n if (humanModeRef.current) {\n const r = Math.random()\n if (r < P_SNAP) {\n wait = randRange(SNAP_WAIT)\n branch = \"snap\"\n } else if (r < P_SNAP + P_MID) {\n wait = randRange(MID_WAIT)\n branch = \"mid\"\n } else {\n wait = randRange(DEFAULT_WAIT)\n branch = \"default\"\n }\n easeCoeff = randRange(EASE_RANGE)\n } else {\n wait = WAIT_ROBOTIC_MS\n branch = \"fixed\"\n easeCoeff = EASE_ROBOTIC\n }\n report(branch)\n timerId = setTimeout(() => {\n if (!alive || status !== \"auto\") return\n pickTarget()\n scheduleNext()\n }, wait)\n }\n\n const enterUserMode = () => {\n if (status !== \"user\") {\n status = \"user\"\n if (timerId) {\n clearTimeout(timerId)\n timerId = null\n }\n report(\"fixed\")\n }\n lastInteraction = performance.now()\n }\n\n const onPointerMove = (e: PointerEvent) => {\n enterUserMode()\n const rect = list.getBoundingClientRect()\n const y = e.clientY - rect.top\n const row = Math.min(ITEMS.length - 1, Math.max(0, Math.floor(y / ROW_H)))\n currentRow = row\n targetY = row * ROW_H + ROW_H / 2\n easeCoeff = 0.2\n }\n const onPointerDown = () => enterUserMode()\n\n list.addEventListener(\"pointermove\", onPointerMove)\n list.addEventListener(\"pointerdown\", onPointerDown)\n\n scheduleNext()\n\n const loop = (now: number) => {\n if (!alive) return\n if (status === \"user\" && now - lastInteraction > IDLE_RESUME_MS) {\n status = \"auto\"\n pickTarget()\n scheduleNext()\n }\n posY += (targetY - posY) * easeCoeff\n highlight.style.transform = `translateY(${posY - ROW_H / 2}px)`\n raf = window.requestAnimationFrame(loop)\n }\n raf = window.requestAnimationFrame(loop)\n\n return () => {\n alive = false\n window.cancelAnimationFrame(raf)\n if (timerId) clearTimeout(timerId)\n list.removeEventListener(\"pointermove\", onPointerMove)\n list.removeEventListener(\"pointerdown\", onPointerDown)\n }\n }, [])\n\n return (\n <div\n ref={rootRef}\n data-testid=\"idle-attract-demo\"\n data-status=\"auto\"\n data-branch=\"default\"\n data-ease=\"0.05\"\n className=\"flex w-full max-w-md flex-col gap-3\"\n >\n <div className=\"flex items-center justify-between gap-2\">\n <p className=\"break-keep text-xs text-muted-foreground\">\n 토글: 판단마다 간격·이징·목표 오프셋을 확률로 섞는지(사람처럼), 고정값 하나로만\n 움직이는지(기계적으로)를 바꿉니다.\n </p>\n <button\n type=\"button\"\n onClick={() => applyToggle(!humanMode)}\n aria-pressed={humanMode}\n className={`shrink-0 rounded-md border px-3 py-1.5 text-xs transition-colors ${\n humanMode\n ? \"border-foreground bg-foreground text-background\"\n : \"border-border text-muted-foreground hover:text-foreground\"\n }`}\n >\n {humanMode ? \"사람처럼\" : \"기계적으로\"}\n </button>\n </div>\n\n <div\n ref={listRef}\n className=\"relative touch-none overflow-hidden rounded-lg border border-border bg-background\"\n style={{ height: ROW_H * ITEMS.length }}\n >\n <div\n ref={highlightRef}\n className=\"pointer-events-none absolute inset-x-2 top-0 rounded-md border border-foreground bg-foreground/10\"\n style={{ height: ROW_H - 8, transform: `translateY(4px)` }}\n />\n {ITEMS.map((label) => (\n <div\n key={label}\n className=\"relative flex items-center px-4 text-sm text-foreground\"\n style={{ height: ROW_H }}\n >\n {label}\n </div>\n ))}\n </div>\n\n <dl className=\"grid grid-cols-3 gap-x-4 gap-y-1 font-mono text-xs text-muted-foreground\">\n <div>\n <dt className=\"inline\">상태 </dt>\n <dd className=\"inline text-foreground\">{state.status === \"auto\" ? \"자동 시연\" : \"사용자 조작\"}</dd>\n </div>\n <div>\n <dt className=\"inline\">분기 </dt>\n <dd className=\"inline text-foreground\">{BRANCH_LABEL[state.branch]}</dd>\n </div>\n <div>\n <dt className=\"inline\">이징 </dt>\n <dd className=\"inline text-foreground\">{state.ease.toFixed(3)}</dd>\n </div>\n </dl>\n\n <p className=\"break-keep text-sm text-muted-foreground\">\n 유휴 상태에서 리스트 위 커서가 스스로 항목을 훑으며 사용법을 시연합니다. 사람처럼 모드는\n 판단마다 다음 판단까지의 간격(스냅 4%·중간 15%·기본 81%)과 이징 계수, 목표 지점의 작은\n 오프셋을 매번 새로 뽑아 섞습니다. 기계적으로 모드로 끄면 간격·이징·오프셋이 전부 고정값\n 하나로 바뀌어 같은 리스트가 곧바로 메트로놈처럼 뻣뻣해집니다. 리스트 위에서 포인터를\n 움직이거나 클릭하면 시연이 즉시 멈추고 커서가 조작을 따라가며, 입력이 멎으면 약 1.5초\n 뒤 다시 유휴 시연을 이어갑니다.\n </p>\n </div>\n )\n}\n"},{"id":"idle-nudge-hint","name":"Idle Nudge Hint","pattern_group":"application-ui","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.border.default","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/idle-nudge-hint.tsx","component_refs":[],"term_refs":["card","tooltip"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/application-ui/idle-nudge-hint.md","body":"## Intent\n\nAffordance delivered as motion rather than copy. Four seconds of no input starts a nudge whose amplitude ramps over four more seconds, so it seeps in instead of popping, and any input cancels it and resets the timer.\n\nObserved at https://summer-afternoon.vlucendo.com/ (SMA-03, accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Idle threshold and ramp both come from the observed tween: a four second delay and a four second ramp to full amplitude.\n- The ramp is the technique. An instant-maximum toggle is provided precisely so the difference can be felt.\n- A visible countdown tells the viewer a wait is in progress; without it the demo looks inert.\n- Reduced-motion divides the amplitude rather than removing the affordance entirely.\n\n## Pitfalls\n\n- A nudge with no ramp reads as a glitch, not an invitation.\n\n## States\n\n- 대기 — 카운트다운이 남은 시간을 보여준다.\n- 넛지 — 진폭이 램프로 차오른다.\n- 조작 — 넛지가 멎고 타이머가 리셋된다.\n- 모션 축소 — 진폭이 크게 줄어든다.\n\n## Variants\n\n- 램프 / 즉시 최대 두 모드.\n- 대기 시간과 강도를 슬라이더로 조절.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/idle-nudge-hint.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 램프 없이 최대 진폭으로 튀어 경고처럼 보이게 하는 것.\n- 유휴 대기를 화면에 알리지 않아 아무 일도 안 일어나는 것처럼 보이게 두는 것.\n\n## Agent notes\n\n지연 4초·램프 4초는 관측값이다. 대기 카운트다운은 판정 가능성을 위해 우리가 더한 것이다.\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--foreground`\n- `--muted`\n- `--muted-foreground`\n- `--radius`","code_source":"// 출처: research/m68/p2-summer-afternoon.json SMA-03 (\"유휴 상태 조작 힌트 넛지\")\n// (https://summer-afternoon.vlucendo.com/ — App3D 청크 playIntroAnimation() 직독, 접근일 2026-08-17)\n// 관측값: camera.touchAmount = 0 → gsap.to(camera, {touchAmount: 1, delay: 4, duration: 4}),\n// 이어서 delayedCall(1.5, () => events.emit(\"webgl_character_controls_enable\", true)).\n// visual: 컨트롤이 활성화된 뒤 4초간 무입력이면 화면이 서서히(4초에 걸쳐) 커지는 흔들림으로\n// \"조작할 수 있다\"를 말없이 알린다. 입력이 들어오면 즉시 멎고 유휴 타이머가 리셋된다.\nimport { useEffect, useRef, useState } from \"react\"\n\n/** 관측값 delay:4 — 슬라이더 초기값 `useState(4)` 가 이 값을 쓴다. */\nconst DEFAULT_WAIT_SEC = 4\nconst RAMP_MS = 4000\nconst MAX_TILT_DEG = 14\nconst OSC_SPEED = 1.6 // rad/s\n\nexport default function IdleNudgeHint() {\n const rootRef = useRef<HTMLDivElement>(null)\n const stageRef = useRef<HTMLDivElement>(null)\n const cardRef = useRef<HTMLDivElement>(null)\n\n const [amountPct, setAmountPct] = useState(70)\n const [rampEnabled, setRampEnabled] = useState(true)\n const [waitSec, setWaitSec] = useState(DEFAULT_WAIT_SEC)\n const [countdown, setCountdown] = useState(waitSec)\n const [nudging, setNudging] = useState(false)\n const [dragging, setDragging] = useState(false)\n\n const amountRef = useRef(amountPct)\n const rampRef = useRef(rampEnabled)\n const waitRef = useRef(waitSec)\n amountRef.current = amountPct\n rampRef.current = rampEnabled\n waitRef.current = waitSec\n\n useEffect(() => {\n const root = rootRef.current\n const stage = stageRef.current\n const card = cardRef.current\n if (!root || !stage || !card) return\n\n const reduceMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n\n let alive = true\n let raf = 0\n let lastInteraction = performance.now()\n let phase = 0\n let lastTs = performance.now()\n\n let isDragging = false\n let dragStartX = 0\n let dragTiltY = 0\n let dragTiltX = 0\n\n const applyTransform = (tiltY: number, tiltX: number) => {\n card.style.transform = `rotateX(${tiltX.toFixed(2)}deg) rotateY(${tiltY.toFixed(2)}deg)`\n }\n\n const onPointerDown = (e: PointerEvent) => {\n isDragging = true\n setDragging(true)\n setNudging(false)\n lastInteraction = performance.now()\n dragStartX = e.clientX\n stage.setPointerCapture(e.pointerId)\n }\n const onPointerMove = (e: PointerEvent) => {\n lastInteraction = performance.now()\n if (!isDragging) return\n const dx = e.clientX - dragStartX\n dragTiltY = Math.max(-24, Math.min(24, dx / 4))\n dragTiltX = 0\n applyTransform(dragTiltY, dragTiltX)\n }\n const onPointerUp = () => {\n isDragging = false\n setDragging(false)\n lastInteraction = performance.now()\n }\n\n stage.addEventListener(\"pointerdown\", onPointerDown)\n stage.addEventListener(\"pointermove\", onPointerMove)\n window.addEventListener(\"pointerup\", onPointerUp)\n\n const loop = (now: number) => {\n if (!alive) return\n const dt = (now - lastTs) / 1000\n lastTs = now\n const waitMs = waitRef.current * 1000\n const idleFor = now - lastInteraction\n\n if (isDragging) {\n setCountdown(waitRef.current)\n raf = window.requestAnimationFrame(loop)\n return\n }\n\n if (idleFor < waitMs) {\n const remaining = Math.max(0, Math.ceil((waitMs - idleFor) / 1000))\n setCountdown((prev) => (prev !== remaining ? remaining : prev))\n setNudging((prev) => (prev ? false : prev))\n // 유휴 대기 중에는 드래그 흔적을 서서히 원위치로 되돌린다\n dragTiltY *= 0.9\n applyTransform(dragTiltY, 0)\n raf = window.requestAnimationFrame(loop)\n return\n }\n\n setNudging((prev) => (prev ? prev : true))\n const target = reduceMotion ? amountRef.current / 100 / 6 : amountRef.current / 100\n const elapsedSinceStart = idleFor - waitMs\n const currentAmount = rampRef.current\n ? Math.min(1, elapsedSinceStart / RAMP_MS) * target\n : target\n\n phase += dt * OSC_SPEED\n const tiltY = Math.sin(phase) * currentAmount * MAX_TILT_DEG\n const tiltX = Math.sin(phase * 0.6) * currentAmount * (MAX_TILT_DEG * 0.3)\n applyTransform(tiltY, tiltX)\n\n raf = window.requestAnimationFrame(loop)\n }\n raf = window.requestAnimationFrame(loop)\n\n return () => {\n alive = false\n window.cancelAnimationFrame(raf)\n stage.removeEventListener(\"pointerdown\", onPointerDown)\n stage.removeEventListener(\"pointermove\", onPointerMove)\n window.removeEventListener(\"pointerup\", onPointerUp)\n }\n }, [])\n\n return (\n <div\n ref={rootRef}\n data-testid=\"idle-nudge-hint\"\n className=\"flex w-full max-w-md flex-col gap-3\"\n >\n <p className=\"break-keep text-xs text-muted-foreground\">\n 어포던스: 카드를 좌우로 드래그해 기울일 수 있습니다. 손을 떼고 기다리면 조작 가능하다는\n 힌트가 스스로 나타납니다.\n </p>\n\n <div\n ref={stageRef}\n className=\"relative touch-none select-none overflow-hidden rounded-lg border border-border bg-muted\"\n style={{ height: 260, perspective: 700 }}\n >\n <div\n ref={cardRef}\n className=\"absolute left-1/2 top-1/2 h-32 w-48 rounded-xl border border-border bg-background shadow-sm\"\n style={{\n marginLeft: -96,\n marginTop: -64,\n transformStyle: \"preserve-3d\",\n willChange: \"transform\",\n }}\n />\n <div className=\"pointer-events-none absolute bottom-2 left-2 rounded-md bg-background/80 px-2 py-1 font-mono text-[11px] text-muted-foreground\">\n {dragging\n ? \"조작 중\"\n : nudging\n ? \"힌트 재생 중\"\n : `${countdown}초 뒤 힌트`}\n </div>\n </div>\n\n <div className=\"flex flex-col gap-2 rounded-lg border border-border bg-background p-3\">\n <label className=\"flex items-center justify-between gap-3 text-xs text-muted-foreground\">\n <span>강도 {amountPct}%</span>\n <input\n type=\"range\"\n min={10}\n max={100}\n value={amountPct}\n onChange={(e) => setAmountPct(Number(e.target.value))}\n className=\"w-40 accent-foreground\"\n />\n </label>\n\n <div className=\"flex items-center justify-between gap-3\">\n <span className=\"text-xs text-muted-foreground\">\n {rampEnabled ? \"4초 램프로 스며듦\" : \"즉시 최대로 툭 튐\"}\n </span>\n <button\n type=\"button\"\n onClick={() => setRampEnabled((v) => !v)}\n aria-pressed={rampEnabled}\n className={`shrink-0 rounded-md border px-3 py-1.5 text-xs transition-colors ${\n rampEnabled\n ? \"border-foreground bg-foreground text-background\"\n : \"border-border text-muted-foreground hover:text-foreground\"\n }`}\n >\n {rampEnabled ? \"4초 램프\" : \"즉시 최대\"}\n </button>\n </div>\n\n <label className=\"flex items-center justify-between gap-3 text-xs text-muted-foreground\">\n <span>대기 시간 {waitSec}초</span>\n <input\n type=\"range\"\n min={1}\n max={8}\n value={waitSec}\n onChange={(e) => setWaitSec(Number(e.target.value))}\n className=\"w-40 accent-foreground\"\n />\n </label>\n </div>\n\n <p className=\"break-keep text-sm text-muted-foreground\">\n 카드를 조작하지 않고 {waitSec}초가 지나면 카드가 스스로 흔들리기 시작해 드래그할 수\n 있음을 알려줍니다. 4초 램프 모드에서는 흔들림 폭이 4초에 걸쳐 서서히 커져 스며들듯\n 나타나고, 즉시 최대 모드에서는 대기 시간이 끝나자마자 최대 폭으로 툭 튀어 거슬립니다.\n 드래그하면 힌트가 바로 멈추고 유휴 타이머가 리셋됩니다.\n </p>\n </div>\n )\n}\n"},{"id":"lazy-three-object-scene","name":"Lazy three.js Object Scene","pattern_group":"application-ui","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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-08-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- **A stock primitive as the asset**: an untouched spinning icosahedron in a stock material is a three.js hello-world, not a reusable asset. Character should come from the material layer (`onBeforeCompile` on a stock material keeps lighting, tone mapping and shadows intact) rather than from an ever more elaborate mesh.\n- **Displacement that opens the body**: per-facet displacement large enough to separate faces shows the page background through the seams, which reads as missing geometry. Keep the amplitude under the seam threshold, or render both sides so the gap shows the interior instead.\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":"peer-blur-focus","name":"Peer Blur Focus","pattern_group":"application-ui","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites","saas-dashboards","documentation"],"tokens_used":["color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/peer-blur-focus.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/application-ui/peer-blur-focus.md","body":"## Intent\n\nPeer blur focus narrows attention inside a list by pushing the rows you are not pointing at out of focus, instead of brightening the row you are. The hovered row changes nothing at all: it keeps its color, weight, and position, while every sibling takes `blur(6px)` and `opacity 0.4`. The result is a change of focal plane rather than a change of emphasis, so the list never flashes and the hovered row never has to win a contrast fight. Blur is the load-bearing half of the pair. Opacity alone only makes text paler, but with blur added the siblings read as physically behind the plane of attention. Observed at inspiring.nk.studio/es, where the chunk `0yw007.vb_n7k.js` carries the constants `HOVER_H2_PEER_BLUR = \"blur(6px)\"` and `HOVER_H2_PEER_OPACITY = 0.4` (research/m58-round4/p2-nk-studio.json rows[12], ledger id r4-13). Promoted in M59 after the user observation gate; the source palette was dropped and all color remapped onto this project's semantic tokens.\n\n## Anatomy\n\n- A bordered card holds the rows, separated by hairline dividers, with one hover or focus target per row.\n- State is a single `active` index on the container, not per-row local state. Each row derives `dimmed = active !== null && active !== i`, which is what guarantees exactly one row stays sharp.\n- Dimmed rows get `filter: blur(6px)` and `opacity: 0.4`, both observed values.\n- Undimmed rows get `filter: blur(0px)` explicitly rather than `none`, so the browser interpolates between two filter functions of the same type instead of snapping.\n- Transition is `0.4s cubic-bezier(0.64, 0.1, 0, 1)` applied to `filter` and `opacity` together. The curve leaves early and settles slowly, so the retreat reads as a focus pull rather than a fade.\n- With nothing hovered, `active` is `null` and every row is sharp. There is no default-dimmed resting state.\n- `onFocus` and `onBlur` mirror `onMouseEnter` and `onMouseLeave`, so keyboard tabbing produces the same focus pull.\n- The container mirrors state to `data-active-index` and the constants to `data-peer-blur` and `data-peer-opacity` for inspection and testing.\n\n## States\n\n- Rest: no active index, all rows sharp at full opacity.\n- Hovered or focused row: unchanged. This is the point of the pattern.\n- Peer rows: `blur(6px)` plus `opacity 0.4`, reached over `0.4s`.\n- Leaving: `active` returns to `null` and every peer transitions back with the same curve, so exit and entry feel symmetric.\n\n## Variants\n\n- Blur depth: below about 3px the siblings just look slightly soft and the focal plane does not separate. Above about 10px the text turns into shape noise and the list stops being scannable. 6px is the observed value.\n- Opacity floor: pairing a shallower blur with a lower opacity trades focus separation for plain recession, which reads more like a disabled state.\n- Scope: the same mechanism works on a grid of cards or a nav column. It needs siblings that share a visual rhythm, since the effect is read as a comparison against neighbors.\n- Inverted variant, scaling or brightening the hovered item, is a different pattern and should not be mixed in. Doing both at once doubles the signal and loses the calm.\n\n## Code\n\n```tsx\nconst PEER_BLUR = \"blur(6px)\" // 관측값 직독\nconst PEER_OPACITY = 0.4 // 관측값 직독\nconst TRANSITION =\n \"filter 0.4s cubic-bezier(0.64, 0.1, 0, 1), opacity 0.4s cubic-bezier(0.64, 0.1, 0, 1)\"\n\nconst dimmed = active !== null && active !== i\n// 호버 대상은 손대지 않는다. 형제만 초점 밖으로 민다.\nstyle={{\n filter: dimmed ? PEER_BLUR : \"blur(0px)\",\n opacity: dimmed ? PEER_OPACITY : 1,\n transition: TRANSITION,\n}}\n```\n\n## Checks\n\n- Confirm the hovered row's computed style is untouched. If it gained a color, scale, or weight change during review, the pattern has been converted into ordinary emphasis.\n- Use `blur(0px)` for the resting value, never `none`. Transitioning from `none` to `blur(6px)` is not interpolable and the peers pop instead of receding.\n- Transition `filter` and `opacity` with the same duration and curve. Splitting them makes the peers fade and soften at different rates, which reads as a rendering glitch.\n- Tab through the list with the keyboard. If focus does not produce the same pull, the pattern is mouse-only and the list loses its structure for keyboard users.\n- Check that the blur is not clipped at the card edge. A blurred row near the rounded border needs `overflow: hidden` on the container or the soft edge bleeds past the border.\n- Verify dimmed rows are still clickable and still expose their text to assistive technology. Blur is a visual filter, not an inert state.\n- Honor reduced motion by dropping the transition, or the whole effect, rather than shortening it.\n\n## Anti-patterns\n\n- Setting `filter` on the hovered row too, for example `blur(0px)` plus a scale. The moment the hovered row moves, the effect stops being a focus pull.\n- Holding per-row hover state instead of one container index. Fast pointer travel then leaves two rows sharp or none, because enter and leave events interleave.\n- Applying blur to the shared container and trying to exempt one child. Filters create a containing block and the exemption does not work; the blur must live on the siblings.\n- Dimming by default and clearing on hover. The list then looks broken until the user moves the pointer into it.\n- Using it on a list that is being read rather than chosen from. Blur on body copy at rest is hostile; this belongs on selectable items.\n- Long lists where a single hover blurs twenty rows. The effect scales badly past roughly a dozen siblings, both visually and in compositing cost.\n\n## Agent notes\n\n- prompt_phrases: \"blur the other items when hovering one\", \"focus pull on a list instead of a highlight\", \"unhovered rows recede out of focus\".\n- fallbacks: with reduced motion, apply the state without the transition or skip the effect entirely. On low-end devices where animating `filter` on many nodes drops frames, fall back to opacity alone and accept the weaker separation.\n- The blur and opacity constants are observed values from the source and should be carried unchanged unless the surface itself changes."},{"id":"phase-broadcast-chrome","name":"Phase Broadcast Chrome","pattern_group":"application-ui","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites","saas-dashboards","documentation"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/phase-broadcast-chrome.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/application-ui/phase-broadcast-chrome.md","body":"## Intent\n\nPhase broadcast chrome keeps the shell's animation phase in a DOM attribute instead of a React context or a global store, then fires a value-free CustomEvent to say only that the phase changed. The point is that several unrelated readers (CSS selectors, framework components, third-party scroll widgets) can react to the same instant in their own way, while the attribute stays the single source of truth. Observed at inspiring.nk.studio/es by reading chunk `0yw007.vb_n7k.js` module 28779 directly (research/m58-round4/p2-nk-studio.json rows[15] and rows[7], ledger ids r4-16 and r4-08), which exposed `FRAME_CHROME_PHASE_EVENT = \"frame-chrome-phase-change\"`, the `setFrameChromeTopBarPhase` / `getFrameChromeTopBarPhase` pair, and the `useSiteChromePersistStore` zustand persist store. Promoted in M59 after the user observation gate passed; the original brand palette was dropped and every color remapped to this project's semantic tokens.\n\n## Anatomy\n\n- Two phase values only: `\"intro\"` and `\"revealed\"`. Nothing else is legal, and the reader enforces it.\n- Write path: set `dataset.frameChromePhase` on every element carrying the `js-frame-chrome-bar` marker class, then `window.dispatchEvent(new CustomEvent(EVENT, { detail: phase }))`. Attribute first, event second.\n- Read path: `getFrameChromeTopBarPhase()` reads the attribute off the first marked element and returns `null` when the value is neither `\"intro\"` nor `\"revealed\"`. Validation lives in the reader, not the writer.\n- Two bars respond to the same phase differently. The top bar animates `height` 76px to 48px and `opacity` 0.45 to 1; its accent label goes `opacity` 0 to 1. The bottom bar scales a hairline rule on `transformOrigin: left center` from `scaleX(0.25)` to `scaleX(1)`.\n- The stage copy carries the reveal: `blur(10px)` to `blur(0px)` with `opacity` 0.35 to 1.\n- Timing observed as `0.72s` for the bar and copy transitions, `0.75s` for the rule, all on `cubic-bezier(0.38, 0.02, 0.58, 1)`.\n- Side effect on entering `\"revealed\"`: the original calls `releaseIntroBottomLineForSparkles()`. The persisted flags `shellPersisted` and `frameCopyPersisted` are written to localStorage so a return visit starts already revealed.\n\n## States\n\n- `intro`: tall top bar at reduced opacity, accent label hidden, stage copy blurred, bottom rule at a quarter width.\n- `revealed`: compact top bar at full opacity, accent label visible, stage copy sharp, bottom rule at full width.\n- Returning visitor: `shellPersisted` is true on mount, so the component starts in `revealed` with no entry animation.\n- Invalid attribute value: the reader returns `null` while the DOM keeps whatever string was written. Nothing crashes and no listener is misled.\n\n## Variants\n\n- Subscriber count: the pattern pays off with two or more readers. With a single consumer, plain component state is simpler and should be preferred.\n- Scope: the attribute can live on `document.documentElement` for a whole-page phase, or on a scoped container as here so several instances coexist.\n- Persistence: dropping the localStorage flags makes the entry play on every visit, which suits a marketing shell more than a returning-user dashboard.\n- CSS-only consumers: with the attribute in place, `[data-frame-chrome-phase=\"revealed\"]` selectors can carry the entire transition without any JavaScript listener.\n\n## Code\n\n```tsx\n// 쓰기 — 속성이 먼저, 이벤트는 \"바뀌었다\"만 나른다\nconst setChromePhase = (next: Phase) => {\n for (const el of scope.querySelectorAll<HTMLElement>(\".js-frame-chrome-bar\")) {\n el.dataset.frameChromePhase = next\n }\n window.dispatchEvent(new CustomEvent(PHASE_EVENT, { detail: next }))\n}\n\n// 읽기 — dataset 이 단일 출처, 두 허용값이 아니면 null\nconst readChromePhase = (): Phase | null => {\n const v = scope.querySelector<HTMLElement>(\".js-frame-chrome-bar\")?.dataset.frameChromePhase\n return v === \"intro\" || v === \"revealed\" ? v : null\n}\n\n// 같은 단계, 서로 다른 반응 (관측값 그대로)\nconst EASE = \"cubic-bezier(0.38, 0.02, 0.58, 1)\"\n// 상단 바: height 76 → 48, opacity 0.45 → 1, 0.72s\n// 하단 규칙: scaleX(0.25) → scaleX(1), transformOrigin left center, 0.75s\n// 무대 카피: blur(10px) → blur(0px), opacity 0.35 → 1, 0.72s\n```\n\n## Checks\n\n- Write the attribute before dispatching. If the event fires first, a listener that calls the reader gets the previous phase and the whole broadcast desynchronizes.\n- Validate in the reader and return `null` on anything unexpected. Consumers must handle `null` rather than assume a phase.\n- Remove the `window` listener on unmount. This is a global event, so a stale listener keeps writing to localStorage after the component is gone.\n- Read persisted flags inside an effect, not during render. Touching `localStorage` at module or render time breaks server rendering and hydration.\n- Namespace the event and the storage key. A generic name like `phase-change` collides the moment a second shell mounts.\n- Sync the attribute whenever the phase changes from a source other than the setter (initial mount, persisted restore), or CSS consumers stay on the stale value.\n\n## Anti-patterns\n\n- Treating the event `detail` as the source of truth. A late subscriber that missed the dispatch has nothing to read; the attribute is what it can query at any time.\n- Mirroring the phase into a React state, a store, and the attribute at once. Three copies drift, and the DOM is the only one every consumer can reach.\n- Allowing a third or fourth phase because a new section needs one. Two values are what makes the reader's validation meaningful; extra shades of \"almost revealed\" belong in CSS, not in the phase.\n- Persisting the whole store rather than the two booleans. The original partializes down to `shellPersisted` and `frameCopyPersisted` precisely so stale animation state cannot be restored.\n- Skipping the entry animation for returning visitors without a way back. Provide a reset, otherwise the phase becomes untestable after the first visit.\n\n## Agent notes\n\n- prompt_phrases: \"keep the shell animation phase in a data attribute\", \"broadcast a UI phase change to unrelated listeners\", \"let CSS react to an intro/revealed state directly\".\n- fallbacks: without `localStorage` (private mode, blocked storage), the read wrapper returns the default flags and the entry animation simply plays every time. With reduced motion, apply the revealed styles immediately and keep the attribute and event flow untouched, since consumers outside the component still depend on the broadcast."},{"id":"preset-bundle-switch","name":"Preset Bundle Switch","pattern_group":"application-ui","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.border.default","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/preset-bundle-switch.tsx","component_refs":[],"term_refs":["tabs","badge"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/application-ui/preset-bundle-switch.md","body":"## Intent\n\nOne preset object bundles several axes at once and is layered two deep, a shared common block plus a time-of-day override. The bundle also carries a physical parameter, so the preset changes how things move and not only how they look.\n\nObserved at https://slowroads.io/ (SLW-01 + SLW-08 + SLW-09, accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Presets are resolved as common overridden by the current time of day, and the screen prints which layer supplied the applied values.\n- The observed friction table feeds the simulation directly, so a low-friction surface visibly slides further before stopping.\n- Stopping distance is displayed as a number, which is what separates this from a color-only theme switcher.\n- Colors are derived from tokens with alpha steps rather than copied from the source palette.\n\n## Pitfalls\n\n- If nothing in the bundle affects behavior, the pattern collapses into an ordinary theme toggle.\n- Keep integration in real units. An extra frame-rate factor in the integrator produced stopping distances 17x the canvas width before it was caught.\n\n## States\n\n- 장소 전환 — 색·표면·마찰이 한 번에 바뀐다.\n- 시간 전환 — common 위에 day/night 가 덮인다.\n- 굴림 — 표면 마찰에 따라 정지 거리가 달라진다.\n\n## Variants\n\n- 장소 3 × 시간 2 = 6 조합.\n- 표면(road/regolith)에 따른 마찰 2단.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/preset-bundle-switch.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 색만 바꾸고 동작 파라미터를 싣지 않아 테마 토글로 전락시키는 것.\n- 원 사이트의 브랜드 색을 그대로 옮겨 오는 것.\n\n## Agent notes\n\n마찰 테이블은 관측값이고 장소별 배정은 우리 매핑이다. 프리셋이 동작을 싣는다는 구조가 이식 대상이다.\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--foreground`\n- `--muted`\n- `--muted-foreground`\n- `--radius`","code_source":"// 출처: research/m68/p2-slowroads.json SLW-01 + SLW-08 + SLW-09 (합성) — https://slowroads.io/ (접근일 2026-08-17)\n// SLW-09: styleMeta.location.options=[mars,moon,venus] · styleMeta.time.options=[day,night].\n// style.mars.common = {dust:9074016, hasRubble:true, hasStars:false, surfaces:{road:Gravel, offroad:Gravel}, ...}\n// style.mars.day = {dust:8080429, ...} — common 위에 시간대가 \"덮어쓰는\" 2단 구조. (night 덮어쓰기 표는 미확인 —\n// 이 데모의 night.hasStars=true 는 구조를 보여주려는 우리 합성값이다.)\n// SLW-08: `{road:.8, regolith:.6}` 를 꺼내 `setMany({width, lanes:1, friction})` 로 생성 파이프라인에 주입 —\n// 프리셋 값이 겉모습만이 아니라 굴러가는 개체의 감속(동작)까지 바꾼다는 근거.\n// SLW-01: 도로폭 배열 `[3.2, 1.8, 3.2, 4]`(스타일 4단 Straight/Casual/Normal/Winding/Dev 중 일부) — 이 데모는\n// 그 배열 값을 장소별 도로 스트라이프 두께로 재사용한다(이 매핑은 우리가 정한 것, 원래 tier 의미와 무관).\n// visual: 장소(마스탕/문·비너스는 이름만 관측, 팔레트는 미관측) × 시간(day/night) 6조합을 고르면 무대가\n// 통째로 바뀌고, 표면(road/regolith)에 따라 굴러가는 공의 미끄러지는 거리가 달라진다 — 색만 바뀌는\n// 테마 스위처와 달리 동작 파라미터가 같이 실린다.\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\ntype LocationKey = \"mars\" | \"moon\" | \"venus\"\ntype TimeKey = \"day\" | \"night\"\ntype Surface = \"road\" | \"regolith\"\n\n// 관측값 그대로 (row SLW-08).\nconst FRICTION: Record<Surface, number> = { road: 0.8, regolith: 0.6 }\n\n// 관측값 그대로 (row SLW-01) — 앞 3개를 장소 인덱스에 대응시켜 재사용한다.\nconst ROAD_WIDTH_SCALE: [number, number, number, number] = [3.2, 1.8, 3.2, 4]\n\ntype CommonStyle = {\n dust?: number // mars 만 관측(SLW-09). moon/venus 는 미관측이라 비워 둔다.\n hasRubble: boolean\n hasStars: boolean\n hasClouds: boolean\n surface: Surface // 우리가 정한 매핑 — mars=road, moon/venus=regolith (관측된 표면 종류는 이름뿐)\n roadWidthScale: number\n}\ntype TimeOverride = Partial<Pick<CommonStyle, \"dust\" | \"hasStars\">>\n\ntype LocationPreset = {\n common: CommonStyle\n day: TimeOverride\n night: TimeOverride\n}\n\n// common 위에 day/night 가 덮어쓰는 2단 구조 그대로.\nconst PRESETS: Record<LocationKey, LocationPreset> = {\n mars: {\n common: {\n dust: 9074016, // 관측(SLW-09 common)\n hasRubble: true, // 관측\n hasStars: false, // 관측\n hasClouds: false, // 우리가 정함 — 화성은 대기가 옅다\n surface: \"road\", // 우리 매핑\n roadWidthScale: ROAD_WIDTH_SCALE[0], // 관측(SLW-01)\n },\n day: { dust: 8080429 }, // 관측(SLW-09 day)\n night: { hasStars: true }, // 우리가 정함 — night 덮어쓰기 표는 미확인\n },\n moon: {\n common: {\n hasRubble: false, // 우리가 정함\n hasStars: false,\n hasClouds: false,\n surface: \"regolith\",\n roadWidthScale: ROAD_WIDTH_SCALE[1],\n },\n day: {},\n night: { hasStars: true },\n },\n venus: {\n common: {\n hasRubble: true, // 우리가 정함 — 표면이 거칠다\n hasStars: false,\n hasClouds: true, // 우리가 정함 — 두꺼운 대기\n surface: \"regolith\",\n roadWidthScale: ROAD_WIDTH_SCALE[3],\n },\n day: {},\n night: { hasStars: true },\n },\n}\n\nconst LOCATIONS: LocationKey[] = [\"mars\", \"moon\", \"venus\"]\nconst TIMES: TimeKey[] = [\"day\", \"night\"]\nconst LOCATION_LABEL: Record<LocationKey, string> = { mars: \"화성\", moon: \"달\", venus: \"금성\" }\nconst TIME_LABEL: Record<TimeKey, string> = { day: \"낮\", night: \"밤\" }\n\nfunction resolveStyle(location: LocationKey, time: TimeKey): CommonStyle {\n const preset = PRESETS[location]\n const override = preset[time]\n return { ...preset.common, ...override }\n}\n\nconst DECEL_GAIN = 90 // 마찰 1.0 이 만드는 감속(px/s^2 환산 계수) — 우리가 정한 값\nconst LAUNCH_SPEED = 220 // px/s — 우리가 정한 값\nconst PAUSE_MS = 650 // 멈춘 뒤 다시 쏘기 전 대기 — 우리가 정한 값\n\nfunction resolveToken(el: HTMLElement, varName: string, fallback: string) {\n const raw = getComputedStyle(el).getPropertyValue(varName).trim()\n return raw || fallback\n}\n\nexport default function PresetBundleSwitch() {\n const rootRef = useRef<HTMLDivElement>(null)\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const styleRef = useRef<CommonStyle>(resolveStyle(\"mars\", \"day\"))\n\n const [location, setLocation] = useState<LocationKey>(\"mars\")\n const [time, setTime] = useState<TimeKey>(\"day\")\n const [distance, setDistance] = useState(0)\n\n useEffect(() => {\n styleRef.current = resolveStyle(location, time)\n }, [location, time])\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n const prefersReduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n let width = 1\n let height = 1\n\n const layout = () => {\n const rect = canvas.getBoundingClientRect()\n width = Math.max(1, rect.width)\n height = Math.max(1, rect.height)\n canvas.width = Math.round(width * dpr)\n canvas.height = Math.round(height * dpr)\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n layout()\n\n // 별 위치는 한 번만 정해 둔다(매 프레임 재추첨하면 반짝임이 아니라 지지직댐).\n const stars = Array.from({ length: 60 }, () => ({\n x: Math.random(),\n y: Math.random() * 0.55,\n r: Math.random() * 1.2 + 0.3,\n }))\n const rubbleDots = Array.from({ length: 40 }, () => ({\n x: Math.random(),\n y: 0.62 + Math.random() * 0.34,\n r: Math.random() * 1.6 + 0.5,\n }))\n\n let ballX = -20\n let ballV = LAUNCH_SPEED\n let stopped = false\n let stoppedAt = 0\n let lastStopDistance = 0\n let launchX = 0\n\n const relaunch = (now: number) => {\n ballX = 20\n launchX = ballX\n ballV = LAUNCH_SPEED\n stopped = false\n stoppedAt = now\n }\n\n let raf = 0\n let alive = true\n let prev = performance.now()\n\n const step = (now: number) => {\n const dt = Math.min(0.05, (now - prev) / 1000)\n prev = now\n const style = styleRef.current\n const friction = FRICTION[style.surface]\n\n if (!stopped) {\n ballV = Math.max(0, ballV - DECEL_GAIN * friction * dt)\n // px/s * s = px. (2026-08-17 실관측 수정: `* 60 * 0.6` 배율이 붙어 있어 정지 거리가\n // 캔버스 폭 700px 대비 12,000px 대로 찍혔다 — 판정자가 읽는 수치가 무의미해진다.)\n ballX += ballV * dt\n if (ballV <= 0.5) {\n stopped = true\n stoppedAt = now\n lastStopDistance = ballX - launchX\n setDistance(Math.round(lastStopDistance))\n }\n } else if (now - stoppedAt > PAUSE_MS) {\n relaunch(now)\n }\n\n draw(style, friction)\n root.setAttribute(\"data-location\", location)\n root.setAttribute(\"data-time\", time)\n root.setAttribute(\"data-surface\", style.surface)\n root.setAttribute(\"data-friction\", String(friction))\n root.setAttribute(\"data-distance\", String(Math.round(lastStopDistance)))\n\n if (!alive) return\n if (!prefersReduced) raf = window.requestAnimationFrame(step)\n }\n\n const draw = (style: CommonStyle, friction: number) => {\n const foreground = resolveToken(root, \"--foreground\", \"#111111\") // hardcoded-color-ok — 토큰 조회 실패 시 폴백\n const muted = resolveToken(root, \"--muted-foreground\", \"#6b7280\") // hardcoded-color-ok — 토큰 조회 실패 시 폴백\n const border = resolveToken(root, \"--border\", \"#e5e7eb\") // hardcoded-color-ok — 토큰 조회 실패 시 폴백\n\n ctx.clearRect(0, 0, width, height)\n\n // ---- 밤 오버레이 (time 이 하늘 밝기를 덮어쓴다) ----\n ctx.fillStyle = foreground\n ctx.globalAlpha = time === \"night\" ? 0.5 : 0.04\n ctx.fillRect(0, 0, width, height * 0.62)\n ctx.globalAlpha = 1\n\n // ---- 먼지 세기 (mars 만 관측값 기반, 나머지는 기본값) ----\n const dustIntensity =\n style.dust !== undefined ? Math.min(1, Math.max(0, (style.dust - 7_000_000) / 3_000_000)) : 0.2\n ctx.fillStyle = foreground\n ctx.globalAlpha = 0.08 + dustIntensity * 0.14\n ctx.fillRect(0, height * 0.2, width, height * 0.42)\n ctx.globalAlpha = 1\n\n // ---- 별 (hasStars) ----\n if (style.hasStars) {\n ctx.fillStyle = foreground\n for (const s of stars) {\n ctx.globalAlpha = 0.35 + Math.random() * 0.15\n ctx.beginPath()\n ctx.arc(s.x * width, s.y * height, s.r, 0, Math.PI * 2)\n ctx.fill()\n }\n ctx.globalAlpha = 1\n }\n\n // ---- 구름 (hasClouds) ----\n if (style.hasClouds) {\n ctx.fillStyle = muted\n ctx.globalAlpha = 0.25\n for (let i = 0; i < 4; i++) {\n const cx = width * (0.15 + i * 0.22)\n const cy = height * (0.14 + (i % 2) * 0.08)\n ctx.beginPath()\n ctx.ellipse(cx, cy, 34, 10, 0, 0, Math.PI * 2)\n ctx.fill()\n }\n ctx.globalAlpha = 1\n }\n\n // ---- 지면 색 단계 3층 (groundA/B/C 자리) ----\n const groundTop = height * 0.62\n const bands = [0.1, 0.18, 0.3]\n bands.forEach((alpha, i) => {\n ctx.fillStyle = foreground\n ctx.globalAlpha = alpha\n const y = groundTop + (height - groundTop) * (i / bands.length)\n const h = (height - groundTop) / bands.length\n ctx.fillRect(0, y, width, h)\n })\n ctx.globalAlpha = 1\n\n // ---- 절벽선 (cliff) ----\n ctx.strokeStyle = border\n ctx.globalAlpha = 0.9\n ctx.lineWidth = 2\n ctx.beginPath()\n ctx.moveTo(0, groundTop)\n ctx.lineTo(width, groundTop)\n ctx.stroke()\n ctx.globalAlpha = 1\n\n // ---- 표면 잔해 (hasRubble) ----\n if (style.hasRubble) {\n ctx.fillStyle = muted\n for (const d of rubbleDots) {\n ctx.globalAlpha = 0.3\n ctx.beginPath()\n ctx.arc(d.x * width, d.y * height, d.r, 0, Math.PI * 2)\n ctx.fill()\n }\n ctx.globalAlpha = 1\n }\n\n // ---- 도로 스트라이프 (SLW-01 폭 재사용) ----\n const roadY = height * 0.78\n const roadH = Math.max(6, style.roadWidthScale * 4.4)\n ctx.fillStyle = foreground\n ctx.globalAlpha = 0.14\n ctx.fillRect(0, roadY - roadH / 2, width, roadH)\n ctx.globalAlpha = 1\n\n // ---- 굴러가는 공 ----\n ctx.fillStyle = foreground\n ctx.globalAlpha = 0.9\n ctx.beginPath()\n ctx.arc(Math.min(width - 12, ballX), roadY, 8, 0, Math.PI * 2)\n ctx.fill()\n ctx.globalAlpha = 1\n\n void friction\n }\n\n relaunch(prev)\n if (prefersReduced) {\n // 모션 최소화 — 정지 상태만 한 번 그린다.\n lastStopDistance = (LAUNCH_SPEED * LAUNCH_SPEED) / (2 * DECEL_GAIN * FRICTION[styleRef.current.surface])\n stopped = true\n ballX = launchX + lastStopDistance\n setDistance(Math.round(lastStopDistance))\n draw(styleRef.current, FRICTION[styleRef.current.surface])\n } else {\n raf = window.requestAnimationFrame(step)\n }\n\n const ro = new ResizeObserver(() => {\n layout()\n if (prefersReduced) draw(styleRef.current, FRICTION[styleRef.current.surface])\n })\n ro.observe(canvas)\n\n return () => {\n alive = false\n window.cancelAnimationFrame(raf)\n ro.disconnect()\n }\n // location/time 은 styleRef 로 읽으므로 루프를 새로 만들 필요가 없다.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n const setLoc = useCallback((l: LocationKey) => setLocation(l), [])\n const setT = useCallback((t: TimeKey) => setTime(t), [])\n\n const style = resolveStyle(location, time)\n const friction = FRICTION[style.surface]\n\n return (\n <div\n ref={rootRef}\n data-testid=\"preset-bundle-switch\"\n data-location=\"mars\"\n data-time=\"day\"\n data-surface=\"road\"\n data-friction=\"0.8\"\n data-distance=\"0\"\n className=\"flex w-full max-w-3xl flex-col gap-3\"\n >\n <p className=\"break-keep text-sm text-muted-foreground\">\n 장소·시간을 바꿔 보세요. 색만이 아니라 굴러가는 공의 미끄러지는 거리도 바뀝니다.\n </p>\n\n <div className=\"flex flex-wrap items-center gap-2\">\n {LOCATIONS.map((l) => (\n <button\n key={l}\n type=\"button\"\n onClick={() => setLoc(l)}\n aria-pressed={location === l}\n className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${\n location === l\n ? \"border-foreground bg-foreground text-background\"\n : \"border-border text-muted-foreground hover:text-foreground\"\n }`}\n >\n {LOCATION_LABEL[l]}\n </button>\n ))}\n <span className=\"mx-1 text-xs text-muted-foreground\">×</span>\n {TIMES.map((t) => (\n <button\n key={t}\n type=\"button\"\n onClick={() => setT(t)}\n aria-pressed={time === t}\n className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${\n time === t\n ? \"border-foreground bg-foreground text-background\"\n : \"border-border text-muted-foreground hover:text-foreground\"\n }`}\n >\n {TIME_LABEL[t]}\n </button>\n ))}\n </div>\n\n <div className=\"relative h-[340px] w-full overflow-hidden rounded-lg border border-border bg-background\">\n <canvas ref={canvasRef} className=\"absolute inset-0 h-full w-full\" />\n <p className=\"pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-md border border-border bg-background/85 px-2.5 py-1 text-xs text-muted-foreground\">\n 적용된 프리셋 = common ← {time} · 표면 {style.surface} (friction {friction})\n </p>\n </div>\n\n <dl className=\"grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-xs text-muted-foreground sm:grid-cols-4\">\n <div>\n <dt className=\"inline\">surface </dt>\n <dd className=\"inline text-foreground\">{style.surface}</dd>\n </div>\n <div>\n <dt className=\"inline\">friction </dt>\n <dd className=\"inline text-foreground\">{friction}</dd>\n </div>\n <div>\n <dt className=\"inline\">정지 거리(px) </dt>\n <dd className=\"inline text-foreground\">{distance}</dd>\n </div>\n <div>\n <dt className=\"inline\">roadWidthScale </dt>\n <dd className=\"inline text-foreground\">{style.roadWidthScale}</dd>\n </div>\n </dl>\n\n <div className=\"overflow-x-auto rounded-md border border-border\">\n <table className=\"w-full min-w-[34rem] text-left font-mono text-xs\">\n <tbody className=\"text-muted-foreground\">\n <tr className=\"border-b border-border\">\n <th scope=\"row\" className=\"w-28 px-2.5 py-1.5 font-normal\">common</th>\n <td className=\"px-2.5 py-1.5 text-foreground\">\n hasRubble {String(PRESETS[location].common.hasRubble)} · hasClouds{\" \"}\n {String(PRESETS[location].common.hasClouds)} · surface {PRESETS[location].common.surface}\n </td>\n </tr>\n <tr>\n <th scope=\"row\" className=\"px-2.5 py-1.5 font-normal\">{time} 덮어쓰기</th>\n <td className=\"px-2.5 py-1.5 text-foreground\">\n {Object.keys(PRESETS[location][time]).length === 0\n ? \"없음 (common 그대로)\"\n : JSON.stringify(PRESETS[location][time])}\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <p className=\"break-keep text-sm text-muted-foreground\">\n 프리셋 하나가 배경 밝기·지면 색 단계·먼지 세기·구름·별 유무를 한 번에 바꿉니다. 그런데 표를\n 보면 <code>common</code> 위에 <code>{time}</code> 이 일부 값만 덮어쓰는 2단 구조라, 시간을\n 바꿔도 hasRubble 같은 값은 그대로 남습니다. 겉모습만 바뀌는 게 아니라{\" \"}\n <code>surface</code> 값(road=0.8, regolith=0.6)이 공의 감속을 실제로 바꿔서, 화성의 포장\n 도로보다 달·금성의 부스러기 지면에서 공이 더 멀리 미끄러집니다 — 색만 바뀌는 테마 스위처와\n 다른 지점입니다.\n </p>\n </div>\n )\n}\n"},{"id":"progressive-blur-edge","name":"Progressive Blur Edge","pattern_group":"application-ui","kind":"visual-treatment","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites","saas-dashboards","mobile-apps"],"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"],"mobile":{"platforms":{"ios":"none","android":"none"},"gestures":["scroll"],"touch_targets":["none"],"safe_area":"none","reduced_motion":"none","modal_depth":0,"port_notes":"`backdrop-filter` 4겹 사다리는 RN 에 대응물이 없다(expo-blur 는 단일 강도) — Flutter 는 `BackdropFilter`+`ShaderMask` 로 근사한다. 사다리를 못 만들면 단일 블러로 내리고 그 사실을 남긴다"},"code_asset":"examples/ui-vocabulary-site/src/components/progressive-blur-edge.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/application-ui/progressive-blur-edge.md","body":"## Intent\n\nA progressive blur edge stacks four backdrop-filter layers with increasing blur (2/3/5/6px) and staggered mask fade-stops (25/50/75/100%) at a scroll container's top and bottom. Nearer the true edge, more layers accumulate, producing a stepped depth-of-field falloff instead of one flat blur band. Values are the observed originals. Observed at inspiring.nk.studio/es/manifesto (research/m52-round1/p2-nk-studio.json row 11, ManifestoEdgeBlur nth-of-type ladder); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- A relative wrapper around an inner scroll container.\n- Per edge, four absolutely positioned, pointer-events-none layers of fixed height; each has `backdrop-filter: blur(Npx)` and a linear-gradient mask fading from the edge inward to its own fade-stop.\n- The layer whose fade-stop is shortest only covers the outermost sliver, so blur strength steps up toward the edge.\n\n## States\n\n- Scrolling: content sliding under the edges blurs progressively.\n- At-end: content fully scrolled still shows the falloff (the treatment is constant, not scroll-linked).\n- Layer-toggled (demo): individual layers can be disabled to inspect each step's contribution.\n\n## Variants\n\n- Single-edge (top-only under a sticky header; bottom-only above a composer bar).\n- Height and ladder tuning: keep the monotonic blur/fade-stop pairing when re-tuning.\n\n## Code\n\n```tsx\n{LAYERS.map((layer, i) => (\n <div key={i} aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-x-0 top-0\"\n style={{\n height: EDGE_HEIGHT,\n backdropFilter: `blur(${layer.blur}px)`,\n maskImage: `linear-gradient(to bottom, white 0%, transparent ${layer.fadeStop}%)`,\n }} />\n))}\n```\n\n## Checks\n\n- Layers are pointer-transparent and `aria-hidden` (pure treatment).\n- Both maskImage and WebkitMaskImage are set per layer.\n- The blur ladder and fade-stops stay monotonic (mixing the order produces banding).\n- Scroll performance holds with content moving under four backdrop filters; drop to two layers on weak devices.\n\n## Anti-patterns\n\n- One thick blur layer with a soft mask: the whole point is the stepped accumulation; a single layer produces a visible hard boundary where the blur begins.\n- Applying it to non-scrolling containers: without motion underneath it reads as a smudge.\n- Putting interactive elements inside the blurred band without lifting them above the layers.\n\n## Agent notes\n\n- prompt_phrases: \"edges of list fade into blur\", \"progressive backdrop blur\", \"stepped scroll edge\".\n- fallbacks: when backdrop-filter is unsupported, fall back to opacity gradient masks on the content (mask-image on the scroller) rather than nothing."},{"id":"responsive-dashboard-panel","name":"Responsive Dashboard Panel","pattern_group":"application-ui","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","surface_refs":["saas-dashboards","internal-tools","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.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/responsive-dashboard-panel.tsx","component_refs":["button","sidebar-application-shell","recoverable-empty-state"],"term_refs":["sidebar-dashboard-layout","empty-search-result"],"source_refs":[],"last_verified":"2026-08-10","source_path":"recipes/application-ui/responsive-dashboard-panel.md","body":"## Intent\n\nA responsive dashboard panel is the content pane of a dashboard — a filterable card/list collection with per-item state — with every state change carried on both channels: visual (class/style) and semantic (ARIA). It exists because the reference failure mode is a dashboard that works perfectly for sighted mouse users while carrying zero `aria-` attributes: favorites, view switching, and theme all changing silently off the accessibility tree. The navigation rail around this panel is a different recipe (`sidebar-application-shell`); this one owns what happens inside.\n\n## Anatomy\n\n- Panel frame: a bordered card with a titled header naming what the collection is and a short status line. This is what places the component — the same toolbar and list without a frame reads as a page fragment rather than one card on a dashboard.\n- Own measure: the panel caps itself (42rem) instead of filling its container, because a dashboard puts panels beside each other rather than one across the page.\n- Toolbar: search field (labeled), view switcher (list/grid) as an exclusive pair, and result count in an `aria-live` region.\n- Collection: cards in grid view or rows in list view — same items, same state, one source of truth.\n- Per-item favorite toggle: `<button aria-pressed>` whose persisted key is the item's stable id.\n- Empty state: searching past all items yields a real empty surface with a clear-query action, never a silently blank pane.\n\n## States\n\n- View: list or grid — an exclusive choice exposed as `role=\"radiogroup\"` with `aria-checked` (or paired `aria-pressed` buttons), not just a highlight class.\n- Favorite (per item): `aria-pressed` mirrors the visual star; persistence is keyed by item id — reordering or inserting items must never shift favorites onto the wrong rows.\n- Search empty: zero matches renders the empty state with the query visible and a reset action.\n- Theme: the panel inherits the app palette; both light and dark faces must pass contrast independently (palette-swap corollary).\n\n## Variants\n\n- Card grid with media slots (slots left empty for the consumer — no gradient placeholders).\n- Dense list for operator tools.\n- With/without per-item progress meters (each `<progress>` needs its own accessible name).\n\n## Code\n\n```tsx\n// Favorites keyed by stable item id — never by DOM order.\nconst [favorites, setFavorites] = useState<ReadonlySet<string>>(new Set())\nconst toggleFavorite = (id: string) =>\n setFavorites((prev) => {\n const next = new Set(prev)\n if (next.has(id)) next.delete(id)\n else next.add(id)\n return next // persist as { [itemId]: true }, not [true, false, ...]\n })\n```\n\n## Checks\n\n- Every state-changing control carries ARIA state: favorites `aria-pressed`, view switch `radiogroup`/`aria-checked`, disclosure `aria-expanded` — a control whose change is visible only as a class swap fails this recipe.\n- Persisted per-item state is keyed by stable item id (`{ \"run-01\": true }`), never by array index or DOM order — insert/remove/sort must not shift state between items.\n- Search with zero matches shows the empty surface with the active query and a reset action; the result count region is `aria-live`.\n- View switching re-renders the same items with the same state — favorites and selection survive the switch.\n- Both theme faces pass contrast independently — verify after palette swap, not only in the authoring theme.\n- Filtering hides items via rendering state, not by overwriting each element's class string.\n\n## Anti-patterns\n\n- **Visual-only state**: the favorite star fills, the view highlight moves, the theme flips — and none of it exists off the visual channel (the reference dashboard shipped 29.8KB of HTML with zero `aria-` attributes).\n- **Index-keyed persistence**: storing favorites as `[true, false, true]` — one inserted row and every favorite shifts to the wrong item.\n- **Blank as empty**: a search with no matches leaving a silent void — absence needs a surface.\n- **Nameless meters**: six identical `<progress>` bars with no accessible names — visually scannable, semantically a list of unlabeled numbers.\n\n## Agent notes\n\n- prompt_phrases: \"dashboard content panel with list/grid switch, id-keyed favorites, and live result count\", \"filterable dashboard cards where every state change is mirrored in ARIA\"\n- fallbacks: inside an existing shell, drop the toolbar into the shell's header slot and keep the collection contract unchanged.\n- canonical guidance: `knowledge/input-modality.md` §4 (이름과 상태), `knowledge/dark-mode.md` §2 (팔레트 스왑 따름정리), `docs/design-system/decisions/waiting-and-absence.md`."},{"id":"seed-deeplink-share","name":"Seed Deeplink Share","pattern_group":"application-ui","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.action.destructive","color.semantic.border.default","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/seed-deeplink-share.tsx","component_refs":[],"term_refs":["number-input","button"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/application-ui/seed-deeplink-share.md","body":"## Intent\n\nProcedural state is encoded as scene, style, seed, and progress in one hash-shaped string, and decoding validates length and forbidden characters before restoring anything. Pasting the string reproduces the same artwork.\n\nObserved at https://slowroads.io/ (SLW-06, accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Encoder concatenates a scene character, a style index, the seed, and a progress value into a single compact string.\n- Decoder is the other half of the technique: seed length 1-24 and no ? or !, then a shape match. Anything else is rejected with a visible reason and the current state is left untouched.\n- Controls and the address field share one state, so editing either side round-trips through the same encode and decode path.\n- Artwork is drawn from a hand-rolled seeded PRNG, so identical seeds produce identical output.\n\n## Pitfalls\n\n- This demo writes to an on-screen address field, not window.location.hash, because it is mounted alongside other demos. In a real page use the real hash.\n\n## States\n\n- 기본 — 시드로 생성된 그림과 그 주소.\n- 유효한 주소 입력 — 상태가 복원된다.\n- 규칙 위반 입력 — 거부되고 사유가 보이며 기존 상태는 유지된다.\n\n## Variants\n\n- 씬·스타일 선택으로 인코딩 문자가 바뀐다.\n- 진행값 슬라이더는 같은 시드 안에서 창만 옮긴다.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/seed-deeplink-share.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 디코더 검증을 생략해 잘못된 문자열이 상태를 깨뜨리게 두는 것.\n- 여러 데모가 함께 뜨는 화면에서 실제 주소 해시를 조작하는 것.\n\n## Agent notes\n\n인코딩 형태는 관측값이지만 씬·스타일 문자는 번들에서 못 얻어 우리가 정했다. 파일 주석에 명시돼 있다.\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--destructive`\n- `--foreground`\n- `--muted`\n- `--muted-foreground`\n- `--radius`","code_source":"// 출처: research/m68/p2-slowroads.json SLW-06 (\"URL 해시 기반 시드 딥링크\")\n// 원 사이트 https://slowroads.io/ (접근일 2026-08-17), 청크 DevMidlineGenerator.7a22a0a2.js 직독.\n// 관측값: 상태 기본값 {seed, scene: Hills, roadStyle: Normal, startNode: 0, accumulatedDistance: 0}.\n// 인코더 \"\" + sceneChar[scene] + roadStyle + \"-\" + seed + \"@\" + (startNode/100).toFixed(2)\n// → 형태 #{scene}{style}-{seed}@{startNode}. 디코더는 시드 문자열이 길이 1~24자이고\n// `?`·`!` 를 포함하지 않을 때만 허용한다: i.length>0 && i.length<=24 && !/\\?|\\!/.test(i).\n// ⚠ sceneChar[] 실제 배열과 roadStyle 코드 문자는 번들에서 못 얻었다 — 이 데모는 씬·스타일\n// 각각을 한 글자로 인코딩하되 어떤 글자를 쓰는지는 우리가 정한다(관측값 아님, 아래 SCENES/STYLES).\n// 길이 검사는 \"시드 문자열\"(seed 변수)이 아니라 표시 주소 전체(# 포함)에 적용한다 — 원문이\n// 검사 대상 변수명(i)만 주고 무엇을 넘기는지는 밝히지 않아 우리가 내린 판단이다.\n// visual: 시드로 결정되는 절차적 색 그리드(직접 구현 PRNG, 외부 라이브러리 없음). 진행값 슬라이더는\n// 같은 그리드를 순환 스크롤해 \"더 나아간 지점\"을 보여준다 — 지형을 만들지 않는다.\n// ⚠ 실제 브라우저 주소창(window.location.hash)은 건드리지 않는다. 이 데모는 판정 페이지 안에\n// 다른 데모와 함께 마운트되므로, 화면 안의 가짜 주소 표시줄(읽고 쓸 수 있는 input)로 왕복을\n// 재현한다.\nimport { useEffect, useMemo, useState } from \"react\"\n\ntype SceneKey = \"hills\" | \"desert\" | \"canyon\" | \"glacier\"\ntype StyleKey = \"normal\" | \"chaos\" | \"smooth\"\n\n// 씬·스타일 → 한 글자 코드. 실제 sceneChar[] 배열·roadStyle 코드는 미관측 — 우리가 정한 값.\nconst SCENES: { key: SceneKey; char: string; label: string; hue: number }[] = [\n { key: \"hills\", char: \"H\", label: \"Hills\", hue: 140 },\n { key: \"desert\", char: \"D\", label: \"Desert\", hue: 34 },\n { key: \"canyon\", char: \"C\", label: \"Canyon\", hue: 14 },\n { key: \"glacier\", char: \"G\", label: \"Glacier\", hue: 200 },\n]\nconst STYLES: { key: StyleKey; char: string; label: string }[] = [\n { key: \"normal\", char: \"N\", label: \"Normal\" },\n { key: \"chaos\", char: \"X\", label: \"Chaos\" },\n { key: \"smooth\", char: \"S\", label: \"Smooth\" },\n]\n\nconst MAX_LEN = 24\n\ntype DeepState = { scene: SceneKey; style: StyleKey; seed: string; progress: number }\n\nfunction sceneByChar(c: string) {\n return SCENES.find((s) => s.char.toLowerCase() === c.toLowerCase())\n}\nfunction styleByChar(c: string) {\n return STYLES.find((s) => s.char.toLowerCase() === c.toLowerCase())\n}\n\n/** 관측 인코더: \"\" + sceneChar[scene] + roadStyle + \"-\" + seed + \"@\" + (startNode/100).toFixed(2) */\nfunction encode(state: DeepState): string {\n const scene = SCENES.find((s) => s.key === state.scene) ?? SCENES[0]\n const style = STYLES.find((s) => s.key === state.style) ?? STYLES[0]\n const progressFrac = (state.progress / 100).toFixed(2)\n return `#${scene.char}${style.char}-${state.seed}@${progressFrac}`\n}\n\ntype DecodeResult = { ok: true; state: DeepState } | { ok: false; reason: string }\n\n/** 관측 검증 규칙 + 형식 파싱. 검증 규칙은 표시 주소 전체(# 포함)에 적용한다(위 주석 참고). */\nfunction decode(address: string): DecodeResult {\n if (address.length === 0) return { ok: false, reason: \"빈 주소입니다.\" }\n if (address.length > MAX_LEN) {\n return { ok: false, reason: `길이 ${address.length}자 — 시드 규칙은 ${MAX_LEN}자까지만 허용합니다.` }\n }\n if (/\\?|\\!/.test(address)) {\n return { ok: false, reason: \"'?' 또는 '!' 는 시드 문자열에 쓸 수 없습니다.\" }\n }\n const match = /^#([A-Za-z])([A-Za-z])-([^@]+)@(-?\\d*\\.?\\d+)$/.exec(address)\n if (!match) {\n return { ok: false, reason: \"형식이 #씬스타일-시드@진행값 이 아닙니다.\" }\n }\n const [, sceneChar, styleChar, seed, progressStr] = match\n const scene = sceneByChar(sceneChar)\n if (!scene) return { ok: false, reason: `알 수 없는 씬 코드 \"${sceneChar}\" (사용 가능: ${SCENES.map((s) => s.char).join(\", \")})` }\n const style = styleByChar(styleChar)\n if (!style) return { ok: false, reason: `알 수 없는 스타일 코드 \"${styleChar}\" (사용 가능: ${STYLES.map((s) => s.char).join(\", \")})` }\n const progress = Math.min(100, Math.max(0, Number(progressStr) * 100))\n if (!Number.isFinite(progress)) return { ok: false, reason: \"진행값을 숫자로 읽을 수 없습니다.\" }\n return { ok: true, state: { scene: scene.key, style: style.key, seed, progress } }\n}\n\n/** 시드 결정 그림용 문자열 해시 (FNV-1a). */\nfunction hashString(s: string): number {\n let h = 0x811c9dc5\n for (let i = 0; i < s.length; i++) {\n h ^= s.charCodeAt(i)\n h = Math.imul(h, 0x01000193)\n }\n return h >>> 0\n}\n\n/** mulberry32 — 직접 구현 시드 PRNG (외부 라이브러리 없음). */\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction randomSeed(): string {\n const alphabet = \"abcdefghijklmnopqrstuvwxyz0123456789\"\n const len = 6 + Math.floor(Math.random() * 5)\n let out = \"\"\n for (let i = 0; i < len; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)]\n return out\n}\n\nconst POOL_COLS = 28\nconst VISIBLE_COLS = 8\nconst ROWS = 5\n\ntype Cell = { hue: number; light: number; round: boolean }\n\n/** 시드+씬+스타일로 고정된 무한 스트립을 굽는다. progress는 이 풀 위를 순환 스크롤할 뿐이다\n * — 그래야 같은 시드는 항상 같은 무늬이고, 진행값은 그 무늬 위의 위치만 바꾼다. */\nfunction buildPool(scene: SceneKey, style: StyleKey, seed: string): Cell[][] {\n const sceneHue = SCENES.find((s) => s.key === scene)?.hue ?? 0\n const rnd = mulberry32(hashString(`${scene}:${style}:${seed}`))\n const pool: Cell[][] = []\n for (let c = 0; c < POOL_COLS; c++) {\n const col: Cell[] = []\n for (let r = 0; r < ROWS; r++) {\n const jitter = style === \"chaos\" ? 46 : style === \"smooth\" ? 10 : 24\n col.push({\n hue: (sceneHue + (rnd() - 0.5) * jitter + 360) % 360,\n light: 0.32 + rnd() * 0.4,\n round: style !== \"normal\" ? rnd() > 0.35 : rnd() > 0.6,\n })\n }\n pool.push(col)\n }\n return pool\n}\n\nexport default function SeedDeeplinkShare() {\n const [state, setState] = useState<DeepState>(() => ({\n scene: \"hills\",\n style: \"normal\",\n seed: randomSeed(),\n progress: 0,\n }))\n const [addressInput, setAddressInput] = useState(() => encode(state))\n const [error, setError] = useState<string | null>(null)\n const [copyStatus, setCopyStatus] = useState<\"idle\" | \"copied\" | \"failed\">(\"idle\")\n\n // 컨트롤(씬·스타일·시드·슬라이더)이 바뀌면 가짜 주소 표시줄을 정본 상태로 동기화한다.\n useEffect(() => {\n setAddressInput(encode(state))\n setError(null)\n }, [state])\n\n useEffect(() => {\n if (copyStatus === \"idle\") return\n const t = window.setTimeout(() => setCopyStatus(\"idle\"), 1800)\n return () => window.clearTimeout(t)\n }, [copyStatus])\n\n const pool = useMemo(() => buildPool(state.scene, state.style, state.seed), [state.scene, state.style, state.seed])\n const offset = Math.floor((state.progress / 100) * POOL_COLS)\n const visible = useMemo(\n () => Array.from({ length: VISIBLE_COLS }, (_, i) => pool[(offset + i) % POOL_COLS]),\n [pool, offset],\n )\n\n const handleAddressChange = (value: string) => {\n setAddressInput(value)\n const result = decode(value)\n if (!result.ok) {\n setError(result.reason)\n return\n }\n setError(null)\n setState(result.state)\n }\n\n const regenerateSeed = () => setState((s) => ({ ...s, seed: randomSeed() }))\n\n const copyAddress = async () => {\n try {\n if (!navigator.clipboard) throw new Error(\"no clipboard api\")\n await navigator.clipboard.writeText(addressInput)\n setCopyStatus(\"copied\")\n } catch {\n setCopyStatus(\"failed\")\n }\n }\n\n const cellSize = 100 / VISIBLE_COLS\n const rowSize = 100 / ROWS\n\n return (\n <div\n data-testid=\"seed-deeplink-share\"\n data-address={addressInput}\n data-valid={error ? \"false\" : \"true\"}\n className=\"flex w-full max-w-3xl flex-col gap-3\"\n >\n <div className=\"relative aspect-[16/9] w-full overflow-hidden rounded-lg border border-border bg-muted\">\n <svg viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\" className=\"absolute inset-0 h-full w-full\">\n {visible.map((col, ci) =>\n col.map((cell, ri) => {\n const x = ci * cellSize\n const y = ri * rowSize\n const pad = cellSize * 0.08\n return (\n <rect\n key={`${ci}-${ri}`}\n x={x + pad}\n y={y + pad}\n width={cellSize - pad * 2}\n height={rowSize - pad * 2}\n rx={cell.round ? cellSize * 0.4 : cellSize * 0.06}\n fill={`hsl(${cell.hue.toFixed(1)} 62% ${(cell.light * 100).toFixed(1)}%)`}\n />\n )\n }),\n )}\n </svg>\n </div>\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">씬</span>\n {SCENES.map((s) => (\n <button\n key={s.key}\n type=\"button\"\n onClick={() => setState((prev) => ({ ...prev, scene: s.key }))}\n aria-pressed={state.scene === s.key}\n className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${\n state.scene === s.key\n ? \"border-foreground bg-foreground text-background\"\n : \"border-border text-muted-foreground hover:text-foreground\"\n }`}\n >\n {s.label}\n </button>\n ))}\n <span className=\"ml-2 text-xs text-muted-foreground\">스타일</span>\n {STYLES.map((s) => (\n <button\n key={s.key}\n type=\"button\"\n onClick={() => setState((prev) => ({ ...prev, style: s.key }))}\n aria-pressed={state.style === s.key}\n className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${\n state.style === s.key\n ? \"border-foreground bg-foreground text-background\"\n : \"border-border text-muted-foreground hover:text-foreground\"\n }`}\n >\n {s.label}\n </button>\n ))}\n <button\n type=\"button\"\n onClick={regenerateSeed}\n className=\"ml-auto rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n >\n 시드 재생성\n </button>\n </div>\n\n <label className=\"flex flex-col gap-1 text-xs text-muted-foreground\">\n 진행값 {state.progress.toFixed(0)}\n <input\n type=\"range\"\n min={0}\n max={100}\n value={state.progress}\n onChange={(e) => setState((prev) => ({ ...prev, progress: Number(e.target.value) }))}\n className=\"w-full accent-foreground\"\n />\n </label>\n\n <div className=\"flex flex-col gap-1.5\">\n <p className=\"break-keep text-xs text-muted-foreground\">\n 아래 주소 문자열을 고쳐 넣어 보세요 — 유효하면 위 그림이 그 상태로 그대로 복원됩니다.\n </p>\n <div className=\"flex items-center gap-2\">\n <input\n type=\"text\"\n value={addressInput}\n onChange={(e) => handleAddressChange(e.target.value)}\n spellCheck={false}\n aria-invalid={!!error}\n className={`w-full flex-1 rounded-md border bg-background px-2.5 py-1.5 font-mono text-xs text-foreground ${\n error ? \"border-destructive\" : \"border-border\"\n }`}\n />\n <button\n type=\"button\"\n onClick={copyAddress}\n className=\"shrink-0 rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n >\n {copyStatus === \"copied\" ? \"복사됨\" : copyStatus === \"failed\" ? \"복사 실패\" : \"이 상태의 링크 복사\"}\n </button>\n </div>\n <p className=\"min-h-[1.1rem] text-xs text-destructive\">{error ?? \"\"}</p>\n </div>\n\n <p className=\"break-keep text-sm text-muted-foreground\">\n 조작(시드 재생성·씬·스타일·진행값)은 전부 위 주소 문자열 하나로 접힙니다. 그 문자열을 그대로\n 복사해 다른 곳에 붙여넣으면 같은 그림이 재현되고, 반대로 주소를 직접 고쳐 붙여넣어도 그\n 상태가 그대로 복원됩니다. 다만 아무 문자열이나 받지는 않습니다 — 24자를 넘거나 물음표·느낌표가\n 섞이면 거부되고 이유가 위에 뜹니다. 이 검증 규칙 자체가 딥링크 기법의 절반입니다.\n </p>\n </div>\n )\n}\n"},{"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"],"mobile":{"platforms":{"ios":"none","android":"none"},"gestures":["drag"],"touch_targets":["draggable card"],"safe_area":"none","reduced_motion":"복귀가 즉시(매우 강한 스프링) — 드래그 제스처 자체는 유지","modal_depth":0,"port_notes":"스프링은 값 단위로 그대로 옮겨진다 — RN Reanimated `withSpring`, Flutter `SpringSimulation`. `touch-none select-none` 은 네이티브에서 불필요(제스처 인식기가 소유)"},"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"],"mobile":{"platforms":{"ios":"none","android":"none"},"gestures":["none"],"touch_targets":["none"],"safe_area":"none","reduced_motion":"전 항목 즉시 완전 표시 — 이동도 지연도 없다","modal_depth":0,"port_notes":"`staggerChildren` 은 RN 에서 index×interval 지연을 각 항목 entering 에 주는 방식, Flutter 는 `Interval` 커브로 바뀐다 — 부모가 순서를 소유한다는 규칙은 유지"},"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- Rich-text line entrance (revelatio.studio services-item-entrance.js, research/m55-round2/p2-revelatio.json row 19 + research/m56-round3/revelatio-deep.json): split CMS blocks (p/li/h*) into line spans on `<br>` and newline boundaries, then IntersectionObserver-drive each line up from a CSS var offset (`--services-entrance-y: 0.75rem`); non-GSAP fallback is a plain CSS transition on the same var.\n- Descender clipping fix for masked lines (noth.in, research/m56-round3/noth-in-deep.json): give line/letter children `padding-bottom: .15em` and compensate with `margin-bottom: -.15em` on the mask wrapper, so g/y/p glyphs are not clipped by the overflow mask while line-height stays visually unchanged.\n- Two-tier list reveal: the list container itself opts into an immediate no-scroll fade (delay .5) as one group, while each card's headings keep per-line scroll-triggered stagger; images and cursor labels carry no reveal attributes at all. Container mood arrives first, reading rhythm follows.\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":"view-transition-language-crossfade","name":"View Transition Language Crossfade","pattern_group":"application-ui","kind":"motion-pattern","status":"draft","surface_refs":["websites","saas-dashboards","documentation"],"tokens_used":["color.semantic.surface.base"],"code_asset":"pending","component_refs":[],"term_refs":[],"source_refs":["https://github.com/Yoodeit/MBN_Hackathon","https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition"],"component_notes":[],"last_verified":"2026-08-09","source_path":"recipes/application-ui/view-transition-language-crossfade.md","body":"## Intent\n\nWhen the user switches UI language (or any whole-surface content swap: locale, theme preview, A/B variant), re-rendering in place makes every line of text pop simultaneously — jarring and hard to trust. `document.startViewTransition` crossfades the entire surface from old content to new in one composited fade, and `flushSync` inside the callback guarantees React has committed the new content before the snapshot completes. Harvested from MBN_Hackathon (K-RABBIT), where ko/en/th switching crossfades article and admin surfaces.\n\n## Anatomy\n\n- **Support gate first**: if `document.startViewTransition` is missing **or** `prefers-reduced-motion` is set, apply the state change directly — the swap itself must never depend on the API.\n- **Synchronous commit**: inside the transition callback, wrap the state update in `flushSync` so the \"new\" snapshot is the fully re-rendered DOM, not a stale in-between.\n- **One transition at a time**: ignore switch requests while a transition is running (`transition.finished`), or the snapshots tear.\n- Scope with `view-transition-name` only if a region must animate independently; the default whole-root crossfade is usually right for a language swap.\n\n## Code\n\n```tsx\nfunction switchLocale(next: Locale, apply: () => void) {\n const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n if (!(\"startViewTransition\" in document) || reduced) {\n apply()\n return\n }\n ;(document as Document).startViewTransition(() => {\n flushSync(() => apply())\n })\n}\n```\n\n## States\n\n- Supported: single ~250ms crossfade old→new across the surface.\n- Unsupported / reduced motion: instant swap — identical end state.\n- Rapid toggling: requests during an active transition are dropped, never queued into a flicker.\n\n## Variants\n\n- **Whole-root crossfade (기본)** — `view-transition-name` 을 지정하지 않고 루트 스냅샷 하나로 바꾼다. 언어 전환처럼 화면 대부분이 동시에 바뀔 때.\n- **부분 스코프** — 헤더·본문처럼 독립적으로 움직여야 하는 영역에만 `view-transition-name` 을 준다. 영역이 셋을 넘어가면 스태거처럼 읽히므로 기본형으로 되돌린다.\n- **전환 없음(폴백)** — API 미지원 또는 `prefers-reduced-motion`. 최종 상태가 기본형과 **완전히 같아야** 한다.\n\n## Checks\n\n- Language actually changes with the API unavailable (test by stubbing `startViewTransition` to `undefined`) — the gate is a branch, not a dependency.\n- `flushSync` wraps only the minimal state update — large unrelated work inside it blocks the snapshot.\n- Focus position survives the swap: the focused control (e.g. the language switcher) is still focused after the transition.\n\n## Anti-patterns\n\n- **Crossfading without `flushSync`** — React's async commit races the snapshot and the \"new\" side captures half-updated text.\n- **Animating every text node individually** (per-element fades on locale change) — the point is one composed swap, not a stagger.\n- **Treating the crossfade as the i18n mechanism** — string sourcing/fallback is its own layer; this recipe only owns how the swap looks.\n\n## Agent notes\n\n- 이 recipe 는 **전환의 모양**만 소유한다. 문자열 소싱·폴백·라우팅은 i18n 레이어 소관이며 여기서 결정하지 않는다.\n- 지원 게이트를 `try/catch` 로 쓰지 말 것 — `startViewTransition` 부재는 예외가 아니라 분기다.\n- `status: draft` 이고 `code_asset` 이 아직 없다. 라이브 데모 없이 문서만 존재하는 상태이며, 승격하려면 `recipe-format.md` §승격 게이트를 따른다."},{"id":"window-edge-affordance","name":"Window Edge Affordance","pattern_group":"application-ui","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m60","surface_refs":["saas-dashboards","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.destructive","dimension.radius.sm","dimension.radius.md","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/window-edge-affordance.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-17","source_path":"recipes/application-ui/window-edge-affordance.md","body":"## Intent\n\nA desktop-style window's resize handles are placed *across* its border rather than inside it — 6px on the edges, 12px at the corners, offset -3px outward — so a pointer up to 3px outside the visible window still grabs a handle. Dragging or resizing a window off-screen is bounded by a per-direction allowance rather than a single margin, and the allowance is asymmetric: 150px to the left, 50px to the right, 15px up, 15px down. The asymmetry is not about how much of the window stays visible — it is about whether the window can still be recovered. Push a window left and its title bar's right edge stays on-screen, so the user can still find and drag it back. Push a window up and the title bar itself leaves the screen with nothing left to grab, so there is almost no room to give before the system pulls it back. The rule underneath both drag and resize is the same question, \"can this still be reclaimed\", applied with different mechanics: dragging past the allowance snaps back on release (soft, reversible), while resizing past the top edge is hard-clamped in real time (the height is eaten as the pointer moves, it never actually crosses y=0). Observed in daedalOS (`components/system/Window/RndWindow/rndDefaults.ts`, `Window/functions.ts`, `RndWindow/useRnd.ts` — https://github.com/DustinBrett/daedalOS, HEAD 0df82d7, MIT). Full row citations: `research/m60-round6/p2-daedalos.json` rows r6-01–r6-07, r6-17.\n\n## Anatomy\n\n- Resize handles overlay the border: edge handles are `6px` thick, corner handles `12px` square, both offset `-3px` so they straddle the visible edge instead of sitting flush inside it.\n- The offscreen buffer is a fixed rectangle stated per direction, not a percentage of window size: `{ left: 150, right: 50, top: 15, bottom: 15 }`.\n- Minimum window size (`166×30`) is small enough that at the floor, only the title bar remains — the content area collapses to zero by layout, not by a special-cased \"collapsed\" state.\n- The title bar is the drag handle (`handle` class); a control inside it (the maximize/restore button) is excluded from that handle (`cancel` class) so clicking it doesn't also start a drag.\n- Maximized state disables both dragging and resizing outright rather than clamping them to a 1×1 range.\n\n## States\n\n- Idle: window sits inside the stage at its committed rect; no border emphasis.\n- Dragging inside bounds: no violation state, position updates live.\n- Dragging past the offscreen buffer: the window gets a danger border/ring while the pointer is down, signaling the release-time snap-back before it happens.\n- Drag released outside bounds: the window animates back to its pre-drag position over a short transition; released inside bounds, the new position commits immediately.\n- Resizing from the top edge toward y < 0: height is eaten in real time and position is clamped to 0 — there is no violation flash and no snap-back, because the pointer is never allowed to produce an out-of-bounds rect to begin with.\n- Maximized: handles and drag are inert; only the restore control is live.\n\n## Variants\n\n- The buffer values themselves (150/50/15/15) are a specific product's tuning, not a law — what should transfer to any consuming context is the *shape* of the rule (asymmetric, recoverability-based) and the drag-vs-resize split between soft snap-back and hard clamp.\n- A surface with a title bar on the bottom (rare, but some tiling/dock-adjacent UIs place it there) would need to flip which direction gets the wide allowance — the rule is \"protect the edge that holds the affordance to grab it again,\" not \"always favor left.\"\n\n## Code\n\n```tsx\n// r6-05: 방향별 고정 오프스크린 허용치. '얼마나 보이는가' 가 아니라 다시 잡을 수 있는가.\nconst BUFFER = { LEFT: 150, RIGHT: 50, TOP: 15, BOTTOM: 15 }\n\nfunction isOutsideBounds(r: Rect) {\n return (\n r.x < -BUFFER.LEFT ||\n r.x + r.width > STAGE_W + BUFFER.RIGHT ||\n r.y < -BUFFER.TOP ||\n r.y + r.height > STAGE_H + BUFFER.BOTTOM\n )\n}\n\n// r6-06: 위쪽 리사이즈는 드래그의 버퍼(소프트, 스냅백)와 달리 하드 클램프다 —\n// 포인터가 y<0 을 만들 수 없게 실시간으로 높이를 깎는다.\nif (drag.dir.includes(\"n\") && ny < 0) {\n nh += ny\n ny = 0\n}\n\n// r6-07: 손을 놓는 순간에만 경계 판정을 커밋한다 — 밖이면 시작 좌표로 되돌린다.\nif (win && isOutsideBounds(win)) {\n setWindows((ws) => ws.map((w) => (w.id === drag.id ? { ...w, x: drag.startX, y: drag.startY } : w)))\n}\n```\n\n## Checks\n\n- Resize handle hit-area must extend outward past the visible border, not sit flush inside it — otherwise a pointer that's a couple pixels off the edge misses the handle entirely.\n- The offscreen buffer must differ by direction; a single uniform margin loses the actual point (some edges are recoverable, some aren't).\n- Drag-past-bounds must be a release-time check with a soft snap-back transition; resize-past-top must be a per-frame hard clamp. Swapping these (hard-clamping drag, soft-snapping resize) produces visibly wrong behavior — a dragged window should be draggable *through* the bad zone and only corrected on release, while a resize should never be allowed to produce a y<0 rect at all.\n- Minimum size must be at least as tall as the title bar, so hitting the floor degrades to \"title bar only,\" never to a sliver with no visible content and no way to identify the window.\n- The title bar's own interactive controls (close/maximize) must be excluded from the drag handle's hit area, or clicking them also starts a drag.\n\n## Anti-patterns\n\n- Giving every edge the same offscreen allowance. It looks consistent but it's blind to whether the user can still reach the window — the actual failure mode being guarded against.\n- Snapping a dragged window back mid-drag instead of on release. It fights the user's hand and reads as jitter instead of a boundary.\n- Hard-clamping bottom/left/right the same way as top. Those directions have a recoverable edge on-screen even when pushed far out, so a hard clamp there is over-restrictive compared to the soft buffer they actually need.\n- Sizing resize handles exactly to the visible border with no outward offset. Precision-testing this against real pointer input shows users routinely miss the handle by a few pixels.\n\n## Agent notes\n\n- prompt_phrases: \"resizable draggable window\", \"desktop window manager bounds\", \"keep window from being lost off-screen\", \"window snap-back on release\".\n- fallbacks: without pointer capture / drag libraries, the minimum viable version is the offscreen-bounds check (`isOutsideBounds`) plus a release-time position reset — the resize-time hard clamp on the top edge is the part most often skipped, and skipping it is how windows get stranded above the visible stage."},{"id":"window-stack-focus","name":"Window Stack Focus","pattern_group":"application-ui","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m60","surface_refs":["websites","saas-dashboards","internal-tools"],"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","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/window-stack-focus.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-17","source_path":"recipes/application-ui/window-stack-focus.md","body":"## Intent\n\nA multi-window stack derives z-order and \"what's focused\" from one array instead of separate state. `zIndex = stackOrder.length + (minimized ? 1 : -stackOrder.indexOf(id)) + 1` (daedalOS `useFocusable.ts`, ledger `research/m60-round6/p2-daedalos.json` row r6-11) maps stack position to z-index by negative index, so bringing a window to front is a single array reorder rather than a z-index bump that can drift out of sync with focus state.\n\nClosing or minimizing the focused window does not fall back to \"the window right underneath\" — it falls back to the topmost window in `stackOrder` that is not minimized (`useNextFocusable.ts`, row r6-12: `stackOrder.find(sid => sid !== id && !processes[sid]?.minimized)`). A minimized window can sit anywhere in the array without silently stealing focus back when something above it closes.\n\nMinimizing does not fade in place — it flies toward the window's own taskbar entry, computed as the delta between the two elements' bounding-rect centers (`useWindowTransitions.ts`, row r6-14: `x = round(taskbarX - windowX - windowWidth/2 + taskbarWidth/2)`, y symmetric). The eye can follow where a disappearing window went. Close and minimize use different scale targets (0.95 vs 0.7, row r6-16) so the two exits read as distinct actions rather than the same generic fade.\n\nA third rule (row r6-13, `focusedOnTaskbarEntry || focusedOnInsideWindow`) whitelists what counts as \"kept focus\" — clicking the focused window's own taskbar entry does not flash it inactive. This demo achieves the same property structurally: taskbar click and window state update in one batched setState, so there is no intermediate unfocused frame to render.\n\nSource: daedalOS (`https://dustinbrett.com`, MIT license, HEAD `0df82d7`), harvested via `research/m60-round6/p2-daedalos.json` cluster C3 in `research/m60-round6/disposition.json`.\n\n## Anatomy\n\n- `stackOrder: string[]` — index 0 is frontmost. Single source of truth for both z-index and \"next focus\".\n- `runtime: Record<id, { minimized, maximized, closed, prevRect }>` — per-window flags, separate from position/size.\n- `rects: Record<id, { x, y, width, height }>` — current geometry, swapped wholesale on maximize/restore.\n- `phase: Record<id, \"idle\" | \"closing\" | \"minimizing\">` — drives which Motion `animate` target and duration apply; cleared in `onAnimationComplete`.\n- `flyDelta: Record<id, { x, y }>` — measured once per minimize call from live `getBoundingClientRect()` on the window and its taskbar button, then held for the animation.\n- `focusedId` — derived (`useMemo`), never stored: first id in `stackOrder` that is neither minimized nor closed.\n\n## States\n\n- Idle / focused — front of `stackOrder`, full opacity, accent border (`border-foreground/40`).\n- Idle / background — anywhere else in `stackOrder`, default border, lower z-index by stack position.\n- Minimizing — flies toward its taskbar entry at scale 0.7 over 360ms `cubic-bezier(0.22,1,0.36,1)`, then unmounts (removed from render, stays in `stackOrder`).\n- Minimized (idle) — not rendered in the stage; taskbar button shows a dimmed label.\n- Closing — shrinks in place to scale 0.95 over 220ms ease-out, then removed from `stackOrder` and marked closed.\n- Maximized — geometry set to `{0, 0, stageWidth, stageHeight - TASKBAR_HEIGHT}`; restoring reads back the `prevRect` captured at maximize time.\n\n## Variants\n\n- Restore-and-focus is symmetric in this demo (clicking a minimized taskbar entry restores and brings to front in one step) because the original's exact restore-focus interaction was not confirmed from the source — this is a stated assumption, not an observed value.\n- Maximize is collapsed to a single on/off toggle here; the original's resize-follow and drag-lock behavior is out of scope (tracked separately as the C1/C2 clusters in the same harvest round).\n- Stage uses an inverted surface (`bg-foreground`) against window/taskbar chrome (`bg-background`) specifically so windows read as objects sitting on a desktop rather than blending into it — swap the desktop treatment freely, but keep it a different token from the window body.\n\n## Code\n\n```tsx\n// r6-11: z-index from stack position, not a separate counter\nconst index = stackOrder.indexOf(spec.id)\nconst zIndex = stackLength + (state.minimized ? 1 : -index) + 1\n\n// r6-12: next focus = topmost non-minimized entry, derived every render\nconst focusedId = useMemo(\n () => stackOrder.find((id) => !runtime[id]?.minimized && !runtime[id]?.closed),\n [stackOrder, runtime],\n)\n\n// r6-14: minimize flies toward its own taskbar entry (center-to-center delta)\nconst winRect = winEl.getBoundingClientRect()\nconst taskbarRect = taskbarEl.getBoundingClientRect()\nconst x = Math.round(taskbarRect.left - winRect.left - winRect.width / 2 + taskbarRect.width / 2)\nconst y = Math.round(taskbarRect.top - winRect.top - winRect.height / 2 + taskbarRect.height / 2)\n\n// r6-16: close vs minimize use different scale targets\nconst animateTarget =\n currentPhase === \"closing\"\n ? { opacity: 0, scale: 0.95, x: 0, y: 0 }\n : currentPhase === \"minimizing\"\n ? { opacity: 0, scale: 0.7, x: delta.x, y: delta.y }\n : { opacity: 1, scale: 1, x: 0, y: 0 }\n```\n\n## Checks\n\n- Bringing a window to front must be a single array reorder (`[id, ...prev.filter(x => x !== id)]`), not a z-index increment — increments drift once windows close and reopen.\n- Focus fallback must search `stackOrder` for the topmost non-minimized window, not assume the window one position below the closed/minimized one — that assumption breaks the moment a minimized window sits between them.\n- The fly-to-taskbar delta must be measured live via `getBoundingClientRect()` at minimize time, not precomputed from initial layout — window position changes (drag, maximize/restore) between mounts.\n- Close and minimize must use visually distinct scale targets; using the same value for both makes the two exits indistinguishable.\n- Stage surface and window/taskbar surface must be different semantic tokens — same token on both collapses contrast to zero while still passing a hardcoded-color lint.\n\n## Anti-patterns\n\n- Storing `focusedId` as its own state variable instead of deriving it from `stackOrder` + `runtime`. Two sources of truth for \"what's focused\" go out of sync the first time a window is minimized and restored through a different path than expected.\n- Using a plain z-index counter that increments on every focus. Without resetting relative to `stackOrder` length, the counter grows unbounded and eventually overflows meaningful comparison, and closed windows leave gaps.\n- Minimizing with a generic center-of-viewport fade instead of a taskbar-targeted fly. It looks like a bug fade rather than \"the window went there,\" and users lose track of where their content went.\n- Skipping the whitelist on what counts as \"lost focus.\" Naively blurring a window whenever any click happens outside its bounds flashes it inactive when the click was on its own taskbar entry.\n\n## Agent notes\n\n- prompt_phrases: \"multi-window app UI\", \"taskbar minimize animation\", \"window manager focus stack\", \"desktop-style window stacking\", \"z-index from array order\".\n- fallbacks: without a motion library, snap directly between `idle`/`minimized`/`closed` states with no transition — the stack-order and focus-fallback logic is independent of whether the exit is animated. Without `getBoundingClientRect()` access (e.g. SSR), fall back to a plain opacity fade in place instead of the taskbar-targeted fly."},{"id":"add-to-cart-feedback-modes","name":"Add-to-Cart Feedback Modes","pattern_group":"commerce","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m62","surface_refs":["commerce"],"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.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/add-to-cart-feedback-modes.tsx","component_refs":["button","popover"],"term_refs":["cart-summary","cart-summary-bar","drawer","toast"],"source_refs":[],"last_verified":"2026-08-17","source_path":"recipes/commerce/add-to-cart-feedback-modes.md","body":"## Intent\n\n\"Add to cart\" is one event, but three production services answer it three different ways: a full-page navigation, an anchored popover, and an auto-opening drawer. None of the three is the correct answer — the only behavior all three share is that the header cart count updates immediately, before whatever secondary feedback follows. This recipe does not prescribe one mode; it puts the three side by side so the choice of mode is a deliberate product decision, not a default. It differs from `actionable-toast` in scope — that recipe documents one feedback shape (a transient toast with an undo/retry action) in depth, while this one documents the fork itself: which of several structurally different modes to reach for.\n\n## Anatomy\n\n- Header cart count: a number in the persistent shell that increments the instant the add-to-cart action fires, independent of which secondary mode is active.\n- Trigger: an add-to-cart control, either on a product-detail view or as an overlay button on a grid card image.\n- Mode 1 — full-page transition: the click replaces the current screen (or, with real routing, changes the URL) with a cart view. No overlay renders; the page itself becomes the confirmation.\n- Mode 2 — anchor popover: a small panel opens directly above the trigger (`bottom: 100%`) with a pointing arrow, states the item that was added, and offers \"continue shopping\" / \"go to cart\" actions before it auto-dismisses.\n- Mode 3 — drawer auto-open: a side-anchored panel opens automatically when the cart quantity changes, without a spinner or toast preceding it.\n\n## States\n\n- Idle: header count at its current value, no secondary feedback visible.\n- Added (full-page): header count incremented, screen replaced by the cart view.\n- Added (popover): header count incremented, popover open and anchored to the trigger, auto-dismiss timer running.\n- Added (drawer): header count incremented, drawer opens only if the quantity actually changed from its last known value — a repeat signal for the same quantity must not reopen it.\n- Popover dismissed: timer cleared, panel closed, trigger and product state unchanged.\n- Drawer closed: user-initiated close, quantity state unchanged.\n\n## Variants\n\n- Full-page transition suits a flow where the cart itself is a first-class page a user is expected to review before checkout.\n- Anchor popover suits a dense grid or catalog view where leaving the page to confirm one item would break browsing momentum.\n- Drawer auto-open suits a flow where quantity changes happen often (multi-item carts, quick re-adds) and a persistent side panel is worth the screen space.\n\n## Code\n\n```tsx\nconst addToCart = useCallback(\n (productName: string) => {\n // Convergence point across all three modes: the header count updates first.\n const nextCount = cartCount + 1\n setCartCount(nextCount)\n setLastAdded(productName)\n\n if (mode === \"fullpage\") {\n setShowFullpage(true)\n return\n }\n\n if (mode === \"popover\") {\n if (popoverTimer.current) clearTimeout(popoverTimer.current)\n setPopoverOpen(true)\n popoverTimer.current = setTimeout(() => setPopoverOpen(false), 3200)\n return\n }\n\n // Drawer mode: compare against the last known quantity so a no-op\n // signal never reopens an already-open drawer.\n if (nextCount !== quantityRef.current) {\n quantityRef.current = nextCount\n setDrawerOpen(true)\n }\n },\n [cartCount, mode],\n)\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/add-to-cart-feedback-modes.tsx`.\n\n## Checks\n\n- The header cart count updates on the add-to-cart click itself, not after the secondary feedback finishes rendering.\n- Clicking add-to-cart with no state change (e.g. a duplicate signal for a quantity that didn't move) must not reopen an already-open drawer.\n- The anchor popover stays anchored to its trigger button, not to the viewport, so it stays correct in a scrolling grid.\n- Switching between modes clears any pending timers and closes any open panel from the previous mode before the next mode's trigger fires.\n- The full-page mode does not also render an overlay panel — it is a screen replacement, not an overlay on top of the product page.\n\n## Anti-patterns\n\n- **Silent count**: incrementing the header cart count without any visible acknowledgment at the trigger leaves the user unsure the click registered.\n- **Stacked feedback**: firing a toast, a popover, and a drawer for the same click buries the one useful signal under redundant ones.\n- **Stale drawer reopen**: opening the drawer on every add-to-cart call instead of on an actual quantity change, so an unrelated re-render reopens a drawer the user just closed.\n- **Mode mismatch**: using a full-page transition for a high-frequency quick-add grid, which breaks browsing momentum that a popover or drawer would have preserved.\n\n## Agent notes\n\n- prompt_phrases: \"장바구니에 담으면 어떤 피드백을 보여줄지 세 가지 방식으로 비교해줘\", \"add to cart feedback으로 풀페이지 전환/팝오버/드로어 중 뭘 쓸지 결정하고 싶어\"\n- fallbacks: if only one feedback mode is in scope, `actionable-toast` covers the transient-notification case and `cart-drawer` covers the persistent side-panel case — this recipe is for the decision between structurally different modes, not a fourth mode in itself."},{"id":"cart-drawer","name":"Cart Drawer","pattern_group":"commerce","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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":"cart-line-item-anatomy","name":"Cart Line Item Anatomy","pattern_group":"commerce","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m62","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.radius.md","dimension.radius.lg","typography.scale.sm","typography.scale.xs"],"code_asset":"examples/ui-vocabulary-site/src/components/cart-line-item-anatomy.tsx","component_refs":["cart-drawer"],"term_refs":["quantity-stepper","cart-summary","cart-summary-bar"],"source_refs":["amazon","emart-mall","shopify-dawn","vercel-commerce"],"last_verified":"2026-08-17","source_path":"recipes/commerce/cart-line-item-anatomy.md","body":"## Intent\n\nThis recipe is a structural close-up of the single cart line item that `cart-drawer` renders in its item-row slot — it does not replace `cart-drawer`, it fills in the row-level detail that recipe leaves at \"thumbnail, title, variant, per-item quantity control, remove action, and line price.\" Row anatomy (three columns, a separate action bar) and the drawer shell itself (opaque vs. translucent, timing) are the two things worth getting right independently, so they are documented here together with a corrected touch-target default that neither observed site got right.\n\n## Anatomy\n\n- Row: three columns — image, info, price — not a single flex row with everything inline. The image column is fixed-width; the info column carries title/variant and stretches; the price column sits fully separated on the right, never wrapped into the info column's text flow.\n- Action bar: quantity stepper and remove action move out of the info column's text stack and into a dedicated bar the same width as the info column, `height: 32px`. This keeps the title/variant text block visually stable regardless of stepper state.\n- Quantity stepper: decrease / value / increase, `border-radius: full`, sized to the corrected 44px default (see States below), not to the observed 32px convention.\n- Summary/footer: subtotal row anchored with a single `border-top`, no drop shadow, padding asymmetric left/right (`1.5rem` / `2.8rem`) rather than symmetric — matches `cart-drawer`'s Subtotal element but at the row-anatomy level of detail.\n- Drawer shell (two variants, selectable in the code demo): an opaque 40rem panel with a 200ms ease transition (Dawn), or a translucent 390px panel with backdrop blur and an asymmetric open/close transition (vercel/commerce).\n\n## States\n\n- Default: stepper at 44px, action bar visible, row shows one line item.\n- Convergence toggle (demo-only): switching the corrected-target checkbox off shows the observed 32px stepper for side-by-side comparison — this is a teaching control, not a production state.\n- Drawer opening (Dawn): `translateX(100%)` → `translateX(0)` over 200ms ease, same duration on close.\n- Drawer opening (vercel/commerce): 300ms ease-in-out on open, 200ms on close — open and close are not the same duration.\n- Row removed: item leaves the list; subtotal recalculates in the same render, same rule as `cart-drawer`.\n\n## Variants\n\n- Drawer shell — opaque 40rem (Shopify Dawn) vs. translucent 390px with `backdrop-blur-xl` (vercel/commerce). Both are legitimate answers to \"what does the cart drawer surface look like\"; pick one per product, don't mix the opacity model with the other's width.\n- Header padding — observed at 이마트몰 as asymmetric (`padding: 26px 0 20px 16px`), reflected in the code demo's header padding rather than a symmetric default.\n\n## Code\n\n```tsx\n// Action bar: same width as the info column, fixed height, holds stepper + remove\n<div className=\"flex items-center gap-2\" style={{ height: 32 }}>\n <div\n data-testid=\"quantity-stepper\"\n className=\"flex items-center justify-center gap-2 rounded-full border border-border text-sm text-foreground\"\n style={{ height: stepperSize, minWidth: stepperSize * 2.4 }}\n >\n <button aria-label=\"수량 감소\" style={{ width: stepperSize, height: stepperSize }}>−</button>\n <span>1</span>\n <button aria-label=\"수량 증가\" style={{ width: stepperSize, height: stepperSize }}>+</button>\n </div>\n <button type=\"button\" className=\"text-xs text-muted-foreground underline\">삭제</button>\n</div>\n```\n\nFull implementation, including the two-shell drawer toggle and the observed-vs-corrected stepper comparison: `examples/ui-vocabulary-site/src/components/cart-line-item-anatomy.tsx`.\n\n## Checks\n\n- The three columns (image, info, price) are laid out as separate flex/grid children, not simulated with text alignment inside one column.\n- The action bar (quantity + remove) is a distinct block below the title/variant text, not inline with it.\n- The subtotal/footer uses `border-top` only — no box-shadow.\n- The quantity stepper's tap target is at least 44×44px. An observed convention below that (32px, 36px) is not an acceptable default even when multiple reference sites agree on it.\n- Drawer open/close durations are read from the chosen shell variant, not assumed symmetric — vercel/commerce's 300ms/200ms split is a deliberate asymmetry, not a bug to normalize away.\n\n## Anti-patterns\n\n- **Copying the touch-target convergence**: Amazon (32px, AZ-13), Shopify Dawn (32px, GH-07), and vercel/commerce (36px, GH-14) all ship a quantity stepper under the WCAG 44px minimum. All three converging on a similar number is not evidence the number is correct — it is evidence the same accessibility gap shipped independently in three places. This recipe corrects to 44px instead of reproducing the convergence.\n- **Merging the action bar into the info column's text flow**: putting the stepper next to the variant text instead of in its own fixed-height bar makes the row's height change unpredictably as controls render.\n- **Symmetric footer padding by default**: the observed asymmetric padding (`1.5rem 2.8rem 1.5rem 0`) exists because the right edge needs extra clearance next to a scrollbar/close affordance — collapsing it to a single uniform value loses that reason.\n- **Treating this recipe as a drawer replacement**: this is row-level anatomy plus a shell comparison, not a substitute for `cart-drawer`'s trigger/scrim/empty-state/focus-return contract — implement both together, not one instead of the other.\n\n## Agent notes\n\n- prompt_phrases: \"장바구니 행을 이미지·정보·가격 3열로 나누고 수량/삭제는 별도 액션바로 내려줘\", \"cart line item 수량 스테퍼를 44px 터치 타깃으로\", \"cart drawer 셸을 불투명 40rem이나 반투명 390px 중에 골라줘\"\n- fallbacks: if a design references an observed touch-target convention (32px/36px) as the target, correct it to 44px and note the correction — do not silently ship the smaller size just because multiple competitor sites use it.\n- component composition: pairs with `cart-drawer` — that recipe owns the drawer's open/close/dismiss/focus-return contract and the overall panel; this recipe owns the item row's internal structure and the two shell-styling variants shown in its demo toggle."},{"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"],"mobile":{"platforms":{"ios":"checkout form screen in navigation stack","android":"checkout screen with app bar"},"gestures":["tap","scroll"],"touch_targets":["quantity control","edit action","remove action","confirm button"],"safe_area":"none","reduced_motion":"none","modal_depth":0,"port_notes":"통화·수량 표기는 플랫폼 로케일 API 로 바뀐다(`Intl.NumberFormat` → `NumberFormatter`(iOS)·`NumberFormat`(Android/Flutter)). 결제 최종 액션은 네이티브 결제 시트와 연결될 수 있으므로 그 경계를 먼저 정한다"},"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":"price-hierarchy-stack","name":"Price Hierarchy Stack","pattern_group":"commerce","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m62","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.action.destructive","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.xs","typography.scale.xl","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/price-hierarchy-stack.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-17","source_path":"recipes/commerce/price-hierarchy-stack.md","body":"## Intent\n\nA price stack shows three numbers with three different meanings: what the item used to cost, how much was cut, and what the customer actually pays now. Three large commerce sites converge on the same answer to \"which one should read biggest\" — size inversion. The list price is pushed to the smallest, quietest treatment (struck through, muted), the discount rate gets a compact badge, and the sale price is rendered largest and boldest, because it is the only number that describes money the customer is about to spend. The same size-inversion logic reappears in a fixed checkout bar, where the total's numeral dwarfs its own label — the label just names the row, the numeral is the number a customer is about to commit to. Observed rows: CP-B1 (Coupang search card — list price 12px/14px line-through gray, discount badge white-on-red, sale price 20px/24px bold red), EM-05 (Emart Mall product card — coupon price 13px/600 accent-red, non-strikethrough list price 12px gray, 4px row gap), AZ-14 (Amazon cart row — title 18px/400, discounted price raised to 18px/700 to match the title's size, list price 12px/400 struck through, discount badge 11px/700 white-on-red), EM-02 (Emart Mall fixed checkout bar — label 20px/400, total numeral 35px/500). Full row citations: `research/m62-round7/p2-ledger.json` rows CP-B1, EM-05, EM-02, AZ-14 (계열_대조 rating: A, converged).\n\n## Anatomy\n\n- List price: smallest treatment in the stack, muted text color, struck through.\n- Discount badge: compact pill, bold weight, high-contrast fill (destructive/accent), shown only when a discount applies.\n- Sale price: largest and boldest number in the stack, default text color, not muted.\n- Optional fixed total row (checkout bar / cart summary): a label at a moderate size next to a numeral rendered substantially larger — the same size-inversion logic applied to a running total instead of a single item.\n\n## States\n\n- No discount: the discount badge is omitted entirely rather than shown empty or at 0%; only the price remains, still rendered at the \"sale price\" size and weight since it is still the number being charged.\n- Discount applied: all three elements present — list price (struck through, smallest), badge (compact, bold), sale price (largest, boldest).\n- Fixed total row present (e.g. checkout bar): label stays at a readable but secondary size; the total numeral is rendered meaningfully larger than the label, mirroring the item-level hierarchy at the aggregate level.\n\n## Variants\n\n- Badge omitted, price only kept large and bold (a light-discount or no-discount SKU) — seen in EM-05 for near-zero discounts.\n- Discounted price raised to match the original title/price line's size rather than shrinking the original further (AZ-14) — the inversion can be achieved either by shrinking the list price, growing the sale price, or both; what must not happen is all three landing at the same size.\n- Fixed total bar variant: label/numeral pair reuses the identical size-inversion ratio as the per-item stack, scaled up (EM-02: label 20px vs numeral 35px, roughly the same ratio as sale price vs list price at item scale).\n\n## Code\n\n```tsx\nfunction PriceStack({ listPrice, salePrice, discountPct }: PriceStackProps) {\n return (\n <div className=\"flex flex-col gap-2\">\n <span className=\"text-xs text-muted-foreground line-through\">\n {formatMoney(listPrice)}\n </span>\n {discountPct > 0 && (\n <span className=\"inline-flex w-fit items-center rounded bg-destructive px-1.5 py-0.5 text-[11px] font-bold text-destructive-foreground\">\n {discountPct}%\n </span>\n )}\n <span className=\"text-xl font-bold text-foreground\">\n {formatMoney(salePrice)}\n </span>\n </div>\n )\n}\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/price-hierarchy-stack.tsx`.\n\n## Checks\n\n- The sale price must be the largest and boldest number in the stack — never tied in size with the list price or the badge.\n- The list price must use a muted color and a struck-through treatment, and must render smaller than the sale price.\n- A discount badge only appears when a discount actually applies; it is never rendered empty or at 0%.\n- If a fixed total row exists alongside the item stack (e.g. a checkout bar), its numeral must be visibly larger than its own label — the same inversion, applied once more at the total level.\n- Removing the size inversion (all three numbers at equal size) must read as a visible regression, not a neutral restyle — the hierarchy is the point, not a decoration on top of it.\n\n## Anti-patterns\n\n- **Flat pricing**: rendering list price, discount badge, and sale price at the same size and weight. All three sites tested against this and converged away from it — it forces the reader to parse meaning from position and color alone instead of size, and slows down \"how much do I actually pay.\"\n- **Sale price not visually dominant**: making the discount badge or the list price larger or bolder than the sale price. The badge exists to draw the eye toward the sale price, not to compete with it.\n- **Checkout total that doesn't scale with its label**: setting a fixed bar's total numeral to the same size as its label loses the one place a running total most needs emphasis — right before commit.\n\n## Agent notes\n\n- prompt_phrases: \"정가는 취소선으로 작게, 판매가는 크고 굵게 보여주는 price hierarchy stack을 만들어줘\", \"할인율 배지랑 판매가 크기 대비를 쿠팡/이마트/아마존처럼 맞춰줘\"\n- fallbacks: if no discount exists for a SKU, omit the badge and the strikethrough list price entirely — do not fake a 0% badge or an unstruck duplicate price line.\n- component composition: pairs naturally with a fixed checkout/cart total bar that repeats the same label-small/numeral-large ratio; see `checkout-order-summary` for the surrounding summary block this stack usually sits inside."},{"id":"product-detail-purchase-stack","name":"Product Detail Purchase Stack","pattern_group":"commerce","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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":"product-grid-density-presets","name":"Product Grid Density Presets","pattern_group":"commerce","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m62","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.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/product-grid-density-presets.tsx","component_refs":[],"term_refs":["product-card"],"source_refs":["emart-mall","amazon","coupang"],"last_verified":"2026-08-17","source_path":"recipes/commerce/product-grid-density-presets.md","body":"## Intent\n\nThis is a companion to `category-product-grid`, not a replacement — that recipe owns the filter/sort/loading/empty shell around the grid, while this one owns two things that shell doesn't cover: how a grid's card dimensions change when the density preset changes, and how a card's own internal vertical rhythm is built.\n\nA density preset is not \"the same grid shrunk.\" Emart Mall's category grid has three named presets (4/5/6 columns), and each preset redefines the card's own width and gap rather than scaling one base card down: 4 columns is 232px cards with a 24px gap inside a 1000px container (232×4 + 24×3 = 1000, verified exact); 5 columns is 236px cards with a 20px gap; 6 columns is 196px cards with a 20px gap. Each preset also changes the container width itself, since the container is the sum of its own cards and gaps, not a fixed frame the cards are stuffed into.\n\nLayout choice is a separate axis from density. Amazon's search results are not a grid at all — a full-width list where each card is 270px tall, spans the container's full width, and sits against its neighbors with zero gap; the divider between items is drawn on the card's own bottom edge, not in the space between cards. Switching a product set between grid and full-width list changes information density and reading direction independently of which density preset a grid uses.\n\nInside the card, Coupang's search-result cards show a fixed vertical rhythm that holds regardless of density preset or layout: a badge row (earn-amount pill, then rating) with `gap-y 10px` and `py 10px` on its wrapper, then a title block with `pt 8px`, then a price block with `pt 10px`.\n\n## Anatomy\n\n- Density selector: a segmented control choosing among named presets (not a slider or percentage), each preset bound to its own card width and gap.\n- Grid container: width derived from `cardWidth * columns + gap * (columns - 1)` — recomputed per preset, not a fixed frame.\n- Layout switch: grid vs. full-width list, independent of the density selector (disabled or hidden together when the list layout is active, since list has no column concept).\n- Card badge row: earn-amount pill (rounded, `px 6px` / `py 2.5px`, 14px icon, `gap 4px`) followed by a rating badge (star group at `gap 2px`, review count at 12px/14px line-height, `gap 4px` between star group and review count).\n- Card body rhythm: badge row wrapper (`gap-y 10px`, `py 10px`) → title (`pt 8px`) → price (`pt 10px`), in that fixed order.\n- List-layout divider: drawn on each card's own trailing edge, not as a border between two cards.\n\n## States\n\n- Density changed (grid layout): card width, gap, and container width all recompute together — no single dimension holds constant across presets.\n- Layout changed (grid → list): column concept and density selector become inapplicable; cards go full-width with zero inter-card gap.\n- List layout, item boundary: no gap between cards; the visual separation is the current card's own bottom border.\n\n## Variants\n\n- Two-preset or four-preset density controls are acceptable variants of the same idea — what must transfer is that each preset owns its own card width and gap pair, not a shared card scaled by a multiplier.\n- A grid-only product (no full-width list option) is a valid subset; the list layout is Amazon's addition, not a requirement of the density-preset pattern itself.\n\n## Code\n\n```tsx\n// EM-06: card width and gap per density preset — not one card scaled, three distinct definitions\nconst DENSITY_CONFIG: Record<Density, DensityConfig> = {\n 4: { cardWidth: 232, gap: 24, rowGap: 0, source: \"ty_lst4_w1000\" },\n 5: { cardWidth: 236, gap: 20, rowGap: 20, source: \"ty_lst5\" },\n 6: { cardWidth: 196, gap: 20, rowGap: 20, source: \"ty_lst6\" },\n};\n\nconst containerWidth = config.cardWidth * density + config.gap * (density - 1);\n\n// CP-B4: fixed vertical rhythm inside every card, independent of density/layout\n<div className=\"flex flex-col gap-y-[10px] py-[10px]\">\n <EarnBadge amount={product.earnAmount} />\n <RatingBadge rating={product.rating} reviewCount={product.reviewCount} />\n</div>\n<p className=\"pt-[8px]\">{product.name}</p>\n<p className=\"pt-[10px]\">{formatWon(product.price)}</p>\n```\n\nFull implementation: `examples/ui-vocabulary-site/src/components/product-grid-density-presets.tsx`.\n\n## Checks\n\n- Switching density presets recomputes card width, gap, and container width together — no preset reuses another preset's card width.\n- The list layout carries zero gap between cards and draws its divider on the card's own edge, not between cards.\n- The density selector is inert or hidden when the list layout is active, since list has no column count to vary.\n- The badge-row → title → price order and its gap values hold across every density preset and both layouts.\n\n## Anti-patterns\n\n- **Density as pure scale**: treating a denser preset as \"the same card at 80% size\" instead of a distinct width/gap pair — real density presets redefine card dimensions, they don't scale one base card.\n- **Container as fixed frame**: hardcoding container width instead of deriving it from the active preset's card width and gap — this is how 5-column and 6-column presets end up misaligned.\n- **Gap between list cards**: adding inter-card gap to a full-width list layout and then also drawing a divider, producing a double separator.\n- **Card rhythm drift by preset**: letting the badge-row/title/price gap values change between density presets or layouts — the internal rhythm is a constant that a grid's density and layout choices should not touch.\n\n## Agent notes\n\n- prompt_phrases: \"상품 그리드 밀도 프리셋 4/5/6열로 만들어줘\", \"카드 폭이랑 gap이 프리셋마다 다시 계산되는 그리드\", \"풀폭 리스트형 상품 목록도 같이 지원해줘\"\n- fallbacks: if only one density preset is needed, a single fixed card width/gap pair is fine — the pattern only matters once a second preset is introduced and its dimensions must be defined independently rather than derived by scaling.\n- relation to `category-product-grid`: that recipe owns the filter/sort/loading/empty shell around a grid; this recipe owns the density-preset card-dimension math and the internal card rhythm. Compose them — don't reimplement the shell here."},{"id":"shipping-method-selector","name":"Shipping Method Selector","pattern_group":"commerce","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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- Own measure: the list caps itself at a checkout-column width (32rem) rather than filling its container. Each row is one short fact line plus a price, so extra width only pushes the price away from the method it belongs to.\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":"chaos-settle-grid","name":"Chaos Settle Grid","pattern_group":"data-display","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"code_asset":"examples/ui-vocabulary-site/src/components/chaos-settle-grid.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/data-display/chaos-settle-grid.md","body":"## Intent\n\nA chaos settle grid holds a set of cards in independent orbits and then binds them into an aligned grid, so the arrangement reads as something that resolved rather than something that was always there. One scalar drives everything: position, rotation, scale and opacity all interpolate off the same `bind` value, which is why the two states feel like one object changing rather than four animations running side by side. A second idea rides along, a width sensitive curvature that bows the settled row and tilts its outer cards inward, so the grid does not read as a flat spreadsheet on wide screens. Reverse engineered from inspiring.nk.studio (ledger rows `r4-17`, `r4-18`, `r4-19`, `r4-20` in `research/m58-round4/p2-nk-studio.json`), read directly from the `computeCarouselLayout`, `t6`, `t8` and `t2` functions plus the `CAROUSEL_CARD_FLOAT_*` constants. The original is a three.js orbital carousel. This asset is a declared reduction that carries the same layout, curvature and damping math onto CSS 3D transforms, because the repo already owns a WebGL orbit cloud (`orbital-image-cloud-landing`) and only the settle transition and the curvature were worth taking.\n\n## Anatomy\n\n- A perspective wrapper (`perspective: 1100px`) over a stage, with a zero sized origin element carrying `transform-style: preserve-3d`. Every card is absolutely positioned at that origin and moved only by `transform`.\n- Column count is a breakpoint ladder on the stage width, not the viewport: `>=1280` six, `>=1024` five, `>=768` four, otherwise three.\n- Gap is continuous, not stepped: `32` at `<=375`, `50` at `>=1440`, and `32 + (w - 375) / 1065 * 18` in between.\n- Card aspect is fixed at `30 / 23`, so card height follows from cell width and the grid never depends on content length.\n- Curvature progress `p` is `0` at `<=768`, `1` at `>=1280`, and `t squared` in between with `t = (w - 768) / 512`. Squaring is what keeps mid widths nearly flat instead of ramping linearly.\n- From `p`: `curveAmount = 10 + 54p`, `maxTiltRad = 0.11 + 0.25p`, `rotationMul = 0.62 + 0.38p`. Below `768` these are pinned to their mobile values `10`, `0.11`, `0.62`.\n- Curvature applies as `rowY + curveAmount * clamp(x / halfWidth, -1.45, 1.45) squared`, and the same normalized offset drives the per card tilt.\n- Chaos positions come from a deterministic seed hash, `fract(sin(12.9898x + 78.233) * 43758.5453)`, so every card has a stable orbit radius, phase and depth across reloads.\n- Settled cards keep a slow float: amplitude `9px`, period `5s`, damping lambda `2.25`.\n- The bind itself is asymmetric: lambda `4.6` rising toward the grid, `3.25` falling back into chaos, with `dt` clamped at `0.05`.\n\n## States\n\n- Chaos (`bind` at 0): cards orbit on their seeded paths, pushed and pulled in depth, drawn at `0.72` scale and `0.55` opacity.\n- Settling: the single bind value moves through the range and every property crosses at once. Depth, chaotic rotation and the scale deficit all shrink as the grid tilt grows in.\n- Settled (`bind` at 1): cards sit on the curved grid at full scale and opacity, still floating at 9px.\n- Reduced motion: the float target is forced to zero, so a settled grid is genuinely still. The bind transition itself remains, since it is user triggered rather than ambient.\n- Resize: column count, gap and curvature recompute from a `ResizeObserver` on the stage, so the settled grid re-forms without a reload.\n\n## Variants\n\n- Bind as a scroll progress instead of a toggle: same math, the chaos state becomes an entrance rather than a mode.\n- Flat variant: pin `p` to zero and the grid stays rectangular. Useful for dense dashboards where the bow reads as misalignment.\n- Card count and columns: the layout tolerates a partial last row because rows are centered as a block, so counts that do not divide evenly still look intentional.\n- Depth range: the `320px` chaotic z spread is the main dial for how violent the scatter reads.\n\n## Code\n\n```tsx\n// 하나의 bind 값이 네 속성을 동시에 몬다 — 이것이 이 연출의 전부다\nbindRef.current = damp(\n bindRef.current,\n target,\n target > bindRef.current ? 4.6 : 3.25, // 붙을 때가 흩어질 때보다 빠르다\n Math.min(dt, 0.05),\n);\nconst bind = bindRef.current;\n\nconst norm = clamp(gx / halfWidth, -1.45, 1.45);\nconst curveY = layout.curveAmount * norm * norm; // 10 + 54p\nconst tilt = -norm * layout.maxTiltRad * layout.rotationMul;\n\nconst x = cx + (gx - cx) * bind;\nconst y = cy + (gy + curveY + state.float * bind - cy) * bind;\nconst z = cz * (1 - bind);\nconst scale = 0.72 + 0.28 * bind;\nel.style.opacity = String(0.55 + 0.45 * bind);\n```\n\n## Checks\n\n- Drive the breakpoint ladder from the stage width, not `window.innerWidth`. The component is used inside cards and panels, and a viewport read gives six columns to a 400px container.\n- Clamp `dt`. Without the `0.05` cap, a backgrounded tab returns with a multi second delta and every card teleports on the first frame after focus.\n- Keep the two damping lambdas different. Making them equal is the single easiest way to lose the effect: the settle stops feeling like a snap and the scatter stops feeling like a release.\n- Scale the float by `bind` before adding it. A float applied at full strength in the chaos state fights the orbit and reads as jitter.\n- Write only `transform` and `opacity` in the frame loop, and keep `will-change: transform` on the cards. Touching `left`/`top` here forces layout on every card every frame.\n- Disconnect the `ResizeObserver` and cancel the frame on unmount. Both keep running otherwise, and the loop writes to detached nodes.\n- Keep the seed hash deterministic. A `Math.random` scatter changes on every mount and the demo stops being comparable between reloads or screenshots.\n- Card size is derived from `cellW - gap` with a `48px` floor, so check the narrow end: without the floor, three columns in a small container produce negative widths.\n\n## Anti-patterns\n\n- Animating each property with its own tween or transition. The whole point is one scalar. Separate timings make the cards arrive in pieces and the grid never snaps.\n- Using CSS transitions on `transform` while a frame loop also writes `transform`. The two fight and the motion goes rubbery.\n- Applying the curvature as a wrapper `rotate` or `perspective` trick on the container. The observed curvature is per card and derived from horizontal offset, so a container transform bends the wrong thing and breaks hit testing.\n- Letting the curvature run below `768px`. The observed source pins the mobile values there because a bow on a three column narrow grid just reads as broken alignment.\n- Putting real content in the cards while chaos is the resting state. Text under continuous 3D rotation is unreadable and unselectable. Chaos should be a transient or an opt in, with the grid as the default.\n- Linear interpolation of the curvature progress. The squared ramp is what keeps mid widths quiet, and replacing it with `t` makes the bow appear far too early.\n\n## Agent notes\n\n- prompt_phrases: \"cards scatter and then snap into a grid\", \"chaotic floating tiles that settle into alignment\", \"gallery grid that assembles itself\", \"curved card row that bows on wide screens\".\n- fallbacks: with reduced motion, hold the float at zero and keep the settled grid as the default state. Without `ResizeObserver`, compute the layout once on mount and again on `resize`. If 3D transforms are unavailable, drop `z` and the rotations and interpolate only `x`, `y`, scale and opacity, which still reads as a settle.\n- The component is self contained and takes no props. Card count is a module constant (`CARD_COUNT = 12`), so changing it is an edit, not configuration."},{"id":"grid-track-magnitude-transition","name":"Grid Track Magnitude Transition","pattern_group":"data-display","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m66","surface_refs":["websites","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","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/grid-track-magnitude-transition.tsx","component_refs":[],"term_refs":["progress-bar","chart","stat-list"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/data-display/grid-track-magnitude-transition.md","body":"## Intent\n\nThe reflex for showing a number going up is a counter that ticks. This does it with layout instead: each row is a grid whose first track animates from zero to the value's width, and the row's rank is its `grid-row`. Changing the data grows the bars and reshuffles the order in the same gesture, with no measurement and no per-frame JavaScript. The slow easing observed in the origin (a long cubic-bezier that starts and ends nearly still) is what makes a 2.8 second growth feel deliberate rather than sluggish. Observed at Spotify Wrapped Party (https://wrapped-party.activetheory.dev) — ledger rows wrapped#9 in `research/m66-round8/p2-ledger.json`, disposition in `research/m66-round8/disposition.json`; user gate pass frozen in `research/2026-08-17-m66-gate-verdicts.md`.\n\n## Anatomy\n\n- **Row grid** — a value track plus a fixed track for the label or avatar.\n- **Stretch variable** — the per-row target width, applied to the value track.\n- **Rank placement** — `grid-row` carrying the row's position, so reordering is declarative.\n- **Numeric label** — the value in text, since a bar alone is not readable as a number.\n- **Density rule** — a reduced fixed-track size at small sizes, so the labels do not crowd the bars.\n\n## States\n\n- **Initial** — value track at zero width; the row shows only its fixed part.\n- **Growing** — the track transitions to the target over the full duration.\n- **Reordered** — `grid-row` changes; the row jumps to its new rank while bars continue to grow.\n- **Reduced motion** — the transition collapses so the final state is presented without the growth.\n\n## Variants\n\n- **Counter (contrast)** — a rolling odometer conveys the same number by changing digits; this pattern conveys it by changing layout. They are different answers, not variants of one.\n- **Horizontal vs vertical** — the same technique applied to a column track produces growing columns.\n- **Rank animation** — `grid-row` is not interpolatable, so reordering steps rather than glides; sliding requires a different mechanism.\n\n## Code\n\n전체 구현은 `code_asset` 이 정본이다. 핵심만 발췌한다 — 이 패턴을 특징짓는 것은 분량이 아니라 아래 규칙 한 줄이다.\n\n```tsx\n// Grid Track Magnitude Transition — 핵심 규칙\n// 구현 전문: examples/ui-vocabulary-site/src/components/grid-track-magnitude-transition.tsx\n// 관측 파라미터의 출처: research/m66-round8/p2-ledger.json (wrapped#9)\n```\n\n## Checks\n\n- Bar width and rank change in the same interaction.\n- Every row exposes its numeric value as text.\n- The transition duration and easing are declared once and shared by all rows.\n- The list is marked up as a list with accessible names, not as decorative boxes.\n\n## Anti-patterns\n\n- **Animating `grid-row` and expecting a glide.** It is not an animatable value; the rank will jump, and designing as if it will not produces a broken-looking reorder.\n- **Dropping the numeric label.** A bar without a number is a picture of a number, which is not the same thing.\n- **Driving the width from JavaScript per frame.** The point of the pattern is that the browser owns the interpolation.\n- **Reusing the long easing for short bars.** The deliberate feel comes from the duration; a 200ms version of the same curve just looks stuck.\n\n## Agent notes\n\nprompt_phrases: 숫자 대신 막대가 자라면서 순위가 바뀌는 차트 · grid 트랙 전환으로 수치 보여줘 · 순위 재배치되는 랭킹 리스트\n\nfallbacks: 순위 변경을 미끄러지게 하려면 이 패턴이 아니라 FLIP 계열 전환이 필요하다. 값이 매우 자주 갱신되는 실시간 지표라면 긴 전환 시간을 짧게 잡거나 전환 자체를 뺀다."},{"id":"interactive-data-table","name":"Interactive Data Table","pattern_group":"data-display","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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. The bulk action enters with a short slide from the selection that produced it, rather than appearing in place, so the toolbar change reads as a consequence of the tick the user just made.\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":"odometer-counter","name":"Odometer Counter","pattern_group":"data-display","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites","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","typography.font.mono"],"code_asset":"examples/ui-vocabulary-site/src/components/odometer-counter.tsx","component_refs":["stat-summary-grid"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/data-display/odometer-counter.md","body":"## Intent\n\nAn odometer counter rolls each digit of a statistic up through a column of numbers when the figure enters the viewport, so a static number reads as one that was just tallied. Two levels of stagger do the work: metrics start `0.1s` apart and the digits inside a metric `0.04s` apart, which is what makes the row read as a mechanism rather than three unrelated animations. Observed on Revelatio Studio's stat row (research/m55-round2/p2-revelatio.json rows[6]). This demo was repaired during M56 (roller width fixed to `1ch`, surrounding chrome removed) and passed re-judgment in the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 1.\n\n## Anatomy\n\n- Each digit is a fixed-height window (`ROW_HEIGHT`) with `overflow: hidden` over a vertical strip of numerals; the animation is a `y` translation of the strip.\n- The strip contains `DIGIT_CYCLES = 2` full runs of 0-9 before the target digit, so every digit visibly spins rather than jumping to its value.\n- Timing comes from the observation: `duration 1`, `ease power3.out`, `elementStagger 0.1`, `digitStagger 0.04`.\n- The trigger is an `IntersectionObserver` at `threshold 0.35` with `rootMargin \"0px 0px -12% 0px\"`, so the roll starts when the row is committed to the viewport rather than at its first pixel.\n- Each digit column is exactly `1ch` wide with tabular figures, which is the repair that made the row stop shifting while it counted.\n- The observer fires once. A stat that re-rolls every time it scrolls past reads as broken.\n\n## States\n\n- Before entry: strips parked at their first cycle, showing zeros or the leading run.\n- Rolling: strips translate with the two staggers overlapping.\n- Settled: each window shows its target digit and nothing further animates.\n- Reduced motion: the final value is set directly, with no roll.\n\n## Variants\n\n- Cycle count: more cycles read as a longer tally, fewer as a quick correction. Two is the observed value.\n- Direction: rolling up (observed) reads as accumulation; rolling down suits countdowns and reads quite differently.\n- Formatting: thousands separators and suffixes belong outside the rolling columns, so they do not participate in the stagger.\n\n## Code\n\n```tsx\ngsap.to(strip, {\n y: -targetIndex * ROW_HEIGHT,\n duration: DURATION,\n ease: EASE,\n delay: metricIndex * ELEMENT_STAGGER + digitIndex * DIGIT_STAGGER,\n});\n```\n\n## Checks\n\n- Fix the digit column width in `ch` with tabular numerals. Proportional digits change width as they roll and the whole row jitters, which was the exact defect repaired here.\n- Disconnect the observer after the first fire.\n- Expose the final value as text for assistive technology rather than leaving only the animated strip.\n- Total time is `elementStagger * metrics + digitStagger * digits + duration`. With many metrics the last one finishes noticeably late, so cap the row or shorten the element stagger.\n- Kill tweens on unmount so a fast route change does not leave GSAP writing to detached nodes.\n\n## Anti-patterns\n\n- Animating the number with a text-content tween instead of a strip: the intermediate values are meaningless and screen readers announce nonsense.\n- Rolling every digit to the same target time: the digit stagger is what makes the number feel mechanical instead of uniform.\n- Using it on figures that change during the session: the roll implies a first tally, and repeating it on every update is noise.\n- Rolling long numbers in full: past four or five digits it stops reading as an odometer and starts reading as a delay.\n\n## Agent notes\n\n- prompt_phrases: \"stat numbers that roll up like an odometer\", \"counting animation for a metrics row\", \"slot machine digits for statistics\".\n- fallbacks: with reduced motion, set the final values immediately. Without `IntersectionObserver`, run once on mount rather than never running."},{"id":"permission-matrix-editor-grid","name":"Permission Matrix Editor Grid","pattern_group":"data-display","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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. In a grid this dense the press response matters more than on a single control: without it a mis-aimed click in the wrong column is indistinguishable from a hit.\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"],"mobile":{"platforms":{"ios":"refresh control","android":"swipe refresh"},"gestures":["pull-down","scroll"],"touch_targets":["none"],"safe_area":"top","reduced_motion":"인디케이터 회전 정지·오프셋 복귀 즉시 — 상태 기계(pulling→threshold→refreshing→settled)는 그대로","modal_depth":0,"port_notes":"**네이티브에서는 이 상태 기계를 플랫폼이 소유한다** — `RefreshControl`(RN)·`RefreshIndicator`(Flutter). 직접 구현하면 스크롤 소유권이 충돌한다. 이 자산이 넘기는 것은 구현이 아니라 임계 판정·취소 가능 드래그·인디케이터 고정이라는 요구 세 가지다"},"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":"remote-data-screen","name":"Remote Data Screen","pattern_group":"data-display","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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","color.semantic.action.destructive","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.scale.lg","typography.weight.medium"],"mobile":{"platforms":{"ios":"navigation stack screen with status states","android":"screen with app bar and status states"},"gestures":["scroll","tap"],"touch_targets":["retry action","empty-state action"],"safe_area":"none","reduced_motion":"스켈레톤 펄스 정지, 상태 전환은 트윈 없이 즉시","modal_depth":0,"port_notes":"`fetch`+`AbortController` 타임아웃은 네이티브 HTTP 클라이언트의 타임아웃 설정으로 바뀐다. 다섯 상태가 상호배타라는 계약은 스택과 무관하게 유지된다"},"code_asset":"examples/ui-vocabulary-site/src/components/remote-data-screen.tsx","component_refs":["button","recoverable-empty-state"],"term_refs":["skeleton","error-state","empty-state"],"source_refs":[],"last_verified":"2026-08-10","source_path":"recipes/data-display/remote-data-screen.md","body":"## Intent\n\nA remote data screen is the state contract for any view that fetches data over the network and renders it: one screen, five mutually exclusive states — loading, success, empty, error, and timeout — each visually distinct and each with its own next action. The contract exists because the three most common field failures are conflations: showing nothing while loading, showing an error when the result is validly empty, and hanging forever because `fetch` has no default timeout.\n\n## Anatomy\n\n- A status region (`role=\"status\"`, `aria-live=\"polite\"`) that announces state transitions to non-visual channels.\n- Loading: structure-preview skeleton matching the final layout, not a generic spinner.\n- Success: the data list/grid, rendered from an immutable cached original — display transforms (units, formatting) derive at render time and never write back into the cache.\n- Empty: a cause-specific empty state (\"no results\" is a valid answer, not a failure) — anatomy owned by `recoverable-empty-state`.\n- Error: the failure is named (network vs server vs timeout) with a Retry action; retry re-enters loading, never a dead end.\n- Cache layer: keyed by normalized request meaning; a cache hit skips loading entirely.\n\n## States\n\n- Loading: request in flight, skeleton visible, controls that would re-trigger the request are disabled.\n- Success: data rendered; unit/format toggles re-derive from the cached original (round-trip safe).\n- Empty: request succeeded with zero items — distinct copy and action from error.\n- Error: request failed and the cause is named; Retry visible.\n- Timeout: `AbortController` + timer fired — named as \"took too long\", distinct from server failure, Retry visible.\n\n## Variants\n\n- List/table body (rows skeleton) vs card grid (cell skeleton).\n- Foreground screen (full state surface) vs widget embed (compact inline states).\n- With unit/format toggle demonstrating derive-on-read from the immutable cache.\n\n## Code\n\n```tsx\n// Timeout is opt-in — fetch never times out by itself.\nasync function fetchWithTimeout(url: string, ms: number, init?: RequestInit) {\n const ctrl = new AbortController()\n const timer = setTimeout(() => ctrl.abort(), ms)\n try {\n return await fetch(url, { ...init, signal: ctrl.signal })\n } finally {\n clearTimeout(timer)\n }\n}\n\n// State machine: exactly one of five states at any moment.\ntype ScreenState =\n | { kind: \"loading\" }\n | { kind: \"success\"; items: readonly Item[] } // frozen original — display derives at render\n | { kind: \"empty\" }\n | { kind: \"error\"; cause: \"network\" | \"server\" | \"timeout\" }\n```\n\n## Checks\n\n- Every network call has an explicit timeout path (`AbortController`); `AbortError` maps to the timeout state, not the generic error state.\n- All request URLs are `https:` — an `http:` API call works on the dev machine and dies on the deployed HTTPS origin (mixed content), and good error UI then mislabels the cause as a server failure.\n- Zero items renders the empty state, never the error state — and a failed request never renders as \"no results\".\n- The cached response object is never mutated in place: unit conversions and formatting derive from a frozen original at render (`readonly`/`Object.freeze` where available).\n- State-driven styling toggles classes (`classList` / conditional `className`), never overwrites the element's whole class string.\n- Loading always terminates: success, empty, error, or timeout — the `finally` branch releases the loading surface even on thrown paths.\n- The status region announces transitions (`aria-live`) so state changes exist off the visual channel.\n- Skeleton pulse animation is gated behind `motion-safe`.\n\n## Anti-patterns\n\n- **Loading forever**: no timeout wiring, so a stalled request leaves the skeleton up with no exit — `fetch` will happily wait for minutes.\n- **Absence dressed as failure**: an empty result routed into the error surface (\"something went wrong\") when nothing went wrong — the user's next action (refine query) is hidden behind a useless Retry.\n- **Cache poisoned by display**: writing converted units back into the cached response — repeated round-trips accumulate rounding drift and later reads see display-format strings instead of numbers.\n- **`el.className = \"loaded\"`**: overwriting the class string to set one state class, silently stripping every other class on the element.\n\n## Agent notes\n\n- prompt_phrases: \"five-state remote data screen with timeout and cause-named errors\", \"skeleton loading with AbortController timeout and recoverable empty state\"\n- fallbacks: for a widget-scale embed, keep the five states but compress each surface to a single row.\n- canonical guidance: `docs/design-system/decisions/waiting-and-absence.md` (absence vs progress vs failure — both directions), `knowledge/input-modality.md` §4 (state must reach non-visual channels)."},{"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":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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"],"mobile":{"platforms":{"ios":"list row swipe actions","android":"swipe-to-dismiss row"},"gestures":["swipe-horizontal","tap"],"touch_targets":["leading action button","trailing action button","row content"],"safe_area":"none","reduced_motion":"행 오프셋 스프링 없이 액션 노출 상태로 즉시 전환","modal_depth":0,"port_notes":"드래그 계산이 전부 교체된다 — RN Gesture Handler `Pan`, Flutter `Dismissible`/`GestureDetector`. **닫힌 상태의 탭 전용 아이콘 버튼은 반드시 함께 옮긴다** — 제스처를 못 쓰는 사용자의 유일한 경로다"},"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: pinned to the left edge **underneath** the row content and always mounted — non-destructive tone (accent), fixed width. What changes on reveal is the content sliding off it, not the action appearing.\n- Trailing action slot: same, pinned to the right edge underneath the content — 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- **Mounting the action on reveal**: rendering the leading/trailing button only while revealed makes it blink into place, which reads as a different row appearing rather than this row being pulled aside. Keep both actions mounted underneath and move the content.\n- **Removing a dismissed row on click**: the rows below jump up with nothing explaining the gap. Collapse the row first, then drop it.\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":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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- Copy affordance: both code surfaces carry a copy control, rendered as a bare icon whose color derives from that surface's own text at reduced alpha, so it reads without hover on the inverted request panel and the muted response panel alike.\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- **Code sample with no way to copy it**: an API reference exists to be pasted into a caller's code, so a sample the reader has to hand-select is unfinished.\n- **Pill-shaped method/requiredness badges**: `rounded-full` is a stock default rather than a chosen corner; Askewly's badges follow the system's own radius (`DESIGN.md` §7-8).\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":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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. The chips carry the system's own corner radius, not a fully rounded pill.\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- **Pill-shaped chips**: `rounded-full` is a stock default rather than a chosen corner; the chip row follows the system radius (`DESIGN.md` §7-8) so a changelog page does not read as a different product from the rest of the docs.\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":"stable","verified_by":"사용자","verified_at":"2026-08-13","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 — **hover 없이도 읽히는 대비를 갖는다.** 코드면은 반전 배경이므로 페이지 foreground 를 그대로 쓰면 어두운 데 어두운 색이 얹혀 보이지 않는다. 코드면의 텍스트 색을 알파로 낮춰 쓴다.\n- Hover/focus: 배경을 깔지 않고 **불투명도만 올린다.** 위치·크기는 변하지 않는다.\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- **Hover 로만 드러나는 복사 버튼**: 존재를 모르면 hover 할 이유가 없다. 커서가 없는 입력(키보드·터치)에서는 아예 발견되지 않는다.\n- **코드면 위에 페이지 기본 전경색을 그대로 쓴 아이콘**: 반전 배경에서 대비가 무너진다 — 아이콘 색은 놓이는 면의 텍스트 색에서 파생시킨다.\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":"audio-sprite-sfx","name":"Audio Sprite SFX","pattern_group":"feedback","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"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/audio-sprite-sfx.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/feedback/audio-sprite-sfx.md","body":"## Intent\n\nAn audio sprite packs multiple short effects into one buffer addressed by timecode cues: one decoded asset, per-cue segment playback with `source.start(0, cueStart, cueDuration)`, and a dB-to-linear volume curve. This is the game-audio pattern for UI SFX without per-file decode latency. Observed at the Lacoste Ace Breaker game (research/m52-round1/p2-lacoste-ace.json row 7, including the dB conversion and JSON timecode map); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- A cue table: id, start seconds, duration (the observed original ships this as JSON next to an m4a sprite).\n- One AudioBuffer holding every effect (synthesized via OfflineAudioContext in the demo; a decoded file in production).\n- Per play: a fresh AudioBufferSourceNode into a gain node whose value comes from a dB slider through an exponential conversion.\n- The previous source is stopped before a new cue starts (monophonic UI channel).\n\n## States\n\n- Building: sprite not yet rendered/decoded; triggers disabled.\n- Ready: cues playable; the active cue id is tracked for UI feedback.\n- Suspended: context suspended by the browser; `ctx.resume()` runs inside the gesture handler.\n- Note: source nodes are one-shot; a new node per play is the contract, not a leak.\n\n## Variants\n\n- File-based sprite: `decodeAudioData` one m4a/webm plus a JSON cue map.\n- Polyphonic channel: skip the previous-source stop for layered effects.\n\n## Code\n\n```tsx\nfunction dbToLinear(db: number): number {\n return Math.exp((db + 60) * (Math.log(1) - Math.log(0.001)) / 60) * 0.001\n}\nconst source = ctx.createBufferSource()\nsource.buffer = bufferRef.current\ngainNode.gain.value = dbToLinear(volumeDb)\nsource.connect(gainNode)\ngainNode.connect(ctx.destination)\nsource.start(0, cue.start, cue.duration)\n```\n\n## Checks\n\n- All cues play from one buffer; no per-cue network fetch or decode.\n- Volume control is dB-shaped (exponential), not a linear 0..1 slider.\n- Context resumed inside the user gesture when suspended.\n- Sources and context cleaned up on unmount.\n\n## Anti-patterns\n\n- One audio file per effect: decode and scheduling latency makes fast UI sounds feel detached.\n- Linear volume sliders: half the slider range becomes inaudibly loud or dead.\n- Reusing a single AudioBufferSourceNode: they are one-shot by spec; it throws on the second start.\n\n## Agent notes\n\n- prompt_phrases: \"audio sprite for UI sounds\", \"timecode cue playback\", \"dB volume slider\".\n- fallbacks: gate all SFX behind a user-controlled sound toggle; UI must be complete without them."},{"id":"drag-quote-anchored-comments","name":"Drag-Quote Anchored Comments","pattern_group":"feedback","kind":"form-pattern","status":"draft","surface_refs":["websites","documentation"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted"],"code_asset":"pending","component_refs":[],"term_refs":[],"source_refs":["https://github.com/Yoodeit/MBN_Hackathon"],"last_verified":"2026-08-09","source_path":"recipes/feedback/drag-quote-anchored-comments.md","body":"## Intent\n\nComments that point at nothing drift into a second, disconnected conversation. Drag-quote anchoring keeps them on the text: selecting a passage in the article body attaches that excerpt to the comment form as a visible quote anchor, so a comment is *about* something the next reader can see. Paired with hash-derived avatar colors so anonymous nicknames stay visually distinguishable without accounts. Harvested from MBN_Hackathon (K-RABBIT) comment layer.\n\n## Anatomy\n\n- **Selection capture**: a `selectionchange` listener checks that the selection is non-collapsed and its anchor node sits inside the designated body container (`[data-article-body]` or equivalent) — selections in the UI chrome never become quotes.\n- **Anchor normalization**: collapse whitespace, trim, cap length (~120 chars) — the anchor is a pointer, not a reprint.\n- **Anchor chip on the form**: the captured excerpt renders above the input as a removable quote chip; submitting stores it with the comment, and the comment list renders it as a quoted lead-in.\n- **Hash avatar color**: derive a palette index from the nickname (`sum of charCodes % palette.length`) over a fixed set of token-derived tones — same nickname, same color, no persistence needed.\n- After submit, switch the list to \"newest\" sort so the author immediately sees their own comment.\n\n## States\n\n- No selection: form is a plain comment box, no chip.\n- Active anchor: quote chip visible with a clear remove affordance; submitting binds it.\n- Anchored comment in list: excerpt renders visually distinct (quote styling), never as plain body text.\n- Selection outside the article body: ignored — no chip flicker while selecting UI text.\n\n## Variants\n\n- **인용 앵커 있음** — 선택한 문장이 칩으로 폼 위에 붙고 제출 시 코멘트와 함께 저장된다.\n- **앵커 없는 일반 코멘트** — 선택 없이 바로 쓰는 경로. 앵커는 향상이지 전제가 아니다.\n- **읽기 전용 표시** — 목록에서 앵커가 인용 스타일로 렌더된다. 본문과 시각적으로 구분되지 않으면 앵커가 아니다.\n\n## Code\n\n```tsx\n// 선택 캡처 — 본문 컨테이너 안의 비축약 선택만 앵커가 된다.\nuseEffect(() => {\n const onChange = () => {\n const sel = document.getSelection()\n if (!sel || sel.isCollapsed) return\n const host = (sel.anchorNode as Element | null)?.parentElement?.closest(\"[data-article-body]\")\n if (!host) return // UI 크롬에서의 선택은 무시\n const text = sel.toString().replace(/\\s+/g, \" \").trim().slice(0, 120)\n if (text) setAnchor(text)\n }\n document.addEventListener(\"selectionchange\", onChange)\n return () => document.removeEventListener(\"selectionchange\", onChange)\n}, [])\n\n// 닉네임 해시 → 고정 팔레트 인덱스. 같은 닉네임은 항상 같은 색.\nconst toneIndex = (nickname: string) =>\n [...nickname].reduce((sum, c) => sum + c.charCodeAt(0), 0) % AVATAR_TONES.length\n```\n\n## Checks\n\n- Selection inside inputs/textareas or navigation never produces an anchor (container check is the gate).\n- The anchor survives whitespace-heavy selections (normalization) and very long drags (cap).\n- Avatar color contrast: every palette tone keeps its overlaid initial/label readable in both themes — derive tones from tokens, not random HSL.\n- Keyboard-only users can still comment: anchoring is an enhancement over selection, not a requirement for commenting.\n\n## Anti-patterns\n\n- **Anchoring by character offsets** into mutable article HTML — offsets rot on the first edit; store the excerpt text itself.\n- **Random per-session avatar colors** — the same nickname changing color across a thread destroys the only identity signal anonymous comments have.\n- **Auto-submitting the selection as a comment** — the selection fills the anchor, the human writes the comment.\n- **Unremovable anchor chip** — a misfired drag must be dismissible without clearing the typed comment.\n\n## Agent notes\n\n- 앵커는 **포인터이지 재수록이 아니다** — 길이 상한(약 120자)이 계약의 일부다. 상한을 늘리면 코멘트가 인용문에 잡아먹힌다.\n- 아바타 색은 반드시 토큰 유래 고정 팔레트에서 뽑는다. 랜덤 HSL 은 대비 검증이 불가능하다.\n- `status: draft` 이고 `code_asset` 이 아직 없다 — 위 Code 는 문서용 발췌이며 실동작 자산이 아니다."},{"id":"fake-progress-preloader","name":"Fake Progress Preloader","pattern_group":"feedback","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"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/fake-progress-preloader.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/feedback/fake-progress-preloader.md","body":"## Intent\n\nA fake-progress preloader shows a counter that climbs in small fixed ticks (5ms) toward the real completion rate of the loading work, clamped so it can never pass it. Perceived progress stays smooth while staying honest: when real loading lags, the counter stalls at the clamp and waits. At 100% the overlay holds briefly, then wipes upward to reveal content. Observed at normalisboring.es (research/m52-round1/p2-normalisboring.json row 4, preloader.js:36-85); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- A content layer underneath, always rendered.\n- A full-bleed overlay (same background token as the page) holding a large tabular-numbers percentage and a thin progress bar.\n- Two refs track real vs displayed percent; a 5ms interval advances the display by 1, clamped to the real rate.\n- On 100%: a hold delay (observed 1500ms), then a translateY(-100%) wipe transition on the overlay.\n\n## States\n\n- Loading: counter climbing, bar width follows the displayed percent.\n- Stalled: real rate behind the counter, display waits at the clamp (this is the honesty contract).\n- Holding: 100% reached, overlay still covering during the reveal delay.\n- Revealed: overlay wiped up, `aria-hidden` set on it so the counter leaves the accessibility tree.\n\n## Variants\n\n- Real asset wiring: replace the simulated loaders with image/font/fetch promises; the clamp logic is unchanged.\n- Bar-less: percentage only, for typographic preloaders.\n- Downward or split-curtain wipes: swap the reveal transform.\n- Input-locked run with curtain-up reveal (revelatio.studio, research/m56-round3/revelatio-deep.json): while an `is-preloading` class is on, preventDefault wheel/touchmove and the scroll keys (space, arrows, PageUp/Down, Home/End); the background stage sits at `brightness(0.14) blur(1.4px) opacity 0.62` and recovers via a 0.7s cubic-bezier(0.4,0,0.2,1) filter transition when the class flips to leaving. Exit text scrambles out asymmetrically: right block forward index order, left block reversed, 0.018s per-char delay.\n- Dual-gate release (bleach-mh loading screen, research/m56-round3/bleach-mh-deep.json): the loader exits only when BOTH the real load signal and a minimum-playtime signal have fired, so fast loads still show one full sprite cycle and slow loads never cut the animation short.\n\n## Code\n\n```tsx\nconst tickId = setInterval(() => {\n const next = Math.min(fakePctRef.current + 1, realPctRef.current)\n fakePctRef.current = next\n setDisplayPct(next)\n if (next >= 100) {\n clearInterval(tickId)\n setTimeout(() => setRevealed(true), REVEAL_DELAY_MS)\n }\n}, FAKE_TICK_MS)\n```\n\n## Checks\n\n- Counter never exceeds the real completion rate at any sample point.\n- Interval and timers are cleared on unmount and on replay (no double tickers).\n- Overlay uses surface tokens, not hardcoded colors; percentage uses tabular numbers.\n- Reveal transition respects the hold delay; content is interactive only after the wipe.\n\n## Anti-patterns\n\n- Animating a purely time-based fake percent that reaches 100% before assets exist: the reveal then shows unstyled or missing content.\n- Driving the bar with CSS animation independent of state: the bar and the number drift apart.\n- Removing the overlay from the DOM at 100% instantly: the reward moment (hold, then wipe) is the observed signature; skipping it reads as a flicker.\n\n## Agent notes\n\n- prompt_phrases: \"preloader with percentage counter\", \"loading screen that never overshoots\", \"counter stalls until assets ready\".\n- fallbacks: if there is no real loading work to track, prefer no preloader at all over a purely fake one; a preloader without work is dead time."},{"id":"multi-stage-entry-loader","name":"Multi Stage Entry Loader","pattern_group":"feedback","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/multi-stage-entry-loader.tsx","component_refs":["fake-progress-preloader"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/feedback/multi-stage-entry-loader.md","body":"## Intent\n\nA multi stage entry loader is a loading screen with three simultaneous channels and a choreographed exit. Images swap on an interval that accelerates and decelerates along a sine curve, a percent counter runs down on a slow ease, and a progress bar widens on a different curve. The exit is not one fade: four elements leave on overlapping delays, so the loader dissolves in a direction rather than blinking off. The reason to build it this way is that a single spinner tells the visitor only that time is passing, while this tells them the site has a tempo. Observed on noth.in (research/m56-round3/noth-in-disposition.json rows p2-noth-in.json rows[0] and rows[1], deepened in noth-in-deep.json d3[5], d3[6], d3[7]). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- The carousel interval is a function of elapsed time, not a constant: `interval(r) = 100 + (500 - 100) * (1 - sin(r * PI / EF))`, so swaps start slow at 500ms, accelerate to 100ms in the middle, and slow back down.\n- The first tile enters separately: `scale 0 to 1` with `rotate 0 to 15deg` over `0.8s` on `back.out(0.9)` after a `0.5s` delay.\n- Every later swap crossfades with `scale .8 to 1` and `opacity 0 to 1` over `0.32s` on `back.out(1.2)`. The previous tween is killed and the outgoing tile snapped to opacity 0, so overlapping swaps cannot stack.\n- The counter runs `100 to 0` over `EF / 750` seconds on `power2.inOut`, deliberately slower than the image tempo so the two channels never sync up.\n- The exit is four tweens with staggered delays measured from one exit moment: image `scale to 0` (`0.6s`, delay `0.5s`), bar `width to 0` (`0.8s`, delay `0.6s`), counter `opacity to 0` (`0.5s`), container `height to 0` (`1.8s`, `power4.inOut`).\n- GSAP named eases are reproduced as cubic-bezier approximations so the loader has no animation dependency: `back.out(1.2)` as `cubic-bezier(0.34, 1.76, 0.64, 1)`, `power4.inOut` as `cubic-bezier(0.77, 0, 0.175, 1)`.\n- A `sessionStorage` key records that the loader played, so a returning visitor within the session skips it. The demo exposes that as a toggle.\n\n## States\n\n- `playing`: carousel swapping, counter descending, bar widening.\n- `exiting`: the four exit tweens overlap; nothing new starts.\n- `done`: the container has zero height and no longer participates in layout.\n- Skipped (session repeat or reduced motion): the loader resolves immediately with duration 0.\n\n## Variants\n\n- Duration: the observed `EF = 5000ms` is scaled to 55 percent in the demo. Scale the total, keep the ratios, and the acceleration curve is preserved exactly.\n- Content: image tiles here, but the carousel slot accepts any repeated visual. The sine-timed swap is the pattern, the imagery is not.\n- Counter direction: counting down (observed) reads as depletion, counting up reads as progress. They are not interchangeable in tone.\n\n## Code\n\n```tsx\n// 이미지 전환 간격이 사인 곡선을 따라 가속·감속한다\nconst intervalFor = (elapsed: number) =>\n INTERVAL_MIN + (INTERVAL_MAX - INTERVAL_MIN) * (1 - Math.sin((elapsed * Math.PI) / EF));\n```\n\n## Checks\n\n- Kill the outgoing tile's tween and snap it to opacity 0 before starting the next crossfade, or a fast stretch of the curve leaves two tiles visible.\n- The exit delays are relative to one exit moment, not chained onto each other. Chaining them turns a `1.8s` dissolve into a much longer sequence.\n- Animate the container's height to zero at the end, not just its opacity: an invisible full-height loader keeps swallowing pointer events.\n- A loader that outlasts the actual load is a lie the visitor can feel. Tie the exit to the real ready signal, with the observed minimum only as a floor.\n- The session skip must be checked before the first frame, or returning visitors see one flash of the loader.\n\n## Anti-patterns\n\n- A linear swap interval: the whole character of this loader is the acceleration. A constant interval reads as a slideshow.\n- Fading all four elements out together: the exit is what makes it feel authored, and one shared fade throws that away.\n- Blocking the page on a fixed timer regardless of readiness: use it as a minimum hold, never as the whole duration.\n- Replaying the loader on every client-side route change: it is an entry gesture, not a page transition.\n\n## Agent notes\n\n- prompt_phrases: \"site entry loader with image carousel and percent counter\", \"loading screen that accelerates then exits in stages\", \"first-visit-only intro loader\".\n- fallbacks: with reduced motion, skip straight to the exit state so nothing animates while the site still becomes usable. If the load finishes before the minimum hold, still play the exit choreography rather than cutting it."},{"id":"radial-dial-scroll","name":"Radial Dial Scroll","pattern_group":"feedback","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites","documentation"],"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/radial-dial-scroll.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/feedback/radial-dial-scroll.md","body":"## Intent\n\nA radial dial scroll indicator replaces the scrollbar with a ring of ticks that fills clockwise as the reader moves through a long text. The reason it is worth the pixels is placement: a scrollbar sits at the far edge of the window, while this gauge sits near what the reader is already looking at, so progress is available without moving their eyes. The ring is built from 130 individual ticks rather than an arc, which gives the fill a mechanical, instrument-like quality. Observed on Produx Design (research/m55-round2/p2-produx.json rows[12], 130 ticks spanning `0.35deg` to `358.16deg` with `transform-origin: center bottom`). This demo was repaired during M56 by reframing the abstract ring as a reading gauge over real text, and passed re-judgment in the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 1.\n\n## Anatomy\n\n- Tick angles are computed once as an even spread from `ANGLE_START` to `ANGLE_END` across `TICK_COUNT = 130`.\n- Each tick is a thin element rotated around the ring's center with `transform-origin: center bottom`, so one absolute position plus a rotation places all of them.\n- Scroll progress is the container's `scrollTop / (scrollHeight - clientHeight)`, clamped, and the number of lit ticks is that ratio times the tick count.\n- Lit and unlit are a state per tick, not a mask over an arc. That is what preserves the discrete instrument reading.\n- The gauge is pinned in the corner of the reading surface, sized to be legible at a glance without competing with the text.\n- A numeric percentage sits inside the ring, because a gauge that cannot be read precisely is decoration.\n\n## States\n\n- 0 percent: all ticks unlit, gauge still visible so the reader knows it exists.\n- Mid-scroll: the lit arc grows clockwise, tick by tick.\n- 100 percent: the full ring is lit.\n- Reduced motion: identical states, since the fill is positional rather than animated. No transition is required.\n\n## Variants\n\n- Tick count: fewer ticks read as coarser instrumentation, more approach a solid arc and lose the character.\n- Placement: corner overlay (this demo) or inline at a section boundary.\n- Secondary marks: heavier ticks at quarter positions add readability at a glance, at the cost of some of the uniformity.\n\n## Code\n\n```tsx\nconst TICK_ANGLES = Array.from(\n { length: TICK_COUNT },\n (_, i) => ANGLE_START + (i * (ANGLE_END - ANGLE_START)) / (TICK_COUNT - 1),\n);\n```\n\n## Checks\n\n- Compute the angles once outside render. Rebuilding 130 values per scroll frame is pure waste.\n- Update the lit count in an rAF, not directly in the scroll handler.\n- Guard against a container that does not overflow, or the ratio divides by zero.\n- Keep the gauge out of the text's measure. A progress indicator that shifts the reading column is worse than no indicator.\n- Include the percentage in an accessible form (`role=\"progressbar\"` with the current value), since the ticks alone convey nothing to a screen reader.\n\n## Anti-patterns\n\n- Animating each tick's opacity with a transition: 130 simultaneous transitions on a scroll frame is expensive and adds nothing, because the fill is already continuous.\n- Rendering the ring as an SVG arc with a dash offset: it works, but it is a different pattern with a different reading. If you want the instrument, keep the ticks.\n- Placing the gauge where it overlaps the text at any breakpoint.\n- Using it on short pages: a gauge that jumps from empty to full in one flick reads as a bug.\n\n## Agent notes\n\n- prompt_phrases: \"circular reading progress gauge with tick marks\", \"radial scroll indicator instead of a scrollbar\", \"dial that fills as you scroll a long article\".\n- fallbacks: no motion path is needed since the fill is positional. On narrow screens, either move the gauge inline above the text or drop it rather than letting it cover content."},{"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"],"mobile":{"platforms":{"ios":"none","android":"none"},"gestures":["tap"],"touch_targets":["primary recovery action","secondary action"],"safe_area":"none","reduced_motion":"none","modal_depth":0,"port_notes":"원인별 분기(첫 사용·검색 0건·필터 0건·권한·실패)는 스택과 무관하게 유지된다 — 옮기면서 하나의 일반 일러스트로 합치는 것이 이 자산의 실패 모드다"},"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":"reward-reveal-loader","name":"Reward Reveal Loader","pattern_group":"feedback","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"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/reward-reveal-loader.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/feedback/reward-reveal-loader.md","body":"## Intent\n\nA reward-reveal loader shows no spinner, bar, or percentage during the wait. The page holds a quiet ambient background, and when everything is ready it discloses wordmark and content in one staged burst (expo.out, 0.04s stagger). The pattern trades continuous progress feedback for a single rewarding entrance. Observed at partizan.com (research/m52-round1/p2-partizan.json rows 20 and 3); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- An ambient background layer that breathes slowly (long keyframe loop on background-position) so the wait does not look frozen.\n- Hidden wordmark and content items, pre-set with `autoAlpha: 0` plus small y/scale offsets.\n- A readiness signal (timer in the demo; real loading promises in production) that triggers one GSAP timeline: wordmark first (0.9s expo.out), then items with 0.04s stagger, overlapped by -0.5s.\n\n## States\n\n- Waiting: only the ambient background moves; no progress affordance by design.\n- Revealing: timeline running, wordmark then items.\n- Revealed: everything at rest; replay resets the pre-states with `gsap.set`.\n\n## Variants\n\n- Real readiness wiring: `Promise.all` of fonts/images/data in place of the timer.\n- Shader background: the observed original pairs this with a WebGL gradient; `cursor-trail-shader` combines.\n- Longer stagger for editorial lists; keep the single-burst grammar.\n\n## Code\n\n```tsx\ntimerRef.current = setTimeout(() => {\n const tl = gsap.timeline()\n tl.to(wordmarkRef.current, { autoAlpha: 1, y: 0, duration: 0.9, ease: \"expo.out\" })\n .to(itemRefs.current.filter(Boolean),\n { autoAlpha: 1, y: 0, scale: 1, duration: 1.1, ease: \"expo.out\", stagger: 0.04 },\n \"-=0.5\")\n}, WAIT_MS)\n```\n\n## Checks\n\n- Pre-states are set with `gsap.set` before the timeline runs (no first-frame flash).\n- Timeline and timer are killed on unmount and replay.\n- The wait background moves subtly; a fully static wait reads as a hang.\n- Items use surface/border tokens; no hardcoded colors.\n\n## Anti-patterns\n\n- Adding a small spinner \"just in case\": the point of the pattern is the absence of progress UI; mixing both loses the reward beat.\n- Using long waits (over roughly 3s) without any feedback: this pattern fits short, predictable readiness windows.\n- Staggering from a random order on every run: the observed grammar is a stable, composed entrance.\n\n## Agent notes\n\n- prompt_phrases: \"no-spinner loader\", \"reveal everything at once when ready\", \"gated intro with stagger\".\n- fallbacks: when readiness time is unbounded or network-dependent, use `fake-progress-preloader` instead; unbounded blank waits are hostile."},{"id":"skeleton-card-loading","name":"Skeleton Card Loading","pattern_group":"feedback","kind":"component","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.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.weight.medium"],"mobile":{"platforms":{"ios":"none","android":"none"},"gestures":["none"],"touch_targets":["none"],"safe_area":"none","reduced_motion":"펄스 shimmer 없이 정적 muted 블록 — 골격·개수·비율은 그대로","modal_depth":0,"port_notes":"shimmer 는 RN Reanimated 루프·Flutter shimmer 위젯으로 바뀐다. `motion-safe` 클래스에 대응물이 없으니 감소 설정 분기를 코드로 명시해야 한다"},"code_asset":"examples/ui-vocabulary-site/src/components/skeleton-card-loading.tsx","component_refs":["button","remote-data-screen"],"term_refs":["skeleton","error-state"],"source_refs":[],"last_verified":"2026-08-10","source_path":"recipes/feedback/skeleton-card-loading.md","body":"## Intent\n\nSkeleton card loading is the shape contract for the loading state itself: placeholder cards that preview the true structure of what will arrive — same layout, same count, same proportions — so the wait reads as \"your content is forming\" rather than \"something is spinning\". This recipe owns the skeleton's form rules and its transitions; where a skeleton sits inside the five-state network contract is owned by `remote-data-screen`, and grid-context usage is referenced by `category-product-grid`.\n\n## Anatomy\n\n- Skeleton cell: mirrors the final card's regions (media slot, title line, meta line) with muted blocks — never a generic gray rectangle of a different shape.\n- Cell count: matches the expected result count (or the page size), so the layout does not reflow on arrival.\n- Pulse: a subtle opacity shimmer, `motion-safe` only.\n- Exit paths: skeleton → content (success), skeleton → error surface (failure) — a skeleton is a promise, and both ways of keeping it must exist.\n\n## States\n\n- Deferred: loads that finish under ~200ms never show a skeleton — a flash of placeholder is worse than nothing.\n- Loading: skeleton visible, hidden from assistive tech (`aria-hidden`), with a parallel `aria-live` status announcing the load.\n- Resolved: content replaces the skeleton in the same layout slots — no reflow jump.\n- Failed: the skeleton yields to a named error surface with Retry; it never pulses forever.\n\n## Variants\n\n- Card grid (media + text) vs list rows (avatar + two lines).\n- Standalone shimmer block for a single late widget.\n\n## Code\n\n```tsx\n// Deferral: a skeleton that appears and vanishes within 200ms is noise.\n// Show it only once the wait is real.\nconst [showSkeleton, setShowSkeleton] = useState(false)\nuseEffect(() => {\n if (state !== \"loading\") return\n const t = setTimeout(() => setShowSkeleton(true), 200)\n return () => clearTimeout(t)\n}, [state])\n```\n\n## Checks\n\n- The skeleton's layout mirrors the final content's layout — regions, proportions, and count — so arrival swaps content into place without reflow.\n- Loads faster than ~200ms skip the skeleton entirely (deferred appearance).\n- The pulse animation is `motion-safe` gated; with reduced motion the skeleton is static.\n- Skeleton markup is `aria-hidden`; the loading fact is announced through an `aria-live` status region instead.\n- Failure exits the skeleton into a visible error surface — the skeleton itself is never the terminal state.\n- Skeleton blocks use muted surface tokens; media slots stay empty shapes (no gradient placeholder art).\n\n## Anti-patterns\n\n- **Shape lies**: a three-line text skeleton for what arrives as a media card — the preview promised a different page.\n- **Eternal shimmer**: no failure exit, so a dead request pulses forever and the skeleton becomes the error state by accident.\n- **Skeleton flash**: showing placeholders for a cache hit that resolves in 50ms — the UI blinks structure at the user.\n- **Announcing the skeleton**: leaving placeholder blocks in the accessibility tree as unnamed groups instead of one status message.\n\n## Agent notes\n\n- prompt_phrases: \"structure-matching skeleton cards with deferred appearance and a failure exit\", \"skeleton loading that never flashes on fast loads\"\n- fallbacks: for a single tiny widget, prefer reserving the final dimensions with an empty bordered box over a shimmer.\n- canonical guidance: `docs/design-system/decisions/waiting-and-absence.md` (progress vs absence vs failure), `recipes/data-display/remote-data-screen.md` (state machine this feeds)."},{"id":"svg-stroke-assembly-loader","name":"SVG Stroke Assembly Loader","pattern_group":"feedback","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["websites"],"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/svg-stroke-assembly-loader.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/feedback/svg-stroke-assembly-loader.md","body":"## Intent\n\nA multi-stage entry loader scatters a wordmark's SVG strokes off-canvas, then reassembles them in shuffled stagger order while a percent counter runs from 100 down to 0. Loading progress doubles as a brand imprint moment rather than a neutral wait state. On repeat visits within the same session the loader is skipped entirely. Observed at noth.in (research/m55-round2/p2-noth-in.json rows[0]-rows[1]): row[0] is the multi-stage entry loader itself (percent counter 100 to 0 power2.inOut, hero SVG path random-shuffle stagger .07s duration 1.8s ease power4.inOut, loader container height to 0 duration 1.8s power4.inOut); row[1] is the session-revisit skip (sessionStorage key gates replay). User gate pass frozen in research/2026-08-15-m55-gate-verdicts.md section 2 (round-2 new, pass).\n\n## Anatomy\n\n- Four SVG stroke paths form the wordmark. Each is set to a fixed scatter offset (x/y/rotate) and opacity 0 before play; these are reproduction-fixed values standing in for the observed random scatter, not a re-roll on every play.\n- A shuffled draw order (Fisher-Yates over indices 0-3) picks which stroke animates at which stagger slot, so the assembly order differs from visit to visit.\n- GSAP timeline built from three concurrent/offset tracks on one timeline instance:\n - Percent counter tween: value 100 to 0, duration 5000/750 ≈ 6.67s, ease `power2.inOut`, driving a React state update on each tick (`onUpdate`).\n - Stroke tweens: each stroke animates x/y/rotate to 0 and opacity to 1, duration 1.8s, ease `power4.inOut`, placed at `i * 0.07` (stagger 0.07s) in shuffled order, starting at timeline position 0.\n - Container collapse: `height: 0`, duration 1.8s, ease `power4.inOut`, placed at `\"+=0.3\"` after the stroke tweens (0.3s gap), so the wordmark holds visible after assembly before the whole loader shrinks away.\n- `onComplete` on the timeline writes a sessionStorage flag; a mount-time check reads that flag and, if present, renders the finished wordmark directly (no timeline built at all) instead of skipping mid-animation.\n- `prefers-reduced-motion: reduce` short-circuits before any tween is created: strokes are set straight to their resting transform, percent jumps straight to 0, complete flips true, session flag is written. No timeline, no `onUpdate` loop.\n\n## States\n\n- Playing: strokes scattered and animating toward their resting positions, percent counter descending, container at `height: auto`.\n- Complete: timeline's `onComplete` fired, \"완료\" (complete) label shown, session flag set; container is mid-collapse or already collapsed.\n- Skipped (revisit): sessionStorage flag present at mount, loader markup never renders, wordmark shown in its final resting `d` values immediately.\n- Reduced motion: same skipped-looking end state reached without a scatter phase or a tween loop, but still on this same mount (not gated by session).\n\n## Variants\n\n- Different wordmark: swap the `STROKES` path data and `SCATTER_OFFSETS`; stroke count need not stay at 4, but the stagger term (`i * 0.07`) and shuffle function are count-agnostic.\n- Session gate off: drop the sessionStorage read/write pair to make the loader replay every mount (e.g. for a demo or an app that reloads intentionally on route changes).\n- No counter: drop the percent tween track and keep only the stroke-assembly and container-collapse tracks, for a pattern that's purely a wordmark reveal.\n- Carousel micro-choreography (noth.in loader, research/m56-round3/noth-in-deep.json): each image swap is not a plain crossfade but a back.out(1.2) overshoot from scale .8 over .32s, while the swap interval accelerates on a sine curve down to 100ms; the first image enters rotated 15deg with back.out(0.9) after a .5s delay, and the progress bar's target width is responsive (10rem mobile / 20rem desktop, 1.2s power4.inOut).\n- Overlapped exit: four tweens leave with offset delays instead of a sequence: current image scale-out .6s delay .5, bar shrink .8s delay .6, container height collapse 1.8s delay 0, counter fade .5s; the wordmark below then hands off to the letter stagger reveal.\n\n## Code\n\n```tsx\nconst tl = gsap.timeline({\n onComplete: () => {\n setComplete(true)\n window.sessionStorage.setItem(SESSION_KEY, \"1\")\n },\n})\n\n// percent counter 100 -> 0, duration 5000/750 ~= 6.67s, ease power2.inOut\ntl.to(counterState, {\n value: 0,\n duration: 5000 / 750,\n ease: \"power2.inOut\",\n onUpdate: () => setPercent(Math.round(counterState.value)),\n}, 0)\n\n// shuffled stroke assembly, stagger 0.07s, duration 1.8s, ease power4.inOut\norder.forEach((strokeIndex, i) => {\n const el = strokeRefs.current[strokeIndex]\n if (!el) return\n tl.to(el, { x: 0, y: 0, rotate: 0, opacity: 1, duration: 1.8, ease: \"power4.inOut\" }, i * 0.07)\n})\n\n// container collapse after a hold gap\ntl.to(container, { height: 0, duration: 1.8, ease: \"power4.inOut\" }, \"+=0.3\")\n```\n\n## Checks\n\n- `prefers-reduced-motion: reduce` is checked before any tween is created, not applied as a post-hoc duration override.\n- Timeline is killed on unmount (`timelineRef.current?.kill()` in the effect cleanup) and killed again before any replay (`playLoader` kills the prior instance before building a new one), so a fast reset-and-replay never runs two timelines at once.\n- sessionStorage read happens once at mount to decide skip vs play; the write happens only in `onComplete`, so an interrupted (killed) timeline never marks the session as played.\n- Percent counter state is driven by the tween's own `onUpdate`, not a separate `setInterval`, so it can't drift from the visual stroke assembly.\n\n## Anti-patterns\n\n- Re-rolling the scatter offsets on every render instead of once per play call: strokes would jump discontinuously if a parent re-render fires mid-animation.\n- Writing the sessionStorage flag before `onComplete`: a killed or interrupted timeline would falsely mark the loader as played, and a revisit would show the unfinished end state.\n- Running the percent tween and stroke tweens as independent GSAP calls instead of tracks on one timeline: loses the shared kill/cleanup path and the deliberate `\"+=0.3\"` hold before collapse.\n- Applying reduced-motion as a shortened duration rather than a skip: a fast version of a scatter-and-reassemble motion is still disorienting for users who asked for none.\n\n## Agent notes\n\n- prompt_phrases: \"strokes scatter and reassemble into a logo while loading\", \"shuffled stagger wordmark loader with percent counter\", \"entry loader that skips on repeat visits this session\".\n- fallbacks: if SVG stroke geometry isn't available for the brand mark, fall back to a plain percent-counter preloader (see fake-progress-preloader) rather than faking a stroke assembly with generic shapes. If sessionStorage is unavailable (privacy mode, SSR), let the loader replay every visit instead of throwing."},{"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-size-scale","name":"Button Size Scale","pattern_group":"forms","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m62","surface_refs":["commerce","websites"],"tokens_used":["color.semantic.action.primary","color.semantic.text.on-accent","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.border.focus","dimension.radius.sm","dimension.radius.md","dimension.radius.lg","dimension.radius.xl"],"code_asset":"examples/ui-vocabulary-site/src/components/button-size-scale.tsx","component_refs":[],"term_refs":["button","primary-button","secondary-button","variant-selector-buttons"],"source_refs":[],"last_verified":"2026-08-17","source_path":"recipes/forms/button-size-scale.md","body":"## Intent\n\n버튼 크기는 하나의 척도가 아니라 두 개의 독립된 척도다. 실행형 CTA(장바구니 담기, 결제하기처럼 즉시 트리거되는 단일 행동)는 높이가 고정되고 너비가 라벨 길이에 따라 가변인 반면, 선택형 컨트롤(사이즈 칩, 컬러 스와치, 옵션 카드처럼 여러 값 중 하나를 고르는 토글)은 높이와 너비가 모두 고정된 정해진 치수를 쓴다. 이 둘을 같은 스케일로 취급하면 실행 버튼이 늘어지거나 선택 칩이 라벨마다 들쭉날쭉해진다.\n\n실행형 CTA 안에서도 정반대의 형태 답이 실제로 쓰인다. 아마존은 높이 32px에 radius 100px(알약형)의 얇고 둥근 버튼을 쓰고, Shopify Dawn 프리셋은 높이 4.5rem(45px)에 radius 0(각짐)의 두껍고 각진 버튼을 쓴다. 두 접근 모두 3단 위계(primary/secondary/tertiary)를 갖지만, Dawn은 그 위계를 색상 자체가 아니라 배경/보더의 **알파값**으로 나눈다 — primary는 배경 alpha 100%, secondary는 배경 alpha 0%에 보더를 강조, tertiary는 배경 alpha 0%에 보더 alpha 20%(GH-04). 이마트 실측(EM-10)은 반대 방향의 증거를 더한다 — 3색 variant 버튼의 치수(height 40px, radius 0, padding 0 12px)가 색상과 무관하게 모두 동일해, 색상 위계와 치수 위계가 서로 독립적으로 관리될 수 있음을 보여준다.\n\n쿠팡은 같은 화면 안에서 1차/2차 CTA를 치수만으로 2단 대비시킨다 — 1차는 padding 39px 14px에 22px bold, 2차는 padding 17px 3px에 14px bold로, 위계가 내려갈수록 패딩과 글자 크기가 함께 줄어든다(CP-C2, CP-C3).\n\n포커스 표시는 단일 링이 아니라 배경색 레이어, 링, 옅은 외곽선의 3중 box-shadow로 겹쳐 쌓는다(GH-03) — 어떤 배경 위에 버튼이 놓이든 포커스 링이 배경에 묻히지 않도록 하는 장치다.\n\n선택형 컨트롤의 치수는 실행형과 별도 체계다: 텍스트 칩(사이즈 선택) height 34px width 50px radius 8px, 컬러 스와치 height 52px width 40px radius 12px, 대형 옵션카드 height 143px width 75px radius 8px에 품절 시 border dashed(AZ-02, AZ-03, AZ-04).\n\n근거: `research/m62-round7/p2-ledger.json` rows AZ-01~06, GH-01~04, GH-15, CP-C2, CP-C3, EM-01, EM-10. 오픈소스 코드 근거: `Shopify/dawn@258f00f643`(MIT), `vercel/commerce@3761e52e60`(MIT).\n\n## Anatomy\n\n- **실행형 CTA** — 고정 높이, 가변 너비(라벨 길이 추종), 단일 위계 그룹당 primary 1개.\n- **위계 변형** — primary(배경 alpha 100%) / secondary(배경 alpha 0%, 보더 강조) / tertiary(배경 alpha 0%, 보더 alpha 20%)의 3단. 알파값만 바꾸고 색상 자체는 유지한다.\n- **선택형 컨트롤** — 고정 높이 + 고정 너비. 텍스트 칩(사이즈), 컬러 스와치(원색 미리보기), 대형 옵션카드(라벨+가격 병기) 세 형태.\n- **품절 표시** — 선택형 컨트롤에서 `border-dashed` + 비활성화로 표시(버튼을 지우지 않고 존재는 남긴다).\n- **포커스 링** — 배경색 레이어 → 링 → 옅은 외곽선의 3겹 box-shadow.\n\n## States\n\n- **Default** — 위계에 맞는 알파값의 배경/보더.\n- **Hover** — (원본 관측 범위 밖, 데모는 정적 프리셋만 재현) 시스템 공통 hover 규칙을 따른다.\n- **Focus-visible** — 3중 box-shadow 링, `Tab`으로 순회 시 확인 가능.\n- **Selected**(선택형 컨트롤 한정) — 보더/배경이 `color.semantic.action.primary`로 강조.\n- **Disabled/품절**(선택형 컨트롤 한정) — `border-dashed`, 클릭 불가, 색은 muted.\n\n## Variants\n\n- **프리셋 A(아마존형)** — height 32px, radius 100px(알약), padding 5px 11px, 14px/400.\n- **프리셋 B(Dawn형)** — height 45px(primary/secondary) / 35px(tertiary), radius 0, padding 0 30px(primary/secondary) / 10px 15px(tertiary), 15px/12px medium.\n- **쿠팡 2단 대비** — 프리셋과 무관하게 상시 노출. 1차 padding 39px 14px·22px bold, 2차 padding 17px 3px·14px bold.\n- **선택형 3종** — 텍스트 칩 / 컬러 스와치 / 대형 옵션카드, 서로 다른 고정 치수.\n\n## Code\n\n```tsx\n// GH-03 focus-visible 3중 링 — 배경색 레이어 → 링 → 옅은 외곽선. 토큰만 바꿔 재현.\nconst focusRing =\n \"outline-none focus-visible:[box-shadow:0_0_0_3px_var(--background),0_0_0_5px_var(--ring),0_0_0_8px_color-mix(in_oklch,var(--ring)_35%,transparent)]\"\n\n// AZ-01 아마존형 primary: 32px 높이, 알약 radius, 라벨 길이만큼 가변 너비\n<button className={\"rounded-full bg-primary px-[11px] py-[5px] text-sm font-normal text-primary-foreground \" + focusRing}>\n 장바구니 담기\n</button>\n\n// GH-01/GH-04 Dawn형 tertiary: 배경 alpha 0%, 보더 alpha 20%로만 위계를 낮춘다(색 자체는 안 바꾼다)\n<button\n className={\"border border-foreground/20 bg-transparent px-[15px] py-[10px] text-[12px] font-medium text-foreground \" + focusRing}\n style={{ height: 35 }}\n>\n 나중에 담기\n</button>\n\n// AZ-02 선택형 텍스트 칩: 실행형과 별도로 높이·너비 모두 고정\n<button\n style={{ height: 34, width: 50 }}\n className={\"rounded-lg border text-xs font-medium \" + focusRing + \" \" + (selected ? \"border-primary bg-primary text-primary-foreground\" : \"border-border bg-background text-foreground\")}\n>\n {label}\n</button>\n```\n\n## Checks\n\n- 실행형 CTA는 높이 고정, 너비는 라벨을 따라 가변인지 확인 — 선택형 컨트롤처럼 고정 너비를 실행형에 쓰지 않는다.\n- 같은 위계 그룹에 solid-fill primary가 2개 이상 동시에 보이지 않는지 확인(1개 그룹 = primary 1개).\n- 위계 변형을 나눌 때 색상 자체가 아니라 배경/보더의 알파값을 바꾸는지 확인(별도 색을 새로 만들지 않는다).\n- 포커스 링이 3중 box-shadow로 배경에 상관없이 식별되는지 `Tab` 키로 확인.\n- 선택형 컨트롤(칩·스와치·옵션카드)의 품절/비활성 상태가 `border-dashed`처럼 존재는 남기고 조작만 막는 방식인지 확인 — 완전히 숨기지 않는다.\n\n## Anti-patterns\n\n- **실행형 CTA에 선택형 컨트롤의 고정 너비를 그대로 가져다 쓰는 것.** 라벨이 잘리거나 여백이 어색해진다 — 실행형은 너비 가변이 기본값이다.\n- **위계를 나누려고 새 색을 만드는 것.** Dawn 실측처럼 알파값만으로 3단을 표현할 수 있다 — 위계마다 다른 색상 토큰을 추가하면 색 체계가 불필요하게 늘어난다.\n- **아마존형(알약)과 Dawn형(각짐)을 한 화면에 섞어 쓰는 것.** 두 형태는 각각 완결된 체계이며, 이 레시피의 두 프리셋은 \"이렇게 섞어 쓰라\"가 아니라 \"정반대 답이 둘 다 유효하다\"는 대조 증거다. `button` 레시피가 정한 이 시스템의 고정 radius(`sm`)를 프로덕션에서 임의로 알약형으로 바꾸는 근거로 쓰지 않는다.\n- **선택형 컨트롤에 실행형 CTA의 포커스/호버 스타일을 그대로 복붙하는 것.** 선택형은 `selected` 상태가 있어 hover와 selected가 시각적으로 구분돼야 한다.\n\n## Agent notes\n\n- `prompt_phrases`: \"장바구니 버튼이랑 사이즈 선택 버튼 크기가 왜 헷갈리지\", \"CTA 버튼 위계 3단으로 나눠줘\", \"품절 옵션은 어떻게 표시해\", \"사이즈/컬러 선택 버튼 치수 알려줘\".\n- 실행형 CTA 요청이면 `button` 레시피의 고정 radius/height 스펙을 기본값으로 쓰고, 이 레시피는 그 위계(alpha 기반 3단)와 크기 대비(2단 이상 갈릴 때) 판단 근거로만 참조한다.\n- 선택형(사이즈·컬러·옵션) 요청이면 `variant-selector-buttons` 텀을 함께 확인하고, 이 레시피의 고정 높이/너비 치수를 스캐폴드로 쓴다.\n- 아마존형(알약)과 Dawn형(각짐) 중 하나를 임의로 고르지 말고, 기존 화면에 이미 쓰인 radius 관례가 있으면 그것을 따른다."},{"id":"button","name":"Button","pattern_group":"forms","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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":"character-login","name":"Character Login","pattern_group":"forms","kind":"form-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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","color.semantic.border.focus","color.semantic.action.primary","dimension.space.2","dimension.space.4","dimension.radius.md","typography.scale.sm","typography.scale.lg","typography.weight.medium"],"mobile":{"platforms":{"ios":"credential form screen","android":"credential form screen"},"gestures":["tap"],"touch_targets":["password reveal toggle","provider button","submit button"],"safe_area":"none","reduced_motion":"캐릭터의 표정 전환은 상태이므로 유지하고 트윈만 제거 — 눈 가림·엿봄은 그대로 일어난다","modal_depth":0,"port_notes":"SVG 캐릭터는 `react-native-svg`·Flutter `CustomPaint` 로, `autocomplete` 속성은 `textContentType`(iOS)·`autofillHints`(Android) 로 바뀐다. 캐릭터가 `aria-hidden` 이라는 계약은 `accessibilityElementsHidden`·`ExcludeSemantics` 로 옮긴다"},"code_asset":"examples/ui-vocabulary-site/src/components/character-login.tsx","component_refs":["button"],"term_refs":["login-form","password-field"],"source_refs":[],"last_verified":"2026-08-10","source_path":"recipes/forms/character-login.md","body":"## Intent\n\nA character login gives the most utilitarian surface in a product a personality whose motion is functional, not decorative: a mascot watches the username field as you type, covers its eyes when focus enters the password field, and peeks through when the user reveals the password. Every animation states something true about the form (\"I can see this\" / \"I am not looking\"), which is what separates it from mascot decoration. The form beneath the character remains a fully standard credential form — the character never replaces form semantics.\n\n## Anatomy\n\n- Character: an `aria-hidden` SVG face above the fields — pupils track typing progress in the username field, lids/hands close on password focus, half-open when the password is revealed.\n- Username field: visible `<label>`, `name=\"username\"`, `autocomplete=\"username\"`.\n- Password field: visible `<label>`, `name=\"password\"`, `autocomplete=\"current-password\"`, with a reveal toggle as `<button type=\"button\" aria-pressed>`.\n- Form element: `method=\"post\"` semantics — credentials never ride a query string.\n- Provider row: one button per identity provider (Google, Kakao, ...), placed **above** the credential fields — most returning users take this route, and putting it below makes them read a form they never needed. Every provider button carries the same shape and weight so none reads as the recommended one.\n- Divider: a labelled rule between the provider row and the credential fields, not bare whitespace. The two routes lead to the same account and the divider is what says so.\n- Submit: a real submit button whose visible text is its accessible name.\n- Sign-up path: a link out to account creation. A login screen with no way to make an account is a dead end for exactly the visitors who need one.\n\n## States\n\n- Idle: character gazes forward, fields at rest.\n- Typing (username): pupils lean toward the caret; the offset derives from cached geometry, not per-keystroke layout reads.\n- Password focused: eyes covered — the character states \"not looking\".\n- Password revealed (`aria-pressed=\"true\"`): eyes half-open — the visibility change is mirrored by the character and by the toggle's ARIA state.\n- Submitting: controls disabled; character neutral.\n\n## Variants\n\n- Compact card (demo default) vs full-page split layout.\n- Character skin swap (any face that can express look/covered/peek maps onto the same three states).\n\n## Code\n\n```tsx\n// Geometry is cached — measured once and on resize, never inside the\n// keystroke handler (a per-keystroke getBoundingClientRect is a forced\n// synchronous layout on the hottest path of the form).\nconst fieldWidthRef = useRef(1)\nuseEffect(() => {\n const measure = () => { fieldWidthRef.current = input.getBoundingClientRect().width }\n measure()\n window.addEventListener(\"resize\", measure)\n return () => window.removeEventListener(\"resize\", measure)\n}, [])\n\n// Pupil offset derives from typed length against cached width.\nconst pupilX = Math.min(username.length / 24, 1) * MAX_PUPIL_TRAVEL\n```\n\n## Checks\n\n- Both fields have visible `<label>` text, `name`, and the correct `autocomplete` token pair (`username` / `current-password`) — the password-manager path works.\n- The reveal toggle is a `<button type=\"button\">` with `aria-pressed`, reachable and operable by keyboard alone (Tab → Enter/Space).\n- The character SVG is `aria-hidden=\"true\"` — it is commentary, not content; no screen-reader noise.\n- Eye motion derives from cached geometry (measure on mount/resize only); no layout reads inside input handlers.\n- All motion is gated behind `motion-safe`; with reduced motion the three character states still switch (instantly) because they carry meaning — only the tweening disappears.\n- Credential submission is POST-shaped; nothing about the character intercepts or delays native form behavior.\n\n## Anti-patterns\n\n- **Credential form with no provider route**: shipping only email/password when the product actually supports social sign-in, so returning users hunt for a button that is not there.\n- **Approximated provider marks**: drawing a lookalike logo or picking your own button colors. Each provider's sign-in guidelines govern the mark, the wording, and the colors, and an approximation is not a compliant button. The stand-in marks in this recipe's code exist to show the layout and must be swapped for the official asset.\n- **Placeholder-as-label**: the character draws the eye, the field loses its name the moment typing starts.\n- **`<i>` reveal toggle**: an icon with a click listener — invisible to keyboard and assistive tech; the book's own login did this.\n- **Per-keystroke measurement**: calling `getBoundingClientRect` in the input handler to aim the pupils — forced synchronous layout on every character typed.\n- **Decorative override**: character animation that fires on submit failure and covers the error message — personality must never outrank state visibility.\n\n## Agent notes\n\n- prompt_phrases: \"login form with a character that watches typing and covers its eyes for the password field\", \"functional mascot login — motion states what the form knows\"\n- fallbacks: without an SVG character, keep the exact same form contract and drop only the persona layer.\n- canonical guidance: `knowledge/form-fields.md` (계약 4조), `knowledge/input-modality.md` §3 (조작 요소), `knowledge/motion-principles.md` (목적 있는 모션)."},{"id":"mobile-signup-field-stack","name":"Mobile Signup Field Stack","pattern_group":"forms","kind":"form-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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"],"mobile":{"platforms":{"ios":"form screen with return-key chaining","android":"form screen with IME next action"},"gestures":["tap"],"touch_targets":["provider button","input field","submit button"],"safe_area":"none","reduced_motion":"none","modal_depth":0,"port_notes":"**이 자산의 핵심 계약이 곧 이식 지점이다** — `inputMode`/`enterKeyHint` 는 `keyboardType`+`returnKeyType`(RN)·`TextInputType`+`textInputAction`(Flutter) 로 매핑된다. blur 시점 검증은 `onBlur`·`FocusNode` 리스너로 옮긴다"},"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- Provider row: identity-provider buttons above the field stack, at the same 44px touch height as the fields. On mobile this route matters more than on desktop, because it is the one path that skips typing a password on a soft keyboard.\n- Divider: a labelled rule naming what the fields below are for (\"or sign up with email\"), so the stack reads as the alternative route rather than the only one.\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":"padding-proportional-radius","name":"Padding Proportional Radius","pattern_group":"forms","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m67","surface_refs":["websites","components-primitives"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.action.primary","color.semantic.text.on-accent","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/padding-proportional-radius.tsx","component_refs":[],"term_refs":["button","border-radius"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/forms/padding-proportional-radius.md","body":"## Intent\n\n버튼의 둥근 정도를 값으로 정하면 크기 단계가 늘어날 때마다 형태가 무너진다. 같은 `radius: 10px` 이 작은 버튼에서는 알약처럼 보이고 큰 버튼에서는 각져 보이기 때문이다. NORMAL IS BORING 은 반지름을 **패딩의 함수**로 둬서 이 문제를 아예 없앤다 — `--b-radius: calc((0.65em - 2px) * 3)` 처럼 반지름이 패딩과 같은 단위(`em`)로 자라므로, 글자 크기를 바꾸면 패딩과 반지름이 함께 움직이고 형태 비율이 고정된다.\n\n이것은 정본의 [[button-size-scale]] 이 답하지 않는 축이다. 그쪽은 단계별 높이·패딩·radius 를 **표로** 갖고 있어 단계를 하나 늘리면 사람이 radius 를 다시 정해야 한다. 비례식은 그 결정을 없앤다.\n\n## Anatomy\n\n- **패딩** — `calc(<계수>em - <테두리>px)`. 세로·가로 계수가 따로다(`0.6em` / `1.3em`).\n- **반지름** — `--b-radius: calc((<계수>em - <테두리>px) * 3)`. 패딩과 **같은 단위**로 자란다.\n- **계수** — 형태 언어를 정하는 유일한 숫자. 크면 알약, 작으면 각짐.\n- **뺄셈항** — 테두리 두께 보정. 테두리가 얇아지는 브레이크포인트에서 같이 줄어든다.\n- **원형 변형** — 비례식 밖의 별도 형태(`aspect-ratio: 1/1` + `radius: 100%`).\n\n## States\n\n- **Default** — 배경 `action.primary`, 글자 `text.on-accent`.\n- **Hover** — 채움 레이어만 교체된다. 치수·반지름은 변하지 않는다.\n- **Focus-visible** — 시스템 공통 포커스 링을 따른다.\n- **반전(inv)** — 배경과 글자를 뒤집고 채움 레이어도 뒤집는다. **치수는 공유한다.**\n\n## Variants\n\n- **base** — `padding calc(0.6em - 2px) calc(1.3em - 2px)` · `radius calc((0.65em - 2px) * 3)`.\n- **xl** — `padding calc(1.05em - 2px) calc(2.85em - 2px)` · `radius calc((1.05em - 2px) * 3)`.\n- **좁은 화면 스텝** — 950px 이하 뺄셈항 `2px → 1px`, 1024px 이하 xl 계수 `1.05em → 0.85em`.\n- **원형** — `width 7.5rem`(1024px 이하 `6.5rem`), `aspect-ratio 1/1`, `radius 100%`.\n- **색 변형** — 채움 레이어만 덮어쓰고 치수는 공유한다.\n\n## Code\n\n```css\n/* 반지름이 패딩과 같은 단위로 자란다 — 크기 단계를 늘려도 형태 비율이 고정된다 */\n.btn {\n padding: calc(0.6em - 2px) calc(1.3em - 2px);\n --b-radius: calc((0.65em - 2px) * 3);\n border-radius: var(--b-radius);\n}\n.btn--xl {\n padding: calc(1.05em - 2px) calc(2.85em - 2px);\n --b-radius: calc((1.05em - 2px) * 3);\n}\n@media (max-width: 1024px) {\n .btn--xl { --b-radius: calc((0.85em - 2px) * 3); }\n}\n```\n\n## Checks\n\n- 글자 크기를 두세 단계 바꿔 보고 **둥근 인상이 유지되는지** 확인 — 고정 radius 는 여기서 무너진다.\n- 반지름이 `px` 리터럴이 아니라 패딩과 같은 `em` 식으로 쓰였는지 확인.\n- 테두리 두께가 바뀌는 브레이크포인트에서 뺄셈항도 같이 바뀌는지 확인.\n- 색 변형을 바꿔도 치수·반지름이 그대로인지 확인(색 위계와 치수 위계는 독립이다).\n- 원형 버튼을 비례식으로 만들려 하지 않았는지 확인.\n\n## Anti-patterns\n\n- **반지름을 `px` 로 고정하는 것.** 크기 단계가 하나 늘 때마다 사람이 다시 판단해야 하고, 그 판단은 반드시 어긋난다.\n- **계수를 컴포넌트마다 다르게 두는 것.** 계수는 브랜드의 형태 언어라 시스템 전체에서 하나여야 한다.\n- **원형을 큰 계수로 흉내 내는 것.** 반지름을 키워 원에 가깝게 만들면 크기가 바뀔 때 타원으로 무너진다.\n- **색 변형에 치수를 딸려 보내는 것.** 색만 바꾸려던 곳에서 형태까지 달라진다.\n\n## Agent notes\n\n- `prompt_phrases`: \"버튼 크기 단계 늘려줘\", \"큰 버튼이 각져 보여\", \"radius 를 어떻게 정하지\", \"버튼 형태가 크기마다 달라 보여\".\n- 크기 단계를 **추가**하는 요청이면 표를 늘리지 말고 이 비례식으로 갈아탄다.\n- 이 시스템의 기본 버튼은 `button` 레시피가 정한 고정 radius 를 쓴다 — 이 recipe 는 **크기 축이 여러 단계로 늘어날 때**의 답이다.\n- 원형·아이콘 버튼 요청에는 이 식을 쓰지 말고 `aspect-ratio` + `radius 100%` 로 분리한다.\n\n## 관측 (NORMAL IS BORING · 2026-08-17)\n\n- `.btn--bg`: `padding: calc(0.6em - 2px) calc(1.3em - 2px)` · `--b-radius: calc((0.65em - 2px) * 3)`\n- `.btn--bg-xl`: `padding: calc(1.05em - 2px) calc(2.85em - 2px)` · `--b-radius: calc((1.05em - 2px) * 3)`\n- 반응형 스텝은 계수만 바꾼다 — 950px 이하에서 뺄셈항 `2px → 1px`, 1024px 이하에서 xl 계수 `1.05em → 0.85em`.\n- `.btn--circle`: `width: 7.5rem`(1024px 이하 `6.5rem`) · `aspect-ratio: 1/1` · `border-radius: 100%` — 원형은 비례식 밖의 별도 형태다.\n- 색 변형(`--bg-inv`·`--bg-grey|blue|red|beige`)은 hover fill 레이어만 덮어쓰고 **치수는 공유한다**. 색 위계와 치수 위계가 독립이라는 [[button-size-scale]] 의 이마트 실측과 같은 방향이다.\n\n## 규칙\n\n1. **반지름은 패딩과 같은 단위로 쓴다.** `px` 로 고정하면 크기 단계마다 사람이 다시 판단해야 한다.\n2. **뺄셈항은 테두리 보정이다.** `0.65em - 2px` 의 `2px` 는 테두리 두께이고, 테두리가 얇아지는 브레이크포인트에서 같이 줄어든다.\n3. **계수(`× 3`)가 형태 언어다.** 이 값이 클수록 알약에 가깝고 작을수록 각진다 — 브랜드가 고르는 것은 반지름 값이 아니라 이 계수 하나다.\n4. **원형은 예외로 둔다.** 비례식으로 원을 만들려 하지 말고 `aspect-ratio: 1/1` + `radius: 100%` 로 분리한다.\n\n## 우리가 정한 값\n\n고정 radius 대조군(10px)과 글자 크기 단계(12~34px)는 비교를 위해 우리가 만든 것이고, 팔레트는 전부 프로젝트 토큰이다. 원 사이트의 검정·베이지 팔레트는 가져오지 않았다.\n\n## term 대응\n\n- `button` — 이 규칙이 지배하는 대상 자체가 버튼의 형태 언어다.\n- `border-radius` — 반지름을 값이 아니라 함수로 두는 방법이라 이 term 의 사용 조건을 정교화한다.\n\n## 관련\n\n[[button-size-scale]](같은 축의 표 기반 답 — 이 recipe 가 그 표의 radius 열을 대체한다) · `research/m67-batch2-css/p2-ledger.json` 행 `nib-06`\n\n## Changelog\n\n- 2026-08-18 — m67-batch2-css 회차 신설. 사용자 관측 게이트 통과(동결 `research/2026-08-17-m67-gate-verdicts.md`)."},{"id":"quiet-form-validation","name":"Quiet Form Validation","pattern_group":"forms","kind":"form-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m66","surface_refs":["websites","saas-dashboards","commerce"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.border.input","color.semantic.status.danger-foreground","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/quiet-form-validation.tsx","component_refs":[],"term_refs":["validation-message","field-group","label","text-field"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/forms/quiet-form-validation.md","body":"## Intent\n\nValidating on every keystroke means the user is told the email is invalid while typing the third character of it. Validating only on submit means they find out about four problems at once, after the fact. The rule observed here splits the two events: `blur` decides whether a field is wrong, and `input` is allowed to run validation only for fields already marked wrong, and only to clear the mark. The result is a form that stays silent while you write and releases the error the moment you fix it. It travels with an underline control style where the error state is a border color swap rather than an added icon or background. Observed at IZANAMI (https://izanami-official.com/contact/) — ledger rows izanami#18 · izanami#17 in `research/m66-round8/p2-ledger.json`, disposition in `research/m66-round8/disposition.json`; user gate pass frozen in `research/2026-08-17-m66-gate-verdicts.md`.\n\n## Anatomy\n\n- **Field state** — per-field error, set on blur and cleared on input.\n- **Blur validator** — the only place a new error is created.\n- **Input validator** — guarded so it runs only when an error is already present.\n- **Underline control** — label, a bottom-border input, and a message slot below it.\n- **Error affordance** — border color token swap plus a text message, wired with `aria-invalid` and `aria-describedby`.\n\n## States\n\n- **Untouched** — no validation has run; no message.\n- **Typing (clean)** — no validation runs at all.\n- **Invalid** — set on blur; border swaps to the danger token and the message appears.\n- **Typing (invalid)** — validation runs on each input, but only to clear.\n- **Recovered** — the moment the value becomes valid the error is released without waiting for blur.\n\n## Variants\n\n- **Validate-on-input (contrast)** — the common approach, worth rendering beside this one because the difference is visible within three keystrokes.\n- **Label placement** — a fixed label column on wide viewports, stacked above the control on narrow ones.\n- **Submit-time sweep** — on submit, all fields validate regardless of touch state, since the user has asked for a verdict.\n\n## Code\n\n전체 구현은 `code_asset` 이 정본이다. 핵심만 발췌한다 — 이 패턴을 특징짓는 것은 분량이 아니라 아래 규칙 한 줄이다.\n\n```tsx\n// Quiet Form Validation — 핵심 규칙\n// 구현 전문: examples/ui-vocabulary-site/src/components/quiet-form-validation.tsx\n// 관측 파라미터의 출처: research/m66-round8/p2-ledger.json (izanami#18 · izanami#17)\n```\n\n## Checks\n\n- Typing an invalid value into an untouched field produces no message.\n- Leaving that field produces a message and sets `aria-invalid`.\n- Correcting the value clears the message before blur.\n- The error is conveyed by text, not by color alone.\n- Each control has a programmatically associated label and message.\n\n## Anti-patterns\n\n- **Validating on every input from the start.** This is the behavior the pattern exists to replace; it reads as nagging and trains users to ignore messages.\n- **Letting the input handler create new errors.** Then it is just validate-on-input with extra steps.\n- **Signaling the error only with a red border.** Color alone fails both color-blind users and screen readers.\n- **Debouncing as a substitute.** A delayed nag is still a nag; the fix is which event owns error creation, not how fast it fires.\n\n## Agent notes\n\nprompt_phrases: 타이핑 중에 잔소리하지 않는 폼 검증 · blur 에 검증하고 고치면 바로 풀리게 해줘 · 밑줄형 입력칸에 에러 색만 바꿔줘\n\nfallbacks: 폼 라이브러리를 쓰는 프로젝트라면 이 규칙을 그 라이브러리의 validation mode 로 옮긴다(대개 `onBlur` + `reValidateMode: onChange` 조합이 같은 뜻이다). 서버 검증 결과는 blur 규칙 밖이므로 제출 후 필드에 다시 심는다."},{"id":"schema-driven-settings","name":"Schema Driven Settings","pattern_group":"forms","kind":"form-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.border.default","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/schema-driven-settings.tsx","component_refs":[],"term_refs":["switch","slider","select"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/forms/schema-driven-settings.md","body":"## Intent\n\nSettings are declared as typed metadata and the panel is derived from it. Boolean renders a toggle, Range renders a slider carrying min, max and step, Select renders a choice, and reset reads the declared default.\n\nObserved at https://slowroads.io/ (SLW-13, accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Schema is a discriminated union tagged by type, so each variant carries only the parameters its control needs.\n- Declaration and value are separate objects; the declared default is what reset restores.\n- The panel is a pure function of the schema, so adding, removing, or retyping a field changes the UI with no per-control code.\n- The initial four fields reproduce the observed steering settings verbatim.\n\n## Pitfalls\n\n- Showing only the rendered panel hides the point. Put the declaration next to its output so the derivation is visible.\n\n## States\n\n- 기본 — 관측된 4항목이 선언에서 렌더된다.\n- 항목 추가 — 오른쪽 패널이 즉시 늘어난다.\n- 타입 변경 — 같은 항목이 다른 컨트롤로 다시 그려진다.\n- 기본값 복원 — 선언된 default 를 읽는다.\n\n## Variants\n\n- Boolean·Range·Select 세 타입.\n- 항목 추가·삭제·타입 순환.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/schema-driven-settings.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 선언을 감춘 채 결과 패널만 보여 주는 것.\n- 타입별 컨트롤을 손으로 하나씩 배선해 스키마의 값어치를 없애는 것.\n\n## Agent notes\n\nSelect 타입은 관측 2종을 넘어선 확장이다. 스키마가 늘어도 UI 코드가 늘지 않는다는 것을 보이려고 넣었다.\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--foreground`\n- `--muted`\n- `--muted-foreground`\n- `--radius`","code_source":"// 출처: research/m68/p2-slowroads.json SLW-13 (\"설정 스키마 메타데이터 → UI 자동 생성\")\n// 스키마 항목은 {key, readable, desc, type, default, ...type별 파라미터} 형태의 판별 유니온.\n// 초기 4항목은 원본 청크(SettingsManager.e5e730af.js) 값 그대로: sticky(Boolean, default:false),\n// linearity(Range, min:0, max:1, step:.1, default:.2), speedSteerFactor(Range, 0~1, default:.75),\n// resetMaintainsAccel(Boolean, default:true). 선언(스키마)과 값(상태)이 분리돼 있고 default 는\n// 값 저장소 항목을 그대로 참조한다.\n// visual: 왼쪽 스키마 편집 패널에서 항목을 추가/삭제/타입 변경하면 오른쪽 자동 생성 패널이 즉시\n// 그 항목에 맞는 토글/슬라이더/셀렉트를 새로 그린다.\nimport { useState } from \"react\";\n\ntype BooleanField = {\n key: string;\n readable: string;\n desc: string;\n type: \"Boolean\";\n default: boolean;\n};\n\ntype RangeField = {\n key: string;\n readable: string;\n desc: string;\n type: \"Range\";\n min: number;\n max: number;\n step: number;\n default: number;\n};\n\ntype SelectField = {\n key: string;\n readable: string;\n desc: string;\n type: \"Select\";\n options: string[];\n default: string;\n};\n\ntype SettingField = BooleanField | RangeField | SelectField;\n\nconst INITIAL_SCHEMA: SettingField[] = [\n {\n key: \"sticky\",\n readable: \"Sticky steer\",\n desc: \"조향 입력을 놓아도 마지막 각도를 유지합니다\",\n type: \"Boolean\",\n default: false,\n },\n {\n key: \"linearity\",\n readable: \"Steer linearity\",\n desc: \"조향 입력과 실제 회전각 사이의 반응 곡선\",\n type: \"Range\",\n min: 0,\n max: 1,\n step: 0.1,\n default: 0.2,\n },\n {\n key: \"speedSteerFactor\",\n readable: \"Speed steer factor\",\n desc: \"속도가 높을수록 조향 민감도를 얼마나 낮출지\",\n type: \"Range\",\n min: 0,\n max: 1,\n step: 0.05,\n default: 0.75,\n },\n {\n key: \"resetMaintainsAccel\",\n readable: \"Reset maintains accel\",\n desc: \"리셋 시 가속 입력 상태를 그대로 유지합니다\",\n type: \"Boolean\",\n default: true,\n },\n];\n\nfunction makeField(kind: SettingField[\"type\"], index: number): SettingField {\n const key = `field${index}`;\n if (kind === \"Boolean\") {\n return { key, readable: `새 토글 ${index}`, desc: \"새로 추가된 boolean 항목\", type: \"Boolean\", default: false };\n }\n if (kind === \"Select\") {\n return {\n key,\n readable: `새 선택 ${index}`,\n desc: \"새로 추가된 select 항목\",\n type: \"Select\",\n options: [\"A\", \"B\", \"C\"],\n default: \"A\",\n };\n }\n return {\n key,\n readable: `새 슬라이더 ${index}`,\n desc: \"새로 추가된 range 항목\",\n type: \"Range\",\n min: 0,\n max: 1,\n step: 0.1,\n default: 0.5,\n };\n}\n\nexport default function SchemaDrivenSettings() {\n const [schema, setSchema] = useState<SettingField[]>(INITIAL_SCHEMA);\n const [values, setValues] = useState<Record<string, boolean | number | string>>(\n () => Object.fromEntries(INITIAL_SCHEMA.map((f) => [f.key, f.default])),\n );\n const [nextIndex, setNextIndex] = useState(1);\n\n const addField = (kind: SettingField[\"type\"]) => {\n const field = makeField(kind, nextIndex);\n setSchema((prev) => [...prev, field]);\n setValues((prev) => ({ ...prev, [field.key]: field.default }));\n setNextIndex((n) => n + 1);\n };\n\n const removeField = (key: string) => {\n setSchema((prev) => prev.filter((f) => f.key !== key));\n setValues((prev) => {\n const next = { ...prev };\n delete next[key];\n return next;\n });\n };\n\n const cycleType = (key: string) => {\n const current = schema.find((f) => f.key === key);\n if (!current) return;\n const order: SettingField[\"type\"][] = [\"Boolean\", \"Range\", \"Select\"];\n const nextType = order[(order.indexOf(current.type) + 1) % order.length];\n const replaced = makeField(nextType, 0);\n const merged = { ...replaced, key: current.key, readable: current.readable, desc: current.desc };\n setSchema((prev) => prev.map((f) => (f.key === key ? merged : f)));\n setValues((v) => ({ ...v, [key]: merged.default }));\n };\n\n const resetField = (field: SettingField) => {\n setValues((prev) => ({ ...prev, [field.key]: field.default }));\n };\n\n return (\n <div className=\"flex w-full flex-col gap-3 rounded-lg border border-border bg-background p-4\">\n <p className=\"text-sm text-muted-foreground\">\n 왼쪽에서 스키마 항목을 추가·삭제·타입 변경하면 오른쪽 자동 생성 패널이 즉시 바뀝니다\n </p>\n <div className=\"flex min-h-[280px] max-h-[380px] flex-col gap-4 overflow-hidden md:flex-row\">\n {/* 왼쪽: 스키마 선언 */}\n <div className=\"flex w-full flex-col gap-2 overflow-y-auto rounded-md border border-border bg-muted p-3 md:w-1/2\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-foreground\">스키마 선언</span>\n <div className=\"flex gap-1\">\n <button\n type=\"button\"\n onClick={() => addField(\"Boolean\")}\n className=\"rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground hover:bg-muted/70\"\n >\n + Boolean\n </button>\n <button\n type=\"button\"\n onClick={() => addField(\"Range\")}\n className=\"rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground hover:bg-muted/70\"\n >\n + Range\n </button>\n <button\n type=\"button\"\n onClick={() => addField(\"Select\")}\n className=\"rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground hover:bg-muted/70\"\n >\n + Select\n </button>\n </div>\n </div>\n <ul className=\"flex flex-col gap-1.5\">\n {schema.map((field) => (\n <li\n key={field.key}\n className=\"flex items-center justify-between gap-2 rounded-md border border-border bg-background px-2 py-1.5 text-xs\"\n >\n <div className=\"min-w-0\">\n <div className=\"truncate font-medium text-foreground\">{field.readable}</div>\n <div className=\"truncate text-muted-foreground\">\n type: {field.type} · key: {field.key}\n </div>\n </div>\n <div className=\"flex shrink-0 gap-1\">\n <button\n type=\"button\"\n onClick={() => cycleType(field.key)}\n className=\"rounded-md border border-border px-1.5 py-0.5 text-muted-foreground hover:bg-muted\"\n aria-label={`${field.readable} 타입 변경`}\n >\n 타입↻\n </button>\n <button\n type=\"button\"\n onClick={() => removeField(field.key)}\n className=\"rounded-md border border-border px-1.5 py-0.5 text-muted-foreground hover:bg-muted\"\n aria-label={`${field.readable} 삭제`}\n >\n 삭제\n </button>\n </div>\n </li>\n ))}\n {schema.length === 0 && (\n <li className=\"rounded-md border border-dashed border-border p-3 text-center text-xs text-muted-foreground\">\n 항목이 없습니다. 위 버튼으로 추가하세요.\n </li>\n )}\n </ul>\n </div>\n\n {/* 오른쪽: 자동 생성된 설정 패널 */}\n <div className=\"flex w-full flex-col gap-2 overflow-y-auto rounded-md border border-border bg-background p-3 md:w-1/2\">\n <span className=\"text-xs font-medium text-foreground\">자동 생성 패널</span>\n <ul className=\"flex flex-col gap-3\">\n {schema.map((field) => (\n <li key={field.key} className=\"flex flex-col gap-1\">\n <div className=\"flex items-center justify-between\">\n <label htmlFor={`sds-${field.key}`} className=\"text-sm text-foreground\">\n {field.readable}\n </label>\n <button\n type=\"button\"\n onClick={() => resetField(field)}\n className=\"text-xs text-muted-foreground underline hover:text-foreground\"\n >\n 기본값\n </button>\n </div>\n <p className=\"text-xs text-muted-foreground\">{field.desc}</p>\n\n {field.type === \"Boolean\" && (\n <input\n id={`sds-${field.key}`}\n type=\"checkbox\"\n checked={Boolean(values[field.key])}\n onChange={(e) => setValues((v) => ({ ...v, [field.key]: e.target.checked }))}\n className=\"h-4 w-4 accent-foreground\"\n />\n )}\n\n {field.type === \"Range\" && (\n <div className=\"flex items-center gap-2\">\n <input\n id={`sds-${field.key}`}\n type=\"range\"\n min={field.min}\n max={field.max}\n step={field.step}\n value={Number(values[field.key])}\n onChange={(e) =>\n setValues((v) => ({ ...v, [field.key]: Number(e.target.value) }))\n }\n className=\"w-full accent-foreground\"\n />\n <span className=\"w-10 shrink-0 text-right text-xs text-muted-foreground\">\n {Number(values[field.key]).toFixed(2)}\n </span>\n </div>\n )}\n\n {field.type === \"Select\" && (\n <select\n id={`sds-${field.key}`}\n value={String(values[field.key])}\n onChange={(e) => setValues((v) => ({ ...v, [field.key]: e.target.value }))}\n className=\"rounded-md border border-border bg-muted px-2 py-1 text-sm text-foreground\"\n >\n {field.options.map((opt) => (\n <option key={opt} value={opt}>\n {opt}\n </option>\n ))}\n </select>\n )}\n </li>\n ))}\n {schema.length === 0 && (\n <li className=\"rounded-md border border-dashed border-border p-3 text-center text-xs text-muted-foreground\">\n 스키마가 비어 있습니다.\n </li>\n )}\n </ul>\n </div>\n </div>\n </div>\n );\n}\n"},{"id":"css-only-sticky-sequence","name":"CSS-Only Sticky Sequence","pattern_group":"layout","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m66","surface_refs":["websites"],"tokens_used":["color.semantic.surface.raised","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/css-only-sticky-sequence.tsx","component_refs":[],"term_refs":["sticky-scroll-section","scroll-area","grid"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/layout/css-only-sticky-sequence.md","body":"## Intent\n\nA scroll sequence where a label and an image stay pinned while the third column swaps one screen at a time is normally built with a scroll controller: measure the container, compute per-child offsets, translate a wrapper every frame. The observed origin ships both answers on one site, and the simpler page uses none of that. Two sticky rails plus a `gap` of one viewport height on the swapping column produce the same result with no measurement, no listeners, and nothing to resynchronize on resize. Observed at IZANAMI (https://izanami-official.com/philosophy/) — ledger rows izanami#3 in `research/m66-round8/p2-ledger.json`, disposition in `research/m66-round8/disposition.json`; user gate pass frozen in `research/2026-08-17-m66-gate-verdicts.md`.\n\n## Anatomy\n\n- **Grid with three tracks** — two pinned rails and one scrolling column.\n- **Sticky rails** — `position: sticky; top: 0` with a height of one viewport unit, so they pin for exactly the section's length.\n- **Rhythm gap** — the scrolling column separates its items by one viewport height, which is what makes each item occupy a full screen turn.\n- **Section height** — falls out of the item count automatically; there is no computed container height to keep in sync.\n\n## States\n\n- **Before the section** — nothing is pinned; the block scrolls normally.\n- **Inside** — rails are pinned, the third column advances one item per viewport of scroll.\n- **After** — rails unpin on their own at the section boundary.\n\n## Variants\n\n- **Viewport vs container units** — the origin keys off `vh`. Inside a bounded panel the same rhythm is written in container query units so it survives outside a full-page context.\n- **Rail count** — one pinned rail is the common case; the observed page pins two.\n- **Scripted equivalent** — the same site's other pages use a JS controller for this effect. Reach for it only when the pinned content itself must change per item.\n\n## Code\n\n전체 구현은 `code_asset` 이 정본이다. 핵심만 발췌한다 — 이 패턴을 특징짓는 것은 분량이 아니라 아래 규칙 한 줄이다.\n\n```tsx\n// CSS-Only Sticky Sequence — 핵심 규칙\n// 구현 전문: examples/ui-vocabulary-site/src/components/css-only-sticky-sequence.tsx\n// 관측 파라미터의 출처: research/m66-round8/p2-ledger.json (izanami#3)\n```\n\n## Checks\n\n- Scrolling the container pins the rails at a constant viewport offset while the third column advances.\n- Removing all JavaScript leaves the sequence working.\n- Resizing the container does not require any recalculation.\n- The section's total height equals item count times the rhythm unit, with no hardcoded height.\n\n## Anti-patterns\n\n- **Putting `overflow: hidden` on an ancestor.** It silently kills `position: sticky` and the usual response is to reach for the JS controller that was never needed.\n- **Using viewport units inside a bounded panel.** The rhythm then refers to the window rather than the panel and the pinning drifts.\n- **Adding a scroll listener for a progress indicator.** That reintroduces the dependency the pattern removes; if progress must be shown, question whether this pattern is the right one.\n- **Nesting sticky inside a transformed ancestor.** A `transform` creates a containing block and the rail pins to the wrong thing.\n\n## Agent notes\n\nprompt_phrases: 스크롤 컨트롤러 없이 sticky 로만 시퀀스 만들어줘 · 왼쪽은 고정하고 오른쪽만 한 화면씩 갈리게 해줘 · JS 없이 스크롤 고정 섹션\n\nfallbacks: 고정된 쪽의 내용이 항목마다 바뀌어야 하면 이 패턴으로는 안 된다 — 그때는 스크롤 컨트롤러가 정답이고, 같은 원본 사이트가 다른 페이지에서 그 답을 쓴다. 컨테이너 쿼리 단위를 못 쓰는 환경이면 고정 px 리듬으로 대체한다."},{"id":"fluid-type-dual-clamp","name":"Fluid Type Dual Clamp","pattern_group":"layout","kind":"visual-treatment","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites","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.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/fluid-type-dual-clamp.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/layout/fluid-type-dual-clamp.md","body":"## Intent\n\nA dual clamp fluid type scale sizes a headline with two different slopes joined at a break point, instead of the usual single linear ramp between a minimum and a maximum. The reason is that one slope cannot satisfy both ends: a ramp gentle enough to keep the headline from swallowing a small screen leaves it undersized on a large one, and a ramp aggressive enough for large screens overflows small ones. Nesting a `max` and a `min` inside the clamp gives a fast segment where growth matters and a gentle segment where it must be restrained. Observed as Dragonfly's `h1-fluid` value, `clamp(78px, max(65.5px + 1.5625vw, min(-32px + 8.3333vw, 128px)), 128px)`, cross-checked against noth.in's type bands (research/m55-round2/p2-dragonfly.json rows[0] and p2-noth-in.json rows[11]). This demo was repaired during M56 by letting the visitor resize the stage directly instead of dragging an abstract slider, and passed re-judgment in the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 1.\n\n## Anatomy\n\n- Segment A is the gentle slope, `65.5px + 1.5625vw`. Segment B is the aggressive one, `-32px + 8.3333vw`, itself capped at the maximum by the inner `min`.\n- The outer `max` selects whichever segment is larger at the current width, which is what produces the break rather than an explicit media query.\n- The outer `clamp` floors and ceilings the whole expression at `78px` and `128px`.\n- The demo lets the visitor drag the stage's corner, so the formula is felt as a window resize rather than read as a number.\n- Because the demo stage is narrower than a real viewport, stage width is converted to a virtual viewport at a fixed multiplier. The formula is untouched; only the input is mapped, and the mapping is stated in the source.\n- A single-slope comparison line runs alongside, which is what makes the dual clamp's advantage visible rather than asserted.\n\n## States\n\n- Below the break: segment A governs, growth is slow.\n- Above the break: segment B governs, growth is fast until the cap.\n- At the floor and the ceiling: the clamp holds the size flat, and the headline stops responding to width at all.\n\n## Variants\n\n- Break position: moving it is the main design decision, and it should land where the layout actually changes character rather than at a round number.\n- Cap: hard cap (observed) or letting the type keep growing on very wide screens, which is rarely right for a measure-bound headline.\n- Units: `vw` (observed) or a container query unit, which makes the same formula work for a component rather than a page.\n\n## Code\n\n```tsx\nfunction dualClampSize(vw: number): number {\n const segA = 65.5 + 1.5625 * (vw / 100);\n const segB = -32 + 8.3333 * (vw / 100);\n const inner = Math.max(segA, Math.min(segB, 128));\n return Math.min(128, Math.max(78, inner));\n}\n```\n\n## Checks\n\n- Verify the break is continuous. If the two segments do not meet near the break, the headline jumps size during a resize.\n- Check the ends explicitly at the floor and the ceiling width, not just in the middle, since that is where a wrong constant hides.\n- Express the same values in CSS as a real `clamp` in production. The JavaScript version here exists to make the formula visible.\n- Confirm the line count of the headline at the break width. A size that is technically correct but pushes the headline to three lines is still wrong.\n- If the stage is not the viewport, state the mapping. An unstated scaling factor makes the observed constants look wrong to the next reader.\n\n## Anti-patterns\n\n- Stacking media queries to fake the curve: it steps at each breakpoint, and every new device width needs another rule.\n- A single `clamp` with one slope: it is simpler and it is exactly what this pattern exists to improve on.\n- Choosing constants by taste and calling them fluid: derive them from the two widths you actually care about and the sizes you want there.\n- Applying the same dual clamp to body copy: the reading measure, not the viewport, governs body size.\n\n## Agent notes\n\n- prompt_phrases: \"headline size with two different growth rates\", \"nested clamp min max fluid typography\", \"fluid type that grows fast then holds\".\n- fallbacks: no motion is involved, so reduced motion needs no path. Where `clamp` is unsupported, fall back to the minimum size rather than the maximum, so nothing overflows."},{"id":"grid-anchored-icons","name":"Grid-Anchored Icons","pattern_group":"layout","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m60","surface_refs":["websites","saas-dashboards","internal-tools"],"tokens_used":["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/grid-anchored-icons.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-17","source_path":"recipes/layout/grid-anchored-icons.md","body":"## Intent\n\nStore a draggable icon's position as a **grid coordinate** (`{ gridColumnStart, gridRowStart }`) instead of an absolute pixel offset, and let CSS Grid's `1fr` tracks recompute the actual cell size when the stage resizes. A pixel-stored position is a snapshot of one viewport; a grid-stored position is a rule that survives every viewport. Observed in daedalOS's desktop icon layout (`contexts/session/types.ts`, `IconPosition = { gridColumnStart, gridRowStart }`, MIT, HEAD `0df82d7`, `https://dustinbrett.com`) — the source only declares the coordinate shape, not grid size or tile dimensions, which this asset had to invent for the demo (`GRID_COLS 4 · GRID_ROWS 2 · TILE_W 48 · TILE_H 64`).\n\nThis asset is a **contrast build, not a single demo**: it renders the same six icons twice, side by side, driven by one shared width slider. The left stage keeps the legacy pixel-coordinate model; the right stage keeps the grid-coordinate model. Without the pixel side next to it, the grid side just looks like \"icons that stayed put\" — the value only becomes visible when the two are narrowed together and one breaks. At minimum stage width, the pixel side measurably overflows (two icons pushed outside the stage bounds); the grid side stays inside its cells because the cell width is recomputed by the grid track, not stored on the icon.\n\n## Anatomy\n\n- Position model is the whole idea: pixel side stores `{ x, y }` absolute pixels computed once from a slot; grid side stores `{ col, row }` and never touches pixels except transiently while dragging.\n- `slotToPixel` converts the same six default slots (`DEFAULT_SLOTS`, 4×2 grid, filled in reading order) into both a fixed pixel position (left) and a grid coordinate (right) at mount — both stages start from an identical visual layout.\n- The grid stage sets `gridTemplateColumns: repeat(4, 1fr)` / `gridTemplateRows: repeat(2, 1fr)` on the stage `div`; each icon's placement is `gridColumnStart` / `gridRowStart` read straight off state. Resizing the stage changes the `1fr` track width, not the stored coordinate.\n- `STAGE_MIN_WIDTH` (192px = `GRID_COLS * TILE_W`) is a deliberate floor — below it the grid side's own cells would be narrower than an icon tile, which would confound the comparison. The slider never goes below that floor.\n- One shared `<input type=\"range\">` drives both stages' `width` style, so the same physical narrowing event is applied to the pixel and grid models simultaneously.\n\n## States\n\n- Idle: icons sit at their stored positions; both stages render identically at `STAGE_MAX_WIDTH`.\n- Dragging (pixel side): pointer capture on the tile; position updates as raw `clientX/clientY` delta added to the drag origin, clamped to `[0, REFERENCE_WIDTH - TILE_W]` — the clamp uses the *reference* (max) width, not the current narrowed width, so a dragged pixel icon can still end up positioned past a narrowed stage's visible edge.\n- Dragging (grid side): pointer capture on the tile; a ghost render (`position: absolute`, tracking the raw pointer) replaces the grid-placed style while dragging so the icon can move freely between cells.\n- Drop (grid side): pointer position is converted to a cell index via `Math.floor(pointerOffset / cellWidth)`, clamped to `[0, GRID_COLS - 1]`, and only that integer `{ col, row }` is persisted — the pixel position used during the drag is discarded.\n- Narrowed stage: pixel side keeps stale absolute coordinates (icons visually clip or exit the stage); grid side's tracks shrink and icons stay centered in their assigned cell.\n\n## Variants\n\n- Grid dimensions, tile size, and stage bounds (`GRID_COLS/ROWS`, `TILE_W/H`, `STAGE_MIN/MAX_WIDTH`) are invented for this demo, not observed — daedalOS's source declares only the coordinate shape. Any concrete count/size works as long as `STAGE_MIN_WIDTH >= GRID_COLS * TILE_W`.\n- A single-stage (grid-only) variant is valid for production use; the pixel stage exists here only to make the contrast observable, not as a recommended pattern in itself.\n- Row/column count could scale from a container query instead of a fixed slider-driven width for a real desktop-icon surface; the slider is a demo instrument, not part of the technique.\n\n## Code\n\n```tsx\n// 격자 좌표만 저장 — 실제 칸 폭은 CSS Grid 의 1fr 트랙이 무대 폭에 맞춰 계산한다.\nconst style = isDragging\n ? { position: \"absolute\" as const, left: gridGhost!.x, top: gridGhost!.y, zIndex: 10 }\n : { gridColumnStart: gridPos[id].col, gridRowStart: gridPos[id].row }\n\n// 드롭 시점에만 픽셀 → 칸 인덱스로 변환하고, 이후로는 정수 좌표만 보관한다.\nconst cellW = rect.width / GRID_COLS\nconst cellH = STAGE_HEIGHT / GRID_ROWS\nconst col = clamp(Math.floor((e.clientX - rect.left) / cellW), 0, GRID_COLS - 1) + 1\nconst row = clamp(Math.floor((e.clientY - rect.top) / cellH), 0, GRID_ROWS - 1) + 1\nsetGridPos((prev) => ({ ...prev, [drag.id]: { col, row } }))\n```\n\n## Checks\n\n- Persisted state for each icon must be an integer cell index (`col`/`row`), never a pixel pair, once the drag ends — check the drop handler discards the ghost's pixel position.\n- The grid stage's `gridTemplateColumns`/`gridTemplateRows` must use `fr` units (or another container-relative unit), not fixed pixel tracks — fixed tracks reintroduce the pixel-storage failure mode inside the grid model.\n- `STAGE_MIN_WIDTH` (or equivalent floor) must keep each track at least as wide as a tile; below that floor the grid side degrades too and the technique's value stops being demonstrable.\n- Same DOM node stays mounted through a drag (style swaps between grid-placement and absolute-ghost) so `setPointerCapture` isn't broken by a node replacement.\n\n## Anti-patterns\n\n- Storing `{ x, y }` pixels \"for now\" with a plan to migrate to grid coordinates later. The failure only shows up at a narrower viewport than the one it was authored in, so it ships invisibly and surfaces as a support report, not a build error.\n- Clamping a dragged position against the stage's current width instead of leaving the coordinate unit-less (a cell index has no notion of \"current width\" to clamp against — that's the point).\n- Recomputing pixel positions on every resize as a patch instead of switching the storage unit. A resize listener that repositions pixel icons on breakpoint changes is a workaround for the anti-pattern, not the pattern itself — it still stores pixels between resizes and drifts under continuous resize (e.g. a draggable panel divider).\n\n## Agent notes\n\n- prompt_phrases: \"desktop icon grid that survives resize\", \"icons stay in their cell when the window shrinks\", \"grid-coordinate drag and drop\", \"icon positions that don't break on narrow screens\".\n- fallbacks: if CSS Grid isn't available, the same idea works with flex-basis percentages computed from `col / GRID_COLS` — any layout primitive where cell size is a function of container size, not a stored constant, preserves the property.\n- Cross-reference `knowledge/adjacency-level-lod.md`: same family of judgment — anchoring a rule to grid/topological coordinates rather than screen coordinates makes it invariant to viewport, zoom, or resize changes. That document applies the rule to resolution-tier selection by grid distance; this recipe applies it to stored element position. Both reject \"pixels\" as the unit of truth once the surface can change size.\n\n## Changelog\n\n- 2026-08-17: M60 회차 6 — daedalOS(`contexts/session/types.ts`) 관측을 대조 구현(픽셀 vs 격자)으로 코드화, `lab/` → `components/` 승격."},{"id":"rail-measured-line","name":"Rail Measured Line","pattern_group":"layout","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites","components-primitives"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.border.focus","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/rail-measured-line.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/layout/rail-measured-line.md","body":"## Intent\n\nA rail measured line is a hairline that extends until it touches a specific element on the page and stops exactly there, at every viewport width, because its length is measured at runtime instead of being written as a percentage or a `calc()` constant. The line owns one CSS transform, `scaleX`, and the layout supplies the number. Observed on inspiring.nk.studio (research/m58-round4/p2-nk-studio.json rows[14], id r4-15), read directly out of chunk `0yw007.vb_n7k.js` module 77340: `measureBottomIntroLineScaleToRails`, `applyIntroBottomLineAtRails`, `resetIntroBottomLineEntrance`, `setIntroBottomLineToEdge`, and the constant `INTRO_BOTTOM_LINE_EXTEND_DURATION_S = 0.75`. The same single line carries two lengths there: during the intro it stops at the rail, and once the page is revealed the rail constraint is dropped and it runs to the edge.\n\n## Anatomy\n\n- A positioned parent whose width is the denominator of the measurement.\n- A rail: the element the line must reach. In the original it is the vertical guide of the intro grid; in the demo it is a `1px` column whose right inset is draggable from `12px` to `220px`.\n- The line itself: a full width hairline that is only ever scaled, never resized, with `transform-origin: left center`.\n- The measurement, taken exactly as observed: `railRight = rail.getBoundingClientRect().right`, then `scaleX = (railRight - line.getBoundingClientRect().left) / parent.getBoundingClientRect().width`, clamped to `0` when the span is negative.\n- A rail whose computed `display` is `none` is skipped and the line keeps a scale of `1`, which is what stops a responsively hidden rail from measuring zero.\n- The extension is a `transform` transition of `0.75s` on `cubic-bezier(0.38, 0.02, 0.58, 1)`, both taken from the source.\n- Re-measurement is driven by a `ResizeObserver` on the parent plus a `resize` listener, so the line follows layout changes that no media query announced.\n\n## States\n\n- Intro: `scaleX` is the measured ratio and the line ends on the rail.\n- Revealed: the rail constraint is dropped, `scaleX` is `1`, and the line spans the parent.\n- Reflow: the observer fires and the line re-scales to the new rail position, using the same transition.\n- Rail hidden: the measurement is skipped and the line stays at full length rather than collapsing.\n- Reduced motion: the target scale is applied without the transition, since the endpoint carries the meaning and the travel does not.\n\n## Variants\n\n- Anchor edge: measuring `right` reaches the far side of the rail, measuring `left` stops just before it. Pick one and keep it, because mixing them across a page reads as misalignment.\n- Axis: the same measurement transposed to `scaleY` gives a vertical connector to a horizontal baseline.\n- Multiple lines against one rail: measure each line separately. They share a rail but not a starting `left`, so they do not share a ratio.\n\n## Code\n\n```tsx\n// 원본 measureBottomIntroLineScaleToRails 와 같은 계산\nconst measure = () => {\n const parent = parentRef.current\n const line = lineRef.current\n const rail = railRef.current\n if (!parent || !line || !rail) return 1\n // 반응형으로 숨은 레일에서 0 을 재지 않는다\n if (window.getComputedStyle(rail).display === \"none\") return 1\n const railRight = rail.getBoundingClientRect().right\n const parentWidth = parent.getBoundingClientRect().width\n if (parentWidth <= 0) return 1\n const span = railRight - line.getBoundingClientRect().left\n return span <= 0 ? 0 : span / parentWidth\n}\n\n// 관측 상수: 확장 0.75s\nconst EXTEND_DURATION_S = 0.75\nconst EASE = \"cubic-bezier(0.38, 0.02, 0.58, 1)\"\n\n<div\n style={{\n transformOrigin: \"left center\",\n transform: `scaleX(${toEdge ? 1 : measure()})`,\n transition: `transform ${EXTEND_DURATION_S}s ${EASE}`,\n }}\n/>\n```\n\n## Checks\n\n- Measure the line while it is at scale `1`. Reading `getBoundingClientRect()` on an already scaled line feeds the previous scale back into the ratio and the length drifts on every reflow.\n- Guard `parentWidth <= 0`. A parent that is still zero wide on first layout produces `Infinity` or `NaN` and the line disappears with no error.\n- Guard the negative span. When the rail sits left of the line the raw ratio is negative, and a negative `scaleX` flips the line back across its origin instead of hiding it.\n- Skip rails whose computed `display` is `none`. This is the responsive failure the original code guards against explicitly.\n- Keep `transform-origin: left center`. With the default center origin the line grows in both directions and never lands on the rail.\n- Re-measure after webfonts settle. A rail positioned by text metrics moves when the font swaps, and nothing else fires at that moment.\n- Animate `transform` only. Transitioning `width` on the same hairline costs layout on every frame for an identical result.\n\n## Anti-patterns\n\n- Writing the length as a percentage or a `calc()` constant tuned at one breakpoint. That is the exact defect this pattern exists to remove, and it reappears at every width nobody tested.\n- Measuring once on mount and never again. The rail moves with container queries, sidebar toggles, and scrollbar appearance, none of which are window resizes.\n- Measuring on every scroll or animation frame. `getBoundingClientRect()` forces layout, so the reads belong on observers and on the transition endpoints.\n- Reusing one measured ratio for several lines that start at different `left` positions. The denominator is shared but the numerator is not.\n- Stretching the duration well past the observed `0.75s`. A hairline that takes seconds to arrive stops reading as an alignment cue and starts reading as a loading state.\n\n## Agent notes\n\n- prompt_phrases: \"a line that extends exactly to that element and stops\", \"hairline connector that stays aligned at every breakpoint\", \"intro rule that grows to the grid rail then runs full width\".\n- fallbacks: without `ResizeObserver`, re-measure on `resize` and on the transition end. With reduced motion, apply the measured scale with no transition. If the rail cannot be resolved at all, leave the line at scale `1` rather than at `0`, so a broken measurement degrades to a full rule instead of an invisible one."},{"id":"reference-viewport-sizing","name":"Reference Viewport Sizing","pattern_group":"layout","kind":"visual-treatment","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m66","surface_refs":["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","dimension.radius.md","dimension.size.touch-target-min"],"mobile":{"platforms":{"ios":"none","android":"none"},"gestures":["tap"],"touch_targets":["nested hit area"],"safe_area":"none","reduced_motion":"none","modal_depth":0,"port_notes":"`var(--vh)` 공식은 RN `useWindowDimensions`·Flutter `MediaQuery.size` 비례 계산으로 바뀐다 — `min()` 클램프를 빠뜨리면 큰 화면에서 무한 성장한다. 시각 크기와 히트 영역을 분리하는 중첩 규칙은 그대로 옮겨진다"},"code_asset":"examples/ui-vocabulary-site/src/components/reference-viewport-sizing.tsx","component_refs":[],"term_refs":["dynamic-viewport-unit","fluid-layout","responsive-layout","breakpoint","button"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/layout/reference-viewport-sizing.md","body":"## Intent\n\nA reference-viewport sizing system picks a single design viewport (height 736px in the observed origin) and writes every dimension as `min(<design px>, calc((N / 7.36) * var(--vh)))`. Above the reference the value is pinned to the design pixel; below it, every dimension shrinks by the same ratio, so the composition never reflows into a different layout. Responsiveness becomes one formula plus one constant instead of a breakpoint table. Two companions travel with it: buttons keep a stable border weight and radius by drawing the outline with `box-shadow` instead of `border`, and touch targets are decoupled from visual size by nesting a 44x44 hit area inside small controls. Observed at Spotify Wrapped Party (https://wrapped-party.activetheory.dev) — ledger rows wrapped#16 · wrapped#17 · wrapped#18 in `research/m66-round8/p2-ledger.json`, disposition in `research/m66-round8/disposition.json`; user gate pass frozen in `research/2026-08-17-m66-gate-verdicts.md`.\n\n## Anatomy\n\n- **Reference constant** — one number (the design viewport height) divided into every dimension. In the observed origin all 53 coefficients resolve to the same divisor, which is what makes the system readable.\n- **Size expression** — `min(px, calc((N / divisor) * var(--vh)))`. The `min()` is the clamp; without it the layout grows unboundedly on tall screens.\n- **Button layer stack** — label, a separate background layer, and an outline drawn as `box-shadow` so thickness and radius stay constant across size steps.\n- **Hit area** — an absolutely positioned child sized to the touch-target minimum, centered on the visual control.\n\n## States\n\n- **Above reference** — every dimension is pinned to its design pixel value. Widening the window changes nothing.\n- **Below reference** — all dimensions scale by the same ratio; relative proportions are preserved exactly.\n- **Pressed** — the button background layer reacts without moving the label, because the outline is a shadow rather than a border.\n\n## Variants\n\n- **Reference axis** — the observed origin keys off viewport *height* because it is a full-screen sequential experience. A scrolling page usually keys off width; the formula is identical.\n- **Unclamped** — dropping `min()` yields the common \"pure vh\" approach, which the demo shows side by side: text keeps growing on large screens.\n- **Hit area visibility** — the demo can outline hit areas for inspection; production keeps them invisible.\n\n## Code\n\n전체 구현은 `code_asset` 이 정본이다. 핵심만 발췌한다 — 이 패턴을 특징짓는 것은 분량이 아니라 아래 규칙 한 줄이다.\n\n```tsx\n// Reference Viewport Sizing — 핵심 규칙\n// 구현 전문: examples/ui-vocabulary-site/src/components/reference-viewport-sizing.tsx\n// 관측 파라미터의 출처: research/m66-round8/p2-ledger.json (wrapped#16 · wrapped#17 · wrapped#18)\n```\n\n## Checks\n\n- Above the reference viewport, no dimension changes.\n- Below it, two arbitrary dimensions keep the same ratio to each other.\n- Every interactive control contains a hit area of at least the touch-target minimum, even when the visual control is smaller.\n- Button outline thickness and radius are identical across all size steps.\n\n## Anti-patterns\n\n- **Mixing the system with breakpoint overrides.** Once one dimension is overridden at a media query, the single-formula guarantee is gone and nobody can predict the layout from the constant.\n- **Scaling the touch target with the visual size.** The whole point is that the hit area stops shrinking when the icon does.\n- **Using `border` for the outline.** Border participates in layout, so switching size steps shifts the label by a pixel and the buttons stop looking like one family.\n- **Choosing the reference viewport from the designer's monitor.** It has to be the smallest screen the design is still correct on, not the largest.\n\n## Agent notes\n\nprompt_phrases: 기준 뷰포트 하나로 전체 치수를 잡아줘 · 큰 화면에서는 안 커지고 작은 화면에서만 줄어드는 반응형으로 · 아이콘 버튼 히트 영역을 44px 로 분리해줘\n\nfallbacks: `var(--vh)` 를 쓰려면 뷰포트 높이를 JS 로 주입해야 하는 환경(모바일 브라우저 주소창)이 있다. 그 경우 `dvh` 단위로 대체하고, 지원하지 않으면 `vh` 로 떨어뜨린다. 기준 뷰포트를 못 정하겠으면 이 자산을 쓰지 말고 일반 브레이크포인트로 간다 — 어중간하게 섞으면 둘 다 잃는다."},{"id":"sidebar-application-shell","name":"Sidebar Application Shell","pattern_group":"layout","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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- Grouped destinations: an item with sub-destinations is a disclosure (`aria-expanded` + `aria-controls`), not a link. Its chevron rotates rather than swapping glyphs, so the control reads as the same object in both states.\n- Sub-list: indented against a rule, not by padding alone — the line is what still says \"these belong to the group above\" once the header has scrolled out of view.\n- Per-item counts: optional trailing numerals on sub-items, tabular so the column stays straight.\n- Active item: one unmistakable selected state, not a second primary CTA. A collapsed group holding the active sub-item carries that state itself, so the current location is never invisible.\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- Group open/closed: closed by default, except a group holding the active sub-item, which starts open so arriving on a nested destination never lands the reader on a collapsed tree.\n- Collapsed rail + group click: the rail expands first and then opens the group. A collapsed rail has nowhere to put a sub-list, and doing nothing on click is indistinguishable from a broken control.\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":"window-placement-cascade","name":"Window Placement Cascade","pattern_group":"layout","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m60","surface_refs":["saas-dashboards","internal-tools"],"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","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/window-placement-cascade.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-17","source_path":"recipes/layout/window-placement-cascade.md","body":"## Intent\n\nWindow placement cascade decides where a newly opened window lands when several windows of the same kind open in a row. Opening them stacked exactly on top of each other reads as \"only one window opened\" even though several exist, so each new window is pushed diagonally from the last one, forming a visible staircase that keeps every window's title bar reachable. When the next diagonal step would push a window off the stage, the cascade gives up and restarts from the center instead of clipping the window or wrapping it back to a corner. That center is not the stage's geometric center — it is the center of the area left over after excluding the taskbar strip at the bottom, so the visual center and the geometric center diverge on purpose. Observed in daedalOS (`components/system/Window/functions.ts`, MIT, HEAD `0df82d7`, https://dustinbrett.com) — `research/m60-round6/p2-daedalos.json` r6-08 (cascade offset + offscreen bailout), r6-09 (taskbar-aware center), r6-10 (min/max size clamp with aspect-preserving scale-down).\n\n## Anatomy\n\n- A stage area (the desktop) with a taskbar strip pinned along its bottom edge.\n- Each window is a rectangle with a title bar (label + close control) and a body.\n- New windows enter either by cascading from the previous window's position or by centering in the available (non-taskbar) area.\n- A checkbox / mode toggle lets the next window request a size larger than the stage, exercising the clamp path.\n\n## States\n\n- **No windows open**: stage shows an empty-state hint (placeholder text) inviting the first window.\n- **Cascading**: each new window sits `CASCADE_OFFSET` px right and down from the previous one.\n- **Cascade exhausted**: the next diagonal step would push the window off-stage — placement falls back to the taskbar-aware center instead.\n- **Oversized request**: the requested size exceeds the stage; width and height are scaled down together (never independently) so the aspect ratio holds, then clamped to a minimum floor.\n\n## Variants\n\n- Default request size vs. an oversized request that forces the clamp path — both are demonstrated by the same toggle rather than as separate components.\n- The reference implementation reads viewport and app-defined defaults; this recipe hardcodes representative constants (`CASCADE_OFFSET`, default/oversized request sizes) for reproducibility since there is no real desktop environment to source them from.\n\n## Code\n\n```tsx\n/** Taskbar-aware center — not the stage's geometric center. */\nfunction centerPosition(size: WindowSize, stage: StageSize): WindowPosition {\n return {\n x: stage.width / 2 - size.width / 2,\n y: (stage.height - TASKBAR_HEIGHT) / 2 - size.height / 2,\n }\n}\n\n/** Diagonal cascade from the previous window; give up (undefined) if the next step goes offscreen. */\nfunction cascadePosition(prev: WindowPosition, size: WindowSize, stage: StageSize): WindowPosition | undefined {\n const x = prev.x + CASCADE_OFFSET\n const y = prev.y + CASCADE_OFFSET\n const isOffscreen = x + CASCADE_OFFSET + size.width > stage.width || y + CASCADE_OFFSET + size.height > stage.height\n if (isOffscreen) return undefined\n return { x, y }\n}\n\n// caller: cascade first, fall back to center when cascade bails out\nconst position = prev\n ? cascadePosition(prev, size, stageSize) ?? centerPosition(size, stageSize)\n : centerPosition(size, stageSize)\n```\n\n## Checks\n\n- The center calculation must subtract the taskbar height before halving, not after placing the window. Halving the full stage height and then subtracting taskbar height from the result produces a different (wrong) y.\n- Cascade offscreen detection must check the *next* step (`x + offset`, `y + offset`), not the current window's own position — otherwise the last window before the edge still gets pushed off-stage.\n- Size clamping must compute one shared scale factor from `min(scaleForWidth, scaleForHeight)` and apply it to both axes. Clamping width and height independently breaks the aspect ratio.\n- Minimum size floors (`MIN_WINDOW_WIDTH`/`MIN_WINDOW_HEIGHT`) must be applied after the aspect-preserving scale-down, not before, or a small stage can still yield a window smaller than the floor.\n\n## Anti-patterns\n\n- Stacking new windows at an identical position with no offset — with more than one window open, the extras are invisible until the top one moves or closes.\n- Centering on the stage's full geometric center when a persistent taskbar/toolbar occupies part of the stage. The window ends up visually low or high relative to the actually-usable area.\n- Clamping width and height against separate scale factors. This is the most common corruption: it silently distorts the window's proportions instead of shrinking it uniformly.\n- Wrapping the cascade back to the top-left corner instead of centering when it runs out of room. Corner-wrapping starts overlapping earlier windows again, defeating the purpose of cascading.\n\n## Agent notes\n\n- prompt_phrases: \"cascade new windows\", \"stagger window placement\", \"desktop window manager open position\", \"center a window excluding the taskbar\", \"diagonal window stacking\".\n- fallbacks: on a stage with no taskbar/toolbar, `TASKBAR_HEIGHT` can be `0` and centering reduces to the plain geometric center — the same two functions still apply."},{"id":"ambient-audio-mixer","name":"Ambient Audio Mixer","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["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.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/ambient-audio-mixer.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/ambient-audio-mixer.md","body":"## Intent\n\nAn ambient audio mixer crossfades two looping Web Audio layers by a spatial position (a marker dragged over a map) instead of a volume slider, and ducks the master gain when foreground UI opens. Position becomes presence: dragging toward one side is dragging toward that place's sound. Observed at summer-afternoon.vlucendo.com (research/m55-round2/p2-summer-afternoon.json rows 4 and 5); user gate pass frozen in research/2026-08-15-m55-gate-verdicts.md section 2 (round-2 new, pass).\n\n## Anatomy\n\n- `AudioContext` is created inside the click handler that starts sound, not on mount, so the browser's autoplay gesture requirement is satisfied.\n- Two looping buffer sources share one 2-second white-noise buffer (`ctx.sampleRate * 2` samples), each routed through its own lowpass `BiquadFilterNode` into its own `GainNode`, both summed into a shared master `GainNode` to `ctx.destination`.\n- Forest layer: lowpass at 300Hz, static cutoff, gain `1 - markerX`.\n- Beach layer: lowpass at 800Hz, cutoff modulated by an LFO (0.15Hz oscillator through a gain of 400) so the filter breathes like waves, gain `markerX`.\n- Marker drag (pointerdown/pointermove/pointerup on window) maps clientX to a 0..1 ratio via the map's bounding rect; that ratio drives both gains with `setTargetAtTime(target, now, 0.05)` for a short exponential glide instead of an instant jump.\n- Panel open/close ducks `masterGain` between `NORMAL_VOLUME` (1) and `DUCK_VOLUME` (0.4) via a GSAP tween on a plain `{ value }` proxy object (`onUpdate` writes into `masterGain.gain.value`), `duration: 1.35`, `ease: \"power1.inOut\"`.\n\n## States\n\n- Not started: no `AudioContext` exists yet; only the \"사운드 시작\" (start sound) button is interactive.\n- Started, panel closed: both loops playing, master gain at `NORMAL_VOLUME`, marker draggable.\n- Dragging: `draggingRef` true, gains glide toward the marker ratio on every pointermove.\n- Panel open: master gain ducked to `DUCK_VOLUME` over the GSAP tween; reduced-motion skips the tween and snaps the value directly.\n\n## Variants\n\n- More than two layers: extend the same pattern to N filtered noise (or sample) sources with gains driven by a 2D position instead of a 1D ratio.\n- Duck target/duration tuning: any UI overlay opening (modal, drawer, video) can trigger the same duck tween, not just this panel.\n- Swap noise buffers for decoded audio files (fetch + `decodeAudioData`) while keeping the gain-crossfade and duck logic unchanged.\n\n## Code\n\n```tsx\nconst handlePointerMove = (e: PointerEvent) => {\n if (!draggingRef.current) return;\n updateFromEvent(e.clientX);\n};\n\nuseEffect(() => {\n if (!started || !audioCtxRef.current) return;\n const now = audioCtxRef.current.currentTime;\n forestGainRef.current?.gain.setTargetAtTime(1 - markerX, now, 0.05);\n beachGainRef.current?.gain.setTargetAtTime(markerX, now, 0.05);\n}, [markerX, started]);\n\n// duck on panel open, 1.35s power1.inOut, skipped under reduced motion\ntweenRef.current = gsap.to(volumeProxyRef.current, {\n value: target,\n duration: DUCK_DURATION,\n ease: \"power1.inOut\",\n onUpdate: () => {\n if (masterGainRef.current) {\n masterGainRef.current.gain.value = volumeProxyRef.current.value;\n }\n },\n});\n```\n\n## Checks\n\n- `AudioContext` is constructed only inside the start-gesture click handler, never on mount or in an effect.\n- `prefers-reduced-motion` is read before ducking; matched users get an instant gain set instead of the GSAP tween.\n- Unmount cleanup kills the active tween, stops and disconnects every tracked source node (wrapped in try/catch since a node may already be stopped), and closes the `AudioContext`.\n- Pointer listeners (`pointerdown` on the map, `pointermove`/`pointerup` on window) are removed in the same effect's cleanup that added them.\n- Gain changes use `setTargetAtTime`, not direct `.value` assignment, avoiding zipper-noise clicks on crossfade.\n\n## Anti-patterns\n\n- Creating the `AudioContext` outside a user gesture: most browsers start it suspended and silent, with no visible error.\n- Assigning `gain.value` directly on every pointermove instead of `setTargetAtTime`: produces audible clicking as the value steps.\n- Driving the duck with a CSS transition on an unrelated DOM property instead of tweening the actual `GainNode` value: the number and the audible volume drift apart.\n- Skipping the reduced-motion branch for the duck tween: users who disabled motion still get an animated (if inaudible visually) volume ramp tied to the same easing curve as visual motion.\n\n## Agent notes\n\n- prompt_phrases: \"ambient sound mixed by cursor position\", \"two audio loops crossfade by drag\", \"background audio ducks when panel opens\".\n- fallbacks: if Web Audio is unavailable or the user never triggers the start gesture, the component must remain a normal silent visual (map + draggable marker) with no broken audio calls; ducking then simply has nothing to do."},{"id":"ascii-particle-logo","name":"ASCII Particle Logo","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/ascii-particle-logo.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/ascii-particle-logo.md","body":"## Intent\n\nAn ASCII particle logo turns a static wordmark into a field of single-character particles that react to the visitor. The glyph shape is sampled once into particle target coordinates, the cursor pushes nearby particles away, and a click (or the first time the logo is 80% on screen) drops every particle under gravity and reassembles the word in staggered ease-out. The point is that the mark stops being an image and becomes something the visitor can disturb. Observed on Revelatio Studio's footer logo and captured in three independent rows: alpha sampling (research/m56-round3/revelatio-deep.json d3[12]), magnetic repulsion (d3[13]), and the fall/reassemble state machine (d3[14], converging with d3[18]). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- `sampleTargets()` draws the wordmark into an offscreen canvas, then walks a `SAMPLE_STEP_DEMO = 6px` grid reading only the alpha channel; every cell above `ALPHA_THRESHOLD = 50` becomes a particle target snapped to `round(v / UNIT) * UNIT`.\n- Fill and stroke on that offscreen pass are irrelevant colors: nothing from it is painted. `strokeText` with `lineWidth = 8` thickens the strokes so the grid catches enough cells (548 particles measured, against 91 before the thickening).\n- The visible canvas is drawn every rAF frame by `draw()`, which reads `--foreground` and `--muted-foreground` through `resolveTokenRgb()` so the particles follow the consuming project's theme instead of a baked color.\n- A single `step()` loop switches on `phaseRef.current`: `scattered` and `formed` share the idle branch (drift plus magnetic repulsion), `falling` integrates gravity, `forming` interpolates toward the target.\n- Magnetic repulsion: `force = (1 - dist / MAGNETIC_RADIUS)^2 * MAGNETIC_STRENGTH`, applied along the particle-to-pointer vector, then eased back with `MAGNETIC_RETURN = 0.08` lerp. Above `SWAP_THRESHOLD = 0.65` a particle has a `SWAP_CHANCE = 0.06` chance per frame of flashing a random character in muted color.\n- An `IntersectionObserver` with `threshold: [0, 0.25, 0.5, 0.75, 0.8]` fires the sequence once at `intersectionRatio >= 0.8`; a click fires it too and disables the auto trigger.\n\n## States\n\n- `scattered`: particles sit at random positions drifting at `DRIFT_SCATTERED = 0.05`, still repelled by the pointer.\n- `falling`: gravity `0.7` per frame, floor bounce at `vy *= -0.25` and `vx *= 0.8`, held for `FALL_DURATION_MS = 1200`.\n- `forming`: each particle waits `(i % 20) / 20 * FORM_DURATION_MS * 0.5` then eases to its target with `1 - (1 - t)^3` over `FORM_DURATION_MS = 1100`.\n- `formed`: the word is legible, drift drops to `DRIFT_NEAR = 0.03`, pointer repulsion still applies.\n- Reduced motion: particles are placed straight onto their targets and the rAF loop never starts.\n\n## Variants\n\n- Source shape: sampling text glyphs (this demo) or an SVG logo drawn into the offscreen canvas (the observed original). The sampling code is identical either way; only the draw call before `getImageData` changes.\n- Density: `SAMPLE_STEP_DEMO` trades legibility against particle count. Wider steps need a heavier stroke pass or the word stops reading.\n- Character pool: `CHAR_POOL` is the texture decision. A digits-only pool reads technical, a symbol-heavy pool reads noisy.\n- Trigger: click only, viewport only, or both (this demo uses both, with the viewport pass firing at most once).\n\n## Code\n\n```tsx\nconst targets: { x: number; y: number }[] = [];\nfor (let gy = 0; gy < CANVAS_H; gy += SAMPLE_STEP_DEMO) {\n for (let gx = 0; gx < CANVAS_W; gx += SAMPLE_STEP_DEMO) {\n const alpha = data[(gy * CANVAS_W + gx) * 4 + 3];\n if (alpha > ALPHA_THRESHOLD) {\n targets.push({\n x: Math.round(gx / UNIT) * UNIT,\n y: Math.round(gy / UNIT) * UNIT,\n });\n }\n }\n}\n```\n\n## Checks\n\n- Draw one frame synchronously before starting the rAF loop, otherwise the canvas flashes empty on mount.\n- Read the theme colors inside `draw()`, not once at setup: reading them once bakes the theme that happened to be active at mount and the logo stops following a theme toggle.\n- The offscreen sampling canvas must be sized identically to the display canvas, or the snapped targets land off the visible area.\n- Cancel the rAF and remove the pointer listeners plus the observer on unmount.\n- Check the particle count after any change to font size, stroke width, or sample step. A word that samples into fewer than a few hundred particles is not readable as a word.\n\n## Anti-patterns\n\n- Sampling with a thin `fillText` only: the grid passes between strokes and the wordmark dissolves into unreadable dust. Thicken with `strokeText` before sampling.\n- Drawing the particles in a muted color as the base state: the silhouette loses contrast against the surface and the mark reads as noise. Muted belongs to the transient character-swap flash, not the resting state.\n- Storing pointer position in React state: this runs a re-render per `pointermove` while an rAF loop is already writing to the canvas.\n- Re-triggering the fall sequence on every scroll pass: the observer must latch, or the logo never settles long enough to be read.\n\n## Agent notes\n\n- prompt_phrases: \"logo made of ASCII characters that scatter from the cursor\", \"wordmark particles fall and reassemble\", \"canvas text particle logo\".\n- fallbacks: with reduced motion, place particles on their targets and skip the loop entirely so the word is simply legible. On touch surfaces there is no pointer to repel with, so keep the click-triggered fall as the whole interaction."},{"id":"attr-declared-cursor-modes","name":"Attribute Declared Cursor Modes","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites","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.accent.base","dimension.radius.lg","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/attr-declared-cursor-modes.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/attr-declared-cursor-modes.md","body":"## Intent\n\nA custom cursor made of two parts that chase the pointer at two different speeds, whose reaction targets are declared on the markup rather than registered in JavaScript. The two speeds are the point: the dot leads and the ring trails, so during motion the ring smears behind the dot and only re-centers on it once the pointer settles. That lag is what reads as weight. The declarative half matters just as much for a single page app, because the cursor never holds a list of elements it knows about. An element opts in by carrying `data-event`, and a `MutationObserver` re-scans after the DOM is swapped, so route changes and lazily rendered sections need no registration call. Reverse engineered from inspiring.nk.studio (chunk `0mrsm3pryz__1.js` cursor component plus CSS module `b.css` `.pointer` / `.parent` / `.text` / `.slash` / `.textPlacementLeft`), recorded as ledger rows `r4-10` and `r4-11` in `research/m58-round4/p2-nk-studio.json` rows[9][10]. Promoted to canon in M59 after passing the user observation gate, with the origin palette dropped and every color remapped onto project tokens.\n\n## Anatomy\n\n- Two followers share one pointer target and two lerp factors, observed as `0.075` for the dot and `0.06` for the ring. Both run in a single `requestAnimationFrame` loop and write `transform` only.\n- The dot is `4x4px`, accent colored, on `z-index 161`. The ring is `36x36px` at rest with `backdrop-filter: blur(4px)`, a translucent surface scrim, and a `1px` border, on `z-index 160`.\n- Ring transitions are split by property, exactly as observed: `width`, `height`, and `opacity` at `0.4s`, background and border color at `0.35s`, all on `cubic-bezier(0.64, 0.1, 0, 1)`.\n- Ring diameter encodes the mode: `36px` idle, `56px` hover, `72px` photo. On photo the blur goes `4px` to `8px` and the scrim turns opaque.\n- The dot has a slash variant: on hover it becomes `2x10px` with `border-radius: 0` (the origin also skews it `-30deg`).\n- The label rides the ring position and reads its side from `data-text-placement`. Default sits to the right, `left` placement flips it to the other side of the ring.\n- Declaration surface is three attributes: `data-event=\"hover\" | \"photo\"`, `data-text`, and `data-text-placement`.\n- Rebinding is a `MutationObserver` with `{ childList: true, subtree: true, attributes: true, attributeFilter: ['data-event','data-text','data-text-placement'] }` and a `100ms` debounce. Already bound elements are kept in a `WeakSet` so a re-scan never double binds.\n- The origin ships this on desktop only, gated at `min-width: 1024px`.\n- Instrumentation: the root publishes `data-lag-px` (live distance between dot and ring) and `data-bound-count`, which is how the two-speed claim and the rebinding claim are checked without eyeballing.\n\n## States\n\n- Idle: small ring, round dot, both centered on the pointer once it stops.\n- Moving: the ring falls behind by a distance proportional to pointer speed, then catches up. This is the state the pattern exists for.\n- Hover: ring grows to `56px`, dot morphs into the slash, label fades in with the target's `data-text`.\n- Photo: ring grows to `72px`, blur doubles, the scrim turns opaque so the ring reads as a lens over the image.\n- Outside the stage: both followers drop to `opacity: 0` and the mode resets to idle.\n- Newly mounted targets: unbound until the debounce fires, then indistinguishable from targets that were present at mount.\n\n## Variants\n\n- Speed spread: the closer the two lerp values, the less lag. Equal values collapse the pattern into an ordinary ring cursor. The observed `0.075` and `0.06` are a roughly 25 percent spread.\n- Dot morph: the slash is one option. A dot that only scales still works, but it loses the directional read the slash gives.\n- Label placement: right by default, `left` for targets sitting near the right edge, which is what the origin uses it for.\n- Scope of the observer: the origin watches `body`. Scoping it to a stage container keeps the cost bounded when the cursor only owns one region.\n\n## Code\n\n```tsx\n// 두 속도 — 이 커서의 정체는 여기다\nconst DOT_LERP = 0.075\nconst RING_LERP = 0.06\n\ndotX = lerp(dotX, targetX, DOT_LERP)\nringX = lerp(ringX, targetX, RING_LERP)\n\n// 선언형 바인딩: 코드가 대상을 알지 않는다. 요소가 data-event 를 달면 그만이다\nconst bindAll = () => {\n for (const el of stage.querySelectorAll<HTMLElement>(\"[data-event]\")) {\n if (bound.has(el)) continue // WeakSet — 재스캔이 중복 바인딩하지 않게\n bound.add(el)\n el.addEventListener(\"pointerenter\", () => setMode(el.dataset.event === \"photo\" ? \"photo\" : \"hover\"))\n el.addEventListener(\"pointerleave\", () => setMode(\"idle\"))\n }\n}\n\n// 100ms 디바운스 — 렌더 폭풍에서 재바인딩이 수십 번 돌지 않도록\nconst observer = new MutationObserver(() => {\n clearTimeout(debounce)\n debounce = window.setTimeout(bindAll, 100)\n})\nobserver.observe(stage, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\"data-event\", \"data-text\", \"data-text-placement\"],\n})\n```\n\n## Checks\n\n- Verify the lag is real. Move fast and read `data-lag-px`: it should climb well above zero during motion and return to near zero within a few hundred milliseconds after stopping. If it stays near zero the two lerp values got equalized somewhere and the pattern is gone.\n- Keep the debounce. Without it a React re-render storm runs `bindAll` on every mutation record, and the observer's own attribute writes can feed it.\n- Keep the `WeakSet`. Re-scanning without a bound check attaches a second `pointerenter` per pass, and after a few route changes one hover fires the handler many times.\n- Disconnect the observer, cancel the frame, and clear the debounce timer on unmount. A surviving loop keeps writing transforms to detached nodes.\n- Scope the observer's `attributeFilter` to the three cursor attributes. Watching all attributes turns every class toggle on the page into a re-scan.\n- `cursor: none` hides the native cursor, so if the follower fails to render the pointer is invisible. Only apply it once the follower is mounted, and never on touch or coarse pointers.\n- The followers must be `pointer-events: none`, or the ring intercepts the hover it is reacting to and the modes flicker.\n- Label text lives on `data-text`, which means it is not in the accessible name of anything. Keep the followers `aria-hidden` and make sure the target itself carries a real label.\n- Positions are written with `transform` only. Animating `left` and `top` instead puts the whole loop on layout and the lag turns into stutter.\n\n## Anti-patterns\n\n- Registering targets with a selector list in JavaScript. That is the thing this pattern replaces, and it silently misses everything mounted after the call.\n- Driving the ring with a CSS transition on `transform` instead of a lerp. A transition re-eases from every new pointer position, which reads as rubber band, not as weight.\n- Setting React state inside the animation loop to move the followers. This is a per frame DOM write, so it belongs on refs. State here is for the discrete mode only.\n- Making the ring opaque in the idle state. The scrim exists so the ring can lag across content without hiding it, and an opaque disc chasing the pointer reads as an obstruction.\n- Shipping it on touch. There is no pointer to trail, so the followers either sit dead in a corner or teleport on every tap.\n- Growing the ring past roughly twice its idle size for a plain hover. Past that it stops reading as a cursor and starts reading as a spotlight, and the `72px` photo state loses its distinction.\n\n## Agent notes\n\n- prompt_phrases: \"custom cursor with a dot and a ring that lag apart\", \"cursor that changes shape and shows a label on hover\", \"cursor targets declared with data attributes instead of registered in JS\".\n- fallbacks: on coarse pointers and under reduced motion, skip the followers entirely and restore the native cursor rather than freezing them in place. Without `MutationObserver`, bind once on mount and re-bind on route change.\n- related: `context-label-cursor` is the general ring plus label cursor. What this recipe adds on top is the two speed split and the declarative rebinding, so do not ship both on one page."},{"id":"blur-char-entrance","name":"Blur Char Entrance","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["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","color.semantic.text.on-accent","color.semantic.border.focus","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/blur-char-entrance.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/blur-char-entrance.md","body":"## Intent\n\nA blur char entrance brings a hero in one character at a time out of a 10px blur, but the pattern is not the blur. It is the schedule. Each block (label, title, subtitle lines, call to action) has its total running time computed from one formula, and the next block's start time is derived from that total rather than guessed. The label is the exception that gives the sequence its shape: it starts at the midpoint of the title block, so the hero reads as one continuous flow instead of four animations queued end to end. Observed on inspiring.nk.studio (chunk `0yw007.vb_n7k.js` module 37553, `runSiteEntranceAnimation` and its `T()` / `R()` / `A()` / `C()` helpers) and recorded in research/m58-round4/p2-nk-studio.json rows[4][5][6], ledger ids `r4-05`, `r4-06`, `r4-07`. Promoted in M59 after passing the user observation gate, with the source palette dropped and every color remapped onto project tokens.\n\n## Anatomy\n\n- Every animated unit tweens the same two properties, from `{ opacity: 0, filter: blur(10px) }` to `{ opacity: 1, filter: blur(0px) }`, on `cubic-bezier(0.38, 0.02, 0.58, 1)`.\n- Block total is one line: `(count - 1) * stagger + duration`. Everything downstream is scheduled off that number.\n- Title: characters at `CHAR_DURATION 0.42` with `STAGGER 0.042`, but the per character tween runs `3 x CHAR_DURATION = 1.26`, so neighbours overlap heavily while the block advances at the stagger rate.\n- Label: starts at the title block total divided by two, then staggers its own characters at the same `0.042`. This midpoint overlap is the signature of the pattern.\n- Subtitle: one unit per line, not per character, at `duration 0.28` and `stagger 0.06`, starting immediately after the title block total.\n- Call to action: `duration 0.42`, starting after title total plus subtitle total.\n- Outro is deliberately asymmetric and faster than the intro: characters `0.3`, stagger `0.03`, call to action `0.18`.\n- While the sequence runs, the content wrapper carries `inert` and the button carries `pointerEvents: none`. Both are released when the schedule completes.\n\n## States\n\n- idle: everything parked at opacity 0 and full blur, nothing interactive.\n- in: blocks resolving on their computed offsets, content still sealed by `inert`.\n- shown: all units at opacity 1 and blur 0, `inert` removed, button clickable.\n- out: the same units returning to blur on the faster outro constants.\n- Reduced motion: the component jumps straight to `shown` and never seals the content.\n\n## Variants\n\n- Per character versus per line: the title earns character granularity because it is short. The subtitle uses per line units because character staggering a full sentence turns a 0.28s reveal into several seconds.\n- Overlap point: the midpoint is the observed value. Starting the label at the title's end reads as a queue, starting it at zero erases the layering entirely.\n- Symmetric outro: reusing the intro constants on the way out is a valid choice but reads as reluctance to leave. The observed asymmetry is roughly 40 percent faster.\n\n## Code\n\n```tsx\n// 블록 총 소요 — 이 한 줄이 다음 블록의 시작 시각을 만든다\nfunction staggerBlockDuration(count: number, each: number, duration: number) {\n if (count <= 0) return 0\n return (count - 1) * each + duration\n}\n\nconst t1Total = staggerBlockDuration(titleChars.length, STAGGER, CHAR_DURATION)\nconst titleMidIn = t1Total / 2 // 라벨은 제목 블록의 중간점에서 출발한다\nconst subtitleTotal = staggerBlockDuration(\n SUBTITLE_LINES.length,\n SUBTITLE_LINE_STAGGER,\n SUBTITLE_LINE_DURATION,\n)\nconst ctaStart = t1Total + subtitleTotal\nconst inTotal = ctaStart + CTA_DURATION\n```\n\n```tsx\n// 단위 하나의 스타일 — 지연과 지속만 바뀌고 from/to 는 전 블록 공통\nconst styleFor = (delay: number, duration: number) => ({\n opacity: visible ? 1 : 0,\n filter: visible ? \"blur(0px)\" : \"blur(10px)\",\n transition: `opacity ${duration}s ${EASE} ${delay}s, filter ${duration}s ${EASE} ${delay}s`,\n})\n```\n\n## Checks\n\n- Derive every start time from `staggerBlockDuration`, never from hand written delay literals. Hardcoded offsets drift the moment the title text changes length, and this pattern is applied to copy that changes.\n- Seal the content with `inert` for exactly `inTotal` and release it in the completion handler. A hero whose button is clickable at opacity 0.2 sends visitors somewhere they could not read.\n- Clear pending timeouts on unmount and before any replay. Two overlapping schedules leave the release handler firing against a sequence that already restarted.\n- Split the title with `[...TITLE]` rather than `TITLE.split(\"\")`, so multi code unit characters are not cut in half.\n- Give each character span `whitespace-pre` and `inline-block`. Splitting text into spans collapses the spaces and the blur cannot be transformed on an inline box.\n- Check the total. Here `inTotal` is about 2.2s, which is already long for a hero. Longer titles push it further because the block total grows with character count.\n- Verify reduced motion lands on the shown state with the seal removed, not on the idle state.\n\n## Anti-patterns\n\n- Animating `filter` on a large container instead of on the individual characters: the browser blurs a full composited layer every frame and the sequence stutters on mid range hardware.\n- Scheduling blocks with a single global stagger index across label, title, subtitle, and button: that is the generic staggered entrance, and it loses the midpoint overlap that makes this version read as one motion.\n- Character staggering the subtitle: two lines at `0.042` per character is a multi second wait for text nobody is reading yet.\n- Mirroring the intro timing on the outro: the exit is a transition away from content already consumed, and holding it at intro speed reads as lag rather than polish.\n- Leaving the sequence unsealed because the durations are short. The button is reachable by keyboard from the first frame, and blurred text is announced in full by screen readers.\n\n## Agent notes\n\n- prompt_phrases: \"hero text that comes in out of a blur letter by letter\", \"staggered blur entrance for a landing headline\", \"sequenced hero intro where the label overlaps the title\".\n- fallbacks: with reduced motion, render the final state immediately and skip the seal. Where `inert` is unsupported, fall back to `pointer-events: none` plus `aria-hidden` on the wrapper for the same interval.\n- The color mapping is deliberate. The source site staged this on its own brand palette. The asset carries only project semantic tokens, so contrast on the label and subtitle follows the muted foreground token and should be re-checked when a consuming project sets a low contrast muted pair."},{"id":"character-sheet-stage","name":"Character Sheet Stage","pattern_group":"marketing","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites","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.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/character-sheet-stage.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/character-sheet-stage.md","body":"## Intent\n\nA character sheet stage is a web page built with print reflexes. Ink texture runs to the edges, the content sits in one fixed column rather than a fluid grid, the setting art is shown uncropped at its native ratio with the artist's annotations intact, headings carry a repeating mark that sets the rhythm, and the credit lives inside the image instead of below it. Most importantly it has no entrance animation at all, which on a site full of motion is itself the strongest statement: this page is a document to be read, not a sequence to be watched. Observed on a bleach-mh special page and measured in the print-methodology layer (research/m56-round3/bleach-mh-disposition.json row p2-bleach-mh.json rows[10], parameters in bleach-mh-layer3.json). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- The ground is an ink texture drawn to a canvas from `--background` and `--foreground`, with rougher marks at the edges so the page reads as a printed sheet rather than a flat fill.\n- The content column is a fixed measure (900px in the source, unchanged at both 1249px and 1280px viewports, reduced proportionally here), not a percentage. Fixed measure is what makes it read as a page.\n- Measured live on 2026-08-16 (M58 step-8): the figure carries `margin: 0`, the setting art renders at exactly its natural `756x1075` with no scaling or cropping, and body copy runs `1rem / 1.8` with `0.05em` letter-spacing. Images inside the body are `max-width: 100%; height: auto` with `margin-block: 2rem`, dropping to `1.5rem` under 768px.\n- Setting art keeps its native `756:1075` ratio and is never cropped, including the annotation strokes around it. Cropping to fit a grid is the thing this layout refuses to do.\n- Each character section opens with a heading carrying a leading mark, one per character, which gives the sheet its scan rhythm.\n- The credit is overlaid at the bottom right inside the image, as it would be on a printed plate.\n- No entrance animation and no scroll trigger anywhere in the block. That is a specified property, not an omission.\n\n## States\n\n- The page has one state. That is the point.\n- Reduced motion changes nothing, because there is no motion to reduce.\n- Resize: the fixed column centers, and the art scales within its ratio rather than reflowing.\n\n## Variants\n\n- Column width is the main dial: wider reads as a catalog, narrower as a manuscript.\n- Texture intensity: heavier ink at the edges reads as a physical print, lighter as a design system surface.\n- Annotation treatment: preserved (observed, and the reason the art is uncropped) or removed for a cleaner plate, which loses the making-of quality.\n\n## Code\n\n```tsx\n// 지면 방법론: 진입 연출 없음. 텍스처만 1회 그린다.\nuseEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n drawInkTexture(canvas);\n}, []);\n```\n\n## Checks\n\n- Draw the texture once. A per-frame loop here is both wasteful and against the block's premise.\n- Read the ink colors from tokens so the sheet follows light and dark themes; a baked paper color breaks in dark mode.\n- Keep the art at its native ratio with no cropping and no forced object-fit.\n- Verify the fixed column at narrow widths, where it has to become a margin-bounded column rather than overflow.\n- Resist adding a reveal later. Once one section animates, the block's meaning changes for the whole page.\n\n## Anti-patterns\n\n- Adding a scroll reveal because every other section has one: the deliberate stillness is the design decision being reproduced.\n- Cropping the art to fill a card: it discards the annotations, which are the reason to show the setting art at all.\n- Moving the credit to a caption below the image: on a plate the credit belongs to the plate.\n- Letting the column go fluid to fill wide screens: the measure is what makes it read as a page rather than a web layout.\n\n## Agent notes\n\n- prompt_phrases: \"character setting sheet page with print feel\", \"ink textured page with a fixed content column\", \"static page with no entrance animation on an animated site\".\n- fallbacks: none needed for motion, since the block has none. On very small screens, reduce the column to a margin-bounded measure rather than switching to a fluid grid."},{"id":"character-stack-crossfade","name":"Character Stack Crossfade","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["websites"],"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/character-stack-crossfade.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/character-stack-crossfade.md","body":"## Intent\n\nA character stack crossfade selects one figure from a small roster by fading it in while the others fade out in place, driven by a draggable pagination bar rather than a tab list or carousel scroll. The stack reads as a stage: every candidate occupies the same footprint and only opacity changes, so switching feels like a costume change rather than a page swap. Observed at BLEACH Mirrors High (research/m55-round2/p2-bleach-mh.json rows 2 and 3: `CharacterSection_figure_image` opacity crossfade and `CharacterSection_pagination_bar` drag-driven progress bar); user gate pass frozen in research/2026-08-15-m55-gate-verdicts.md section 2 (round-2 new, pass list).\n\n## Anatomy\n\n- Figures stack via `position: absolute; inset: 0` inside a shared relative container, one per character, so no layout shift occurs on switch.\n- Active state toggles a boolean per figure (`data-active`); only `opacity` transitions, 0.4s, `cubic-bezier(0,.51,.5,1)` (row 2).\n- A pagination track below the stack maps horizontal position to index: pointer-down on the track jumps directly to the nearest index, pointer-move while dragging keeps updating it via window-level listeners.\n- The thumb's `left` position transitions 0.25s `cubic-bezier(.4,0,.2,1)` (row 3), independent of the figure crossfade timing, so drag response reads snappier than the fade.\n- Left/right arrow buttons step the index by 1 and disable at the ends; they short-circuit the drag math entirely.\n\n## States\n\n- Idle: one figure at `opacity: 1`, rest at `opacity: 0`, thumb resting at that index's position.\n- Dragging: `draggingRef` true, index recalculated every pointermove from clientX against the track's bounding rect, clamped to `[0, count-1]`.\n- Crossfading: index just changed; outgoing figure fades out and incoming fades in concurrently over the same 0.4s window (no explicit sequencing between them).\n- Boundary: arrow button disabled and no-ops at index 0 or count-1.\n\n## Variants\n\n- Selector body: figures can be images, illustration stacks, or full card layouts; the pattern only requires that every variant shares one bounding box.\n- Navigation surface: keep the draggable pagination bar, arrows only, or both; the crossfade contract is independent of which control drives the index.\n- Timing split: slow the 0.4s figure fade while keeping the 0.25s thumb transition for a heavier stage feel, or equalize both for a calmer swap.\n- Lit-thumbnail rail (bleach-mh, research/m56-round3/bleach-mh-deep.json): the selector rail marks the active thumb by swapping to a separate pre-lit artwork file (two stacked images toggled by data-state), not a CSS tint; the rail uses Swiper's official Scrollbar module for its draggable progress bar and always ends with a coming-soon placeholder slide reserving space for unrevealed characters.\n- Background name state machine: the huge background character-name layer changes in two phases: the outgoing text finishes its exit transition first (onTransitionEnd chaining on a React key-remounted node), then the new name enters. The figure crossfade and the name swap deliberately run on different clocks.\n- Media double-layer crossfade (bleach-mh SystemSection, research/m55-round2/p2-bleach-mh.json row 5): the same data-state toggle drives two stacked media layers at once: a background layer to opacity .19 with blur(20px) and a preview layer to opacity 1, both .5s cubic-bezier(.25,1,.5,1), so the stage swaps content and mood in one gesture.\n\n## Code\n\n```tsx\nconst indexFromClientX = (clientX: number) => {\n const track = trackRef.current;\n if (!track) return activeIndex;\n const rect = track.getBoundingClientRect();\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return Math.min(count - 1, Math.round(ratio * (count - 1)));\n};\n\n// figure layer:\n// className=\"absolute inset-0 ... opacity-0 data-[active=true]:opacity-100\"\n// transitionDuration: \"0.4s\", transitionTimingFunction: \"cubic-bezier(0,.51,.5,1)\"\n\n// thumb layer:\n// left: `${thumbLeftPercent}%`\n// transitionDuration: \"0.25s\", transitionTimingFunction: \"cubic-bezier(.4,0,.2,1)\"\n```\n\n## Checks\n\n- Drag listeners (`pointermove`, `pointerup`) are attached to `window`, not the track, so a drag started inside the track still tracks correctly when the pointer leaves it; both are removed on unmount.\n- Index math is clamped on every read (`Math.min`/`Math.max`), so a pointer past either edge of the track cannot select an out-of-range figure.\n- Arrow buttons use `disabled` at the boundaries, not a max/min clamp inside the click handler alone, so keyboard/AT users get a real disabled affordance.\n- Each figure and control uses semantic tokens (`bg-background`, `bg-muted`, `text-foreground`, `text-muted-foreground`, `border-border`); no hardcoded colors.\n\n## Anti-patterns\n\n- Animating figure size or position instead of opacity alone: the stack's whole premise is a fixed footprint; anything that shifts layout during the fade reads as a jump cut, not a crossfade.\n- Binding the pagination thumb's transition duration to the same 0.4s as the figure fade: the two are observed as decoupled (0.25s vs 0.4s) so drag response stays perceptibly faster than the content change.\n- Reading pointer position only from the track's own event target during drag: losing the pointer outside the track's bounds should not end the drag, only `pointerup` should.\n\n## Agent notes\n\n- prompt_phrases: \"character selector with crossfade stage\", \"drag a bar to switch between stacked figures\", \"stacked absolute figures fade between each other\".\n- fallbacks: with a roster of one, hide the pagination bar and arrows entirely rather than rendering disabled no-op controls. Reduced-motion users should get an instant opacity swap (no 0.4s/0.25s transition) rather than a disabled crossfade."},{"id":"clip-reveal-wordmark","name":"Clip Reveal Wordmark","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites","documentation"],"tokens_used":["color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.action.primary","color.semantic.border.focus","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/clip-reveal-wordmark.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/clip-reveal-wordmark.md","body":"## Intent\n\nA clip reveal wordmark keeps the brand name at a compact width in the header and expands it to the full name on hover, without ever swapping one logo asset for another. There is a single wordmark in the DOM, and the only thing that animates is how much of it is clipped away. The point is positional stillness: the visible leading letters do not move by a pixel while the rest of the name arrives, which is exactly the defect that a two asset swap (short mark, full mark) cannot avoid no matter how carefully the two are aligned. Observed on inspiring.nk.studio, read directly from the `.logo-container` rule in its compiled stylesheet, and recorded as research/m58-round4/p2-nk-studio.json rows[13] (id r4-14).\n\n## Anatomy\n\n- A fixed box of `200 x 40px` holds the wordmark. Its size never changes, in either state.\n- The resting state is `clip-path: inset(0 130px 0 0)`, so `130px` of the right side is cut off and only the leading `70px` of the mark is visible.\n- The hover state is `clip-path: inset(0)`, the full box.\n- Transition is `clip-path 0.3s cubic-bezier(0.18, 0.78, 0, 1)` for the reveal and `opacity 0.45s cubic-bezier(0.64, 0.1, 0, 1)` for the fade. The clip easing decelerates hard, which is what makes the tail of the name settle rather than snap.\n- The wordmark span is absolutely positioned at `left: 0` with the full `200px` width, so it is laid out once and the clip window moves over it. Nothing about the text layout is state dependent.\n- The clip only applies at `@media (min-width: 1280px)`. Below that the rule is `clip-path: none` and the full name is always visible, because on a narrow screen there is no hover to reveal it with.\n- Menu open state sets `opacity: 0` with `transition-delay: 0.3s`, so the mark leaves after the menu overlay has begun, not at the same instant.\n\n## States\n\n- Resting (wide viewport): clipped to the leading portion, full name present in the DOM but not painted.\n- Hover or focus: clip released over `0.3s`, full name visible, no reflow of the leading letters.\n- Narrow viewport (under `1280px`): no clip at all, so the resting and hover looks are identical.\n- Menu open: the whole mark fades to `opacity: 0` over `0.45s` after a `0.3s` delay, and pointer events are dropped so the hidden mark cannot be hovered.\n- Reduced motion: the clip should switch without a transition, since the reveal is decoration rather than information.\n\n## Variants\n\n- Clip amount: `130px` of `200px` is the observed cut. A smaller cut reads as a trim, a larger cut approaches a logo that appears from nothing and loses the continuity that justifies the technique.\n- Clip side: cutting from the right suits left aligned latin wordmarks. A right aligned or RTL mark cuts from the left instead, using `inset(0 0 0 Npx)`.\n- Trigger: hover on the mark itself (observed), hover anywhere in the header, or scroll position. Header wide hover reveals earlier and feels more generous, scroll linked reveal turns it into a state indicator instead of an invitation.\n- Breakpoint: `1280px` is the observed threshold, chosen because that is where the header has spare horizontal room to give back.\n\n## Code\n\n```tsx\nconst FULL_WIDTH = 200\nconst HEIGHT = 40\nconst CLIP_RIGHT = 130 // 오른쪽 130px 을 잘라 둔다 (관측값)\nconst CLIP_EASE = \"cubic-bezier(0.18, 0.78, 0, 1)\"\nconst FADE_EASE = \"cubic-bezier(0.64, 0.1, 0, 1)\"\n\n<div\n style={{\n width: FULL_WIDTH,\n height: HEIGHT,\n clipPath: clipped ? `inset(0 ${CLIP_RIGHT}px 0 0)` : \"inset(0)\",\n opacity: menuOpen ? 0 : 1,\n pointerEvents: menuOpen ? \"none\" : \"auto\",\n transition: `clip-path 0.3s ${CLIP_EASE}, opacity 0.45s ${FADE_EASE}`,\n // 메뉴가 열릴 때만 0.3s 늦게 사라진다 (관측값)\n transitionDelay: menuOpen ? \"0.3s\" : \"0s\",\n }}\n>\n {/* 워드마크는 항상 전체 폭으로 한 번만 레이아웃된다 */}\n <span className=\"absolute left-0 top-0 ...\" style={{ width: FULL_WIDTH }}>\n …\n </span>\n</div>\n```\n\n## Checks\n\n- Keep the box width fixed at the full width in both states. If the container width animates instead of the clip, the header relayouts and everything to the right of the logo slides, which is the failure this pattern exists to avoid.\n- Animate `clip-path`, not `width` or `max-width`. Width animation reflows text and can rewrap the mark mid transition.\n- Give the resting clip a value that lands on a letter boundary rather than mid glyph. A half cut letter reads as a rendering bug, not as a truncation.\n- Provide focus as well as hover. The mark is often a link, and a keyboard user must be able to reveal the full name.\n- Expose the full name to assistive technology through `aria-label` or visible text. Clipping is purely visual, so the accessible name must be the complete one regardless of state.\n- Drop pointer events when the mark is faded out for the menu, otherwise an invisible logo still takes clicks.\n- Verify the sub `1280px` path actually removes the clip. A stale clip on mobile leaves the brand permanently truncated with no way to reveal it.\n\n## Anti-patterns\n\n- Swapping between a short mark and a full mark on hover. Even with matched metrics there is a sub pixel jump at the swap, and font loading makes it worse.\n- Sliding the wordmark horizontally into a fixed window. That moves the leading letters, which defeats the whole point.\n- Using `overflow: hidden` on a shrinking parent instead of `clip-path`. It works until the parent participates in flex or grid sizing, at which point the neighbors move.\n- Applying the clip on touch devices, where there is no hover to release it. The observed breakpoint exists precisely to prevent this.\n- Matching the fade duration to the clip duration. The observed values are deliberately different (`0.45s` fade against `0.3s` clip) so the two never read as one motion.\n- Loading the reveal with meaning, for example hiding a word the user needs. Anything required must be visible in the resting state.\n\n## Agent notes\n\n- prompt_phrases: \"logo that expands to the full name on hover\", \"header wordmark that unclips instead of swapping\", \"brand mark truncated until hovered\".\n- fallbacks: with reduced motion, apply the clip change with no transition. Without `clip-path` support, render the unclipped full wordmark, which is the same as the narrow viewport path and is always correct.\n- The clip value is coupled to the wordmark's rendered width, so it must be recomputed if the typeface, size, or brand string changes. Do not treat `130px` as portable across a different mark."},{"id":"composite-bleach-mh","name":"Composite: bleach-mh Grain","pattern_group":"marketing","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.surface.scrim","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/composite-bleach-mh.tsx","component_refs":["name-crossfade-state-machine","character-stack-crossfade","character-zoom-isolation","character-sheet-stage"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/composite-bleach-mh.md","body":"## Intent\n\nThis composite reproduces a character selector stage as one scene: a huge background name driven by the two-stage crossfade state machine, the figure crossfading in place at the center, a thumbnail rail below where selection swaps to a separately lit artwork rather than applying a filter, a coming-soon slide pinned at the end of the rail, a zoom button that isolates the character behind a blurred page, and a global motion toggle wired to the reduced-motion preference. The ground is an ink texture, so the whole selector reads as a stage rather than a gallery widget. Assembled from research/m56-round3/bleach-mh-disposition.json rows p2-bleach-mh.json rows[2], rows[3] and rows[6], the technique rows in bleach-mh-deep.json, and the ink stage measurement in bleach-mh-layer3.json. Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2, where all three composite demos passed.\n\n## Anatomy\n\n- Figure crossfade uses the observed values exactly: `0.4s cubic-bezier(0, .51, .5, 1)` across absolutely stacked layers where only the active one is opaque.\n- The pagination bar slides on `0.25s cubic-bezier(.4, 0, .2, 1)` and is draggable, so the rail reads as a physical control.\n- The background name runs the exit-then-swap state machine, committing on the opacity `transitionend` with a remount key. Its duration was not recoverable from the source CSS and is a demo choice, recorded as such in the code.\n- Thumbnail selection swaps between two stacked artworks, a base and a lit version, rather than tinting or filtering one image. That is what keeps the selected state from looking like a CSS effect.\n- A coming-soon placeholder is pinned as the last slide, which is a content decision the layout has to accommodate rather than an empty state.\n- The motion toggle combines `matchMedia(\"(prefers-reduced-motion: reduce)\")` with an explicit `aria-pressed` button, so the visitor can override the system preference in either direction and decorative motion is gated on the result.\n- Every character color is a `color-mix` of `--foreground` with a project brand token, so the source's six character accents are reproduced structurally without importing another product's palette.\n\n## States\n\n- Selected character: figure, background name, and thumbnail state all follow one index, on three different clocks.\n- Background name mid-swap: `exit` until its transition ends, then commit.\n- Zoom open: page blurred and dimmed, scroll locked, subject isolated.\n- Motion off: decorative animation gated, crossfades reduced to instant swaps.\n- Coming-soon slide selected: no figure to show, and the stage has to say so rather than go blank.\n\n## Variants\n\n- Rail: a full slider library (observed, with `slidesPerView: \"auto\"` and centered bounds) or a simple horizontal rail with a drag bar. The reduced version is honest when the slide count is small.\n- Selection artwork: two-layer swap (observed) or a filter. The swap costs a second asset per item and looks materially better.\n- Ground: ink texture (observed) or a flat surface, which turns the same structure into an ordinary selector.\n\n## Code\n\n```tsx\n// 캐릭터 액센트는 원 사이트 팔레트가 아니라 프로젝트 토큰 혼합으로 재현한다\ncolor: \"color-mix(in oklch, var(--foreground) 58%, var(--primary) 42%)\",\n```\n\n## Checks\n\n- Keep the three clocks distinct on purpose: figure crossfade, background name state machine, thumbnail swap. Collapsing them into one duration erases the character of the stage.\n- Gate decorative motion on the toggle and the media query together, and let the explicit toggle win, since that is what the visitor just pressed.\n- The coming-soon slide must be reachable by keyboard and must not select an empty character.\n- Read every color from tokens. Copying the source's character palette would import another product's brand identity, which the harvest contract forbids.\n- Verify with time-spaced captures, because the state machine's failure mode is not advancing rather than looking wrong.\n\n## Anti-patterns\n\n- Tinting one thumbnail image for the selected state when the source ships a second artwork: the pattern being reproduced is the artwork swap.\n- Committing the background name swap on a timer: it drifts from the CSS the moment either is edited.\n- Copying the observed character accent colors directly: that is brand identity, and it belongs in the origin note, not in the asset.\n- Treating the composite as the shippable unit: consumers take the individual assets, and this scene exists to show they assemble into one coherent stage.\n\n## Agent notes\n\n- prompt_phrases: \"character selector stage with a huge background name\", \"thumbnail rail with lit selection artwork\", \"composite scene for a character showcase page\".\n- fallbacks: with motion off, swap all three channels instantly and skip the zoom transition while keeping the scroll lock. Each constituent asset carries its own reduced-motion path, so the composite only has to keep the selection state advancing."},{"id":"composite-nk-inspiring","name":"Composite: NK Inspiring Arc","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.text.on-accent","color.semantic.action.primary","color.semantic.border.default","dimension.radius.lg","dimension.radius.md"],"code_asset":"examples/ui-vocabulary-site/src/components/composite-nk-inspiring.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/composite-nk-inspiring.md","body":"## Intent\n\nThis composite reproduces the grain of a single site rather than any one effect. A landing sits in an \"intro\" chrome state, the headline resolves out of blur while accent underlines grow under the emphasized words, and pressing the call to action clears the hero faster than it arrived. That clearing is the broadcast: the chrome switches to \"revealed\", the top bar thins, the bottom measurement line extends to its rail, and a field of cards that was drifting on independent orbits binds into a grid. What the arc is trying to prove is that a page reads as one authored sequence when entry, exit, chrome, and content share a single clock, instead of as a pile of separately tuned animations. Reverse engineered from inspiring.nk.studio (`https://inspiring.nk.studio/es`) and assembled over the observation rows in `research/m58-round4/p2-nk-studio.json`: r4-01 (flare lines), r4-05, r4-06 and r4-07 (blurred character entry, asymmetric outro, inert sealing), r4-09 (underline bridge), r4-10 and r4-11 (two speed cursor), r4-13 (peer blurring), r4-14 (logo clip), r4-15 (rail measurement line), r4-16 (phase broadcast), r4-17, r4-19 and r4-20 (chaos to grid, width sensitive curvature, float), r4-29 (three colors plus one accent), r4-30 (z layer constants). The source palette is not carried into the asset. Only its structure is: one stage surface, one accent used as a signal, and a dim path color for the background flare.\n\n## Anatomy\n\n- **Entry (r4-05).** Every headline character is its own span, transitioning `opacity` and `filter` together from `blur(10px)`. Stagger is `0.042s` per character on `cubic-bezier(0.38, 0.02, 0.58, 1)`. The two headline lines share one continuous character index, which is what lets the underlines know when their word started. Thirty six characters give a headline block of `1.89s`, the sub lines add `0.28s` each at `0.06s` stagger, and the call to action lands last at `2.65s` total.\n- **Label offset.** The eyebrow label does not start with the headline. It begins at `titleMidIn`, half of the headline block, so the two runs interleave rather than stack.\n- **Outro (r4-06).** The exit reuses the same properties with different constants: `0.3s` per character at `0.03s` stagger and a `0.18s` call to action, `1.53s` total. Leaving in roughly 58 percent of the entry time is the asymmetry that makes the transition read as decisive.\n- **Sealing (r4-07).** The hero layer carries `inert` whenever it is not visible, so a half faded hero cannot be tabbed into or clicked through.\n- **Underline bridge (r4-09).** Each emphasized word has an absolutely positioned `2px` bar at `bottom: -0.19em` that scales on X over `1.2s` with `cubic-bezier(0.87, 0, 0.13, 1)`. Odd groups grow from `left center` starting at their first character's stagger time. Even groups grow from `right center` starting `0.1s` before their last character, so the two underlines converge instead of running in parallel.\n- **Phase broadcast (r4-16).** One state value drives the chrome. The top bar animates `76px` to `48px` over `0.72s`, the \"archivo\" marker fades in on the same curve, and the bottom line target flips at the same moment.\n- **Rail measurement line (r4-15).** In the intro phase the line's `scaleX` is measured, not guessed: it is the distance from the line's left edge to the rail tick's right edge divided by the wrapper width. In the revealed phase it is `1`. Extension runs `0.75s`. The measurement is re-run on resize.\n- **Chaos to grid (r4-17, r4-19, r4-20).** Twelve cards at a `30/23` aspect. A single `bind` value damps toward its target with an asymmetric rate, `4.6` binding up and `3.25` releasing, and every card property interpolates against it: orbital position to grid cell, `z` from a `320px` spread to zero, scale `0.72` to `1`, opacity `0.35` to `1`. Columns step `3 / 4 / 5 / 6` at `768 / 1024 / 1280`, gap runs `32px` to `50px` between `375px` and `1440px`, and curvature and tilt scale with a squared width ratio, `curveAmount` `10` to `64` and `maxTiltRad` `0.11` to `0.36`. Float is a `9px` sine at a `5s` period damped at `2.25`, multiplied by `bind` so it only exists once the grid has formed. `dt` is clamped at `0.05` so a background tab does not launch the cards.\n- **Peer blurring (r4-13).** Hovering a bound card sets the others to `blur(6px)` and opacity `0.4` over `0.38s`. The gate is `bind > 0.9`, so nothing dims while the field is still converging.\n- **Two speed cursor (r4-10, r4-11).** A `4px` dot lerps at `0.075` and a `36px` ring at `0.06`, both toward the same target. The gap between them is the whole effect. Over a card the ring grows to `64px` and its backdrop blur goes `4px` to `8px` on `cubic-bezier(0.64, 0.1, 0, 1)`.\n- **Logo clip (r4-14).** The wordmark is one line clipped with `inset(0 130px 0 0)` and opened to `inset(0)` on hover over `0.3s` with `cubic-bezier(0.18, 0.78, 0, 1)`. Nothing moves; only the window changes.\n- **Flare lines (r4-01).** Ten canvas paths run from a random edge toward a jittered center. The dim path draws at alpha `0.07` and width `5`, and a head travels it with a tail `0.16` of the path behind, fading to transparent.\n- **Z layers (r4-30).** Canvas `9`, cards `50`, hero `100`, chrome `105`, cursor ring `160`, cursor dot `161`.\n\n## States\n\n- **Intro / hero:** thick chrome, short measurement line, cards drifting on orbits with `bind` at `0`, hero interactive.\n- **Leaving:** hero characters clearing on the faster outro constants, hero already `inert`, chrome unchanged so the exit is legible before the broadcast.\n- **Archive / revealed:** chrome thinned, line extended to the rail, `bind` damping to `1`, cards interactive.\n- **Card hover:** only in the archive stage and only past `bind > 0.9`. The hovered card holds, the rest blur and dim, the cursor ring inflates.\n- **Pointer outside stage:** cursor dot and ring fade out, hover state clears.\n- **Reduced motion:** the hero is set visible with no entry run, the flare heads stop advancing, and card float is pinned to zero. Layout, chrome, and binding remain usable.\n\n## Variants\n\n- **Bind rate:** the asymmetric damping is the character. Equal up and down rates make the grid feel elastic instead of decisive.\n- **Card count and columns:** twelve is what fills the observed grid. Fewer cards leave the curvature unreadable because the curve is sampled across the row.\n- **Accent surface:** the accent appears in four places only (logo slash, phase marker, underlines, call to action, cursor dot). Spending it in more places turns a signal into decoration.\n- **Flare density:** ten paths is the observed count. More paths read as weather rather than as a background.\n\n## Code\n\n```tsx\n// 진입과 아웃트로가 같은 속성을 쓰되 상수만 다르다 — 아웃트로가 더 빠른 것이 핵심\nconst CHAR_DURATION = 0.42\nconst STAGGER = 0.042\nconst OUT_CHAR_DURATION = 0.3\nconst OUT_STAGGER = 0.03\nconst EASE = \"cubic-bezier(0.38, 0.02, 0.58, 1)\"\n\nconst charStyle = (index: number) => ({\n opacity: heroVisible ? 1 : 0,\n filter: heroVisible ? \"blur(0px)\" : \"blur(10px)\",\n transition:\n `opacity ${isOut ? OUT_CHAR_DURATION : 3 * CHAR_DURATION}s ${EASE} ${index * charStagger}s, ` +\n `filter ${isOut ? OUT_CHAR_DURATION : 3 * CHAR_DURATION}s ${EASE} ${index * charStagger}s`,\n})\n\n// 밑줄 그룹 시작 시각: 홀수는 첫 글자에서 왼쪽으로, 짝수는 마지막 글자보다 0.1s 앞서 오른쪽으로\nconst groupStart = (indices: number[], group: number) =>\n group % 2 === 1\n ? indices[0] * STAGGER\n : Math.max(0, indices[indices.length - 1] * STAGGER - 0.1)\n\n// bind 하나가 궤도·격자·곡률·부유·투명도를 전부 지배한다 (감쇠율은 비대칭)\nbindRef.current = damp(\n bindRef.current,\n bindTargetRef.current,\n bindTargetRef.current > bindRef.current ? 4.6 : 3.25,\n Math.min(dt, 0.05),\n)\nconst x = cx + (gx - cx) * bind\nconst y = cy + (gy + curveY + float * bind - cy) * bind\nconst z = cz * (1 - bind)\n\n// canvas 는 클래스를 못 쓰므로 런타임에 토큰 값을 읽고 color-mix 로 알파를 만든다\nconst headColor = getComputedStyle(root).getPropertyValue(\"--primary\").trim()\ngrad.addColorStop(0, `color-mix(in srgb, ${headColor} 75%, transparent)`)\ngrad.addColorStop(1, `color-mix(in srgb, ${pathColor} 0%, transparent)`)\n```\n\n## Checks\n\n- Seal the hero with `inert` for the whole time it is not fully visible, not just after the outro finishes. A hero at opacity `0.2` still receives clicks and still holds tab stops.\n- Clear the outro timer on every re-entry. Pressing the call to action, restarting, and pressing it again will otherwise fire two phase broadcasts and the chrome will flip twice.\n- Clamp the frame delta. Without `MAX_DT` a tab that was backgrounded resumes with a delta of several seconds and the damping snaps the entire card field in one frame.\n- Gate the peer blur on `bind`, not on the stage name. The stage flips to archive at the start of the convergence, and dimming cards that are still flying reads as a rendering fault.\n- Re-measure the rail line on resize. The intro `scaleX` is a ratio of two measured rects, so a window change leaves it pointing at the wrong place until it is recomputed.\n- Drive the cursor with `transform` writes inside one animation frame loop, and cancel the frame on unmount. Two separate loops for the dot and the ring will drift apart under load and destroy the lag that is the point.\n- Read colors from CSS variables inside the canvas effect and re-read them on theme change. A canvas that resolved its colors once will keep drawing light mode flares on a dark page.\n- Check that the sum of character count and stagger is still tolerable at the longest headline you actually ship. Thirty six characters give `1.89s`. Sixty would give `2.9s` before the sub lines even start.\n\n## Anti-patterns\n\n- Making the outro symmetric with the entry. A slow exit reads as hesitation, and the phase broadcast that fires at the end of it arrives late enough to feel disconnected from the click.\n- Animating each headline character with its own timer or tween instance. Thirty six timers drift, and the underline start times, which are derived from character indices, stop lining up with the characters they belong to.\n- Broadcasting the chrome phase on click instead of at the end of the outro. The bar thins while the hero is still on screen and the two changes fight for attention.\n- Giving each card its own progress value. The convergence reads as one mechanism precisely because a single damped scalar drives all twelve.\n- Growing both underlines from the left. The alternating origin with the `0.1s` lead on even groups is what makes the pair read as one gesture rather than two repeats.\n- Locking the curvature and tilt to fixed values. They are sampled from viewport width with a squared ratio, and a fixed curve either flattens on wide screens or bows absurdly on narrow ones.\n- Hiding the native cursor without a visible substitute in every state. The stage sets `cursor-none`, so if the dot and ring fail to render the pointer is simply gone.\n\n## Agent notes\n\n- prompt_phrases: \"a landing that clears itself and turns into an archive grid\", \"hero text resolving out of blur with accent underlines\", \"cards drifting then snapping into a grid\", \"custom cursor with a lagging ring\".\n- The composite is a sequence, not a component. Lift the pieces separately if only one is wanted: the blurred character entry, the underline bridge, the phase broadcast, the chaos to grid binding, and the two speed cursor are each independently useful.\n- fallbacks: under reduced motion, show the hero immediately, freeze the flare heads, and drop the card float while keeping the bind transition, which is a position change rather than an ambient one.\n- The asset carries no palette of its own. It reads `--primary` and `--muted-foreground` at runtime for the canvas and uses semantic utilities everywhere else, so it inherits whatever the consuming project defines."},{"id":"composite-noth-in","name":"Composite: noth.in Grain","pattern_group":"marketing","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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","typography.font.mono"],"code_asset":"examples/ui-vocabulary-site/src/components/composite-noth-in.tsx","component_refs":["multi-stage-entry-loader","hero-reflection-stage","magnetic-repulsion-cluster","context-label-cursor","shared-element-flip-transition"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/composite-noth-in.md","body":"## Intent\n\nThis composite runs noth.in's whole arc as one scene: entry loader, then a landing with a reflected hero, a sound gauge, a repulsion glyph cluster and a velocity glitch band, then a works list with cursor labels, then a FLIP transition into a detail view, and finally a bottom overrun gauge that pulls the next project. Each piece exists separately as its own asset; assembled in this order and on this dark monospace stage they reproduce what the site actually feels like, which is the claim the composite format exists to test. Assembled from research/m56-round3/noth-in-deep.json d3[2] and d3[10] plus the layer-3 measurement in noth-in-layer3.json results[2], over the round-2 observation rows p2-noth-in.json rows[0], rows[2], rows[4], rows[5], rows[6], rows[8], rows[9]. Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- The stage inverts `--foreground` and `--background` for its panel, so the dark monospace look comes entirely from tokens with no literal colors.\n- Loader: the sine-accelerated carousel with percent counter and four-step exit, compressed for a demo box.\n- Reflected hero: the four-layer stack (background at `scale(1.8)`, source, mirrored copy at `opacity .65` with blur, transparent frame at `scale(1.01)`), driven by one procedural canvas standing in for the two source clips.\n- Sound gauge: a visual-only reproduction of the volume tween (`0 to 1` over `1.6s power1.out`, stop after a `1.4s` delay) with the tick sliding `0.35s` along a measured track.\n- Repulsion cluster: the `460 / 380 / 30 / 0.2` field with `power4.out` push and `elastic.out(1, 0.35)` return.\n- Glitch band: scroll velocity maps to intensity as `f = min(abs(v) / 3400, 0.45)`, lerped at `0.14`, with character substitution probability falling off as `t * (1 - c / 22) ^ 0.6` and a four-character lead ahead of the reveal.\n- Work rows: the cursor label positions in the row's own coordinate space (`clientX - rect.left`), lerps at `0.09`, activates on the first mousemove rather than on enter, and enters with a `back.out(1.8)` overshoot measured live at scale 0 to about 1.1 to 1.\n- FLIP detail transition: only links marked `data-transition=\"flip\"` fly their title into the detail hero (`1.2s power4.inOut`); everything else uses the plain `0.5s power2.inOut` fade.\n- Overrun footer: past the bottom of the detail view, continued scroll fills a gauge (`lerp 0.12`, threshold `2600`) that advances to the next project.\n\n## States\n\n- `loading`: the entry loader owns the stage.\n- `landing`: hero, sound gauge, cluster, and glitch band are live; scroll velocity feeds the band.\n- `works`: rows respond to the pointer with labels.\n- `detail`: arrived through FLIP or fade depending on the link's attribute.\n- `overrun`: the gauge fills only while the visitor keeps scrolling past the end, and resets if they stop.\n\n## Variants\n\n- Any single stage can be dropped. The loader and the reflected hero are the pair that defines the entry; the glitch band is the most removable.\n- Sound is reproduced as a gauge only. A real implementation needs a user gesture before audio starts, which changes the entry sequence.\n- Compression: the observed loader is `5000ms` and the hero blur `80px`. Both are scaled for a demo box here, and the observed values are kept in comments.\n\n## Code\n\n```tsx\n// 스크롤 속도 → 글리치 강도, 그리고 프레임 간 lerp\nconst target = Math.min(Math.abs(velocity) / 3400, 0.45);\nintensity.current += (target - intensity.current) * 0.14;\n```\n\n## Checks\n\n- One scroll driver for the whole scene. The glitch band, the FLIP scrub, and the overrun gauge all read scroll, and separate listeners drift apart.\n- The overrun gauge must require sustained input past the end and decay when it stops, or a single bounce scroll navigates the visitor away.\n- Activate the cursor label on the first mousemove, not on mouseenter: on enter the pointer coordinates are not yet in the row's space and the label flies in from the corner. That exact defect was found and repaired earlier in this milestone.\n- Keep every color on the inverted token pair. A literal dark value here would ignore the consuming theme, which is what the stage is demonstrating it does not do.\n- Verify with time-spaced captures. Phase machines fail by not advancing, and a single frame cannot show that.\n\n## Anti-patterns\n\n- Treating the composite as the distributable unit: consumers take the individual assets, and this scene exists to show the assembly reads as one site.\n- Reproducing audio behavior without a gesture gate: browsers block it and the sequence silently diverges from the design.\n- Stacking techniques that never co-occur on the source: the sequence itself has to come from an observation row, otherwise the result is a demo reel.\n- Keeping every observed duration at full length inside a small stage: a `5000ms` loader in a `60vh` box reads as a hang. Scale it and record what was scaled.\n\n## Agent notes\n\n- prompt_phrases: \"recreate a studio site's whole flow in one scene\", \"loader into reflected hero into works list with FLIP detail\", \"composite demo of a dark monospace portfolio site\".\n- fallbacks: with reduced motion, hold the loader to one frame, freeze the glitch band and the cluster, and keep the phase machine advancing so the scene still reaches every state. Each constituent asset carries its own reduced-motion path."},{"id":"composite-revelatio","name":"Composite: Revelatio Home Grain","pattern_group":"marketing","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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","typography.font.mono"],"code_asset":"examples/ui-vocabulary-site/src/components/composite-revelatio.tsx","component_refs":["fake-progress-preloader","scroll-theme-inversion"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/composite-revelatio.md","body":"## Intent\n\nThis composite is a single scene that runs six separately observed techniques in the order the source site runs them: a fake-progress preloader, a CRT power-on flash, a scroll-driven zoom from a small monitor to full bleed, a theme inversion at the halfway line, an odometer stat row, and a drag-reactive marquee. The claim it tests is that a site's grain is a property of sequence, not of any one effect. Individually each piece is available as its own asset; assembled in this order they reproduce what the source actually feels like. Observed on Revelatio Studio's home page and assembled from research/m56-round3/revelatio-disposition.json rows p2-revelatio.json rows[0] (preloader) and rows[2] (TV zoom), plus research/m56-round3/revelatio-deep.json d3[15] (power-on flare). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2, where all three composite demos passed, the first evidence that this format works.\n\n## Anatomy\n\n- Phase machine: `preloader` to `poweron` to `scene`. A replay button resets scroll, clip path, flash opacity, inversion, and a `runToken` that remounts the preloader.\n- Preloader: stage values snap through `[0, 25, 50, 75, 100]` while displayed progress lerps at `0.09` toward a target curve, with a scramble label over it. Minimum hold is `1500ms` here against `2000ms` observed, compressed because the demo box is not a real page load.\n- The preloader-to-power-on handoff is confirmed in the source rather than inferred (M58 step-8, 2026-08-16): the preloader's exit timer calls `revelatioAscii.setOptions({ powerState: 0 })` and then `revelatioAscii.powerOn(980)`, so the sequence order reproduced here is the site's own.\n- Power-on: a `980ms` timeline in which the aperture opens as `clipPath: inset()` and two Gaussian flares (main at `t = 0.12`, tail at `t = 0.32`) fire over it, reproducing the shader's `powerState` two-segment power curve in DOM.\n- TV zoom: a scroll listener plus rAF computes `progress = clamp(-rect.top / distance, 0, 1)`, driving the monitor's width from `42%` to full and fading the CRT glyph intensity out by `0.85` progress. No ScrollTrigger is involved.\n- Theme inversion: when a dark section crosses the container's midline, the scene's `--sbg` / `--sfg` pair swaps and everything under it transitions over `0.35s`. The scene reads those two variables rather than the theme tokens directly, which is what keeps the inversion scoped to the box.\n- Odometer: an `IntersectionObserver` at `threshold 0.35` with `rootMargin \"0px 0px -12% 0px\"` starts a GSAP tween per digit column, `0.1s` element stagger, `0.04s` digit stagger, `1s` duration, `power3.out`, cycling two full digit runs before landing.\n- Drag marquee: a pure rAF loop with `20s` per loop, drag velocity mapped through `sensitivity 0.01` and clamped to a `40x` multiplier, recovering to base speed over `0.9s`.\n- The ASCII glyph canvas renders from a `29` character ramp at a `12px` cell, reading its two colors from `--foreground` and `--background` at setup so the scene follows the consuming theme.\n\n## States\n\n- `preloader`: the scene is masked, the counter climbs, input is not yet meaningful.\n- `poweron`: the aperture opens from a near-closed inset, flares peak, the scene becomes visible.\n- `scene` before midline: base surface colors, monitor small, glyphs at full CRT intensity.\n- `scene` after midline: `data-inverted=\"true\"`, colors swapped, monitor at full bleed, glyphs faded.\n- Odometer in view: digits roll once and stay. Marquee under drag: timescale saturates and recovers.\n- Replay: every one of the above returns to its start value, which is the part most easily left half-reset.\n\n## Variants\n\n- Composition order is the variable. The same six pieces in a different order read as a different site, which is exactly what makes this a composite and not a component.\n- Any single piece can be dropped: the preloader and the power-on are the pair that most defines the entry, and the marquee is the most removable.\n- Compression: the observed `2000ms` preloader minimum and `3em` monitor lift are scaled down here (`1500ms`, `1.2em`) for a demo box. On a full page, use the observed values.\n\n## Code\n\n```tsx\nconst sceneVars = {\n \"--sbg\": inverted ? \"var(--foreground)\" : \"var(--background)\",\n \"--sfg\": inverted ? \"var(--background)\" : \"var(--foreground)\",\n backgroundColor: \"var(--sbg)\",\n color: \"var(--sfg)\",\n transition: INVERSION_TRANSITION,\n} as CSSProperties;\n```\n\n## Checks\n\n- Scope the inversion to a variable pair on the scene root. Writing the theme tokens themselves inverts the whole page, which is a different pattern with a different blast radius.\n- Reset every channel in the replay handler, including `scrollTop`, the clip path, the flash opacity, and the inversion flag. A partial reset is the defect this kind of scene produces most often.\n- Drive the scroll mapping from the scroll container's own rect, not the window, or the demo works only when it happens to fill the viewport.\n- Read canvas colors from CSS variables and keep a fallback literal for the case where the variable is missing, otherwise a failed lookup paints nothing and the screen goes blank.\n- Verify with time-spaced screenshots, not one capture. Sequencing defects (a phase that never advances, a flare that fires before the aperture) are invisible in a single frame.\n\n## Anti-patterns\n\n- Building a composite by stacking effects that never co-occur on the source: the result is a demo reel, not a grain. The sequence has to come from an observation row too.\n- Letting each piece own its own scroll listener: several listeners on one container drift out of step. One rAF driver reads the scroll position and fans it out.\n- Keeping the observed timings unscaled inside a small demo box: a `2000ms` preloader in a `60vh` box reads as a hang. Scale it and record what was scaled.\n- Treating the composite as the distributable unit: consumers take the individual assets. The composite exists to prove the assembly is coherent.\n\n## Agent notes\n\n- prompt_phrases: \"recreate the feel of a site's home page in one scene\", \"preloader into power-on into scroll zoom\", \"composite demo of several motion techniques in the source site's order\".\n- fallbacks: with reduced motion, skip the power-on flare and the marquee auto-scroll, hold the preloader to a single frame, and let the scroll zoom map without easing. Each constituent piece already carries its own reduced-motion path, so the composite's job is only to keep the phase machine advancing when the animations are suppressed."},{"id":"context-label-cursor","name":"Context Label Cursor","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["websites"],"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/context-label-cursor.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/context-label-cursor.md","body":"## Intent\n\nA context label cursor replaces the OS cursor inside a defined area with a small dot that morphs into an action label (View, Play, Drag) whenever it enters a target, telling the visitor what a click will do before they click. Two observed techniques compose: a fixed cursor div lerped toward the pointer (Noomo showcase, research/m52-round1/p2-noomo.json row 3) and a size expansion with an ease-back overshoot plus injected text label (NORMAL IS BORING, research/m52-round1/p2-normalisboring.json row 10, 0.75rem to 7.15rem, duration .25s ease back.out). This demo was repaired and re-judged pass in the user gate frozen in research/2026-08-15-m55-gate-verdicts.md section 1 (\"context-label-cursor | 통과\").\n\n## Anatomy\n\n- A relative-positioned area (`areaRef`) sets `cursor: none` while the pointer is inside; a fixed follower div (`cursorRef`) is absolutely positioned at (0,0) and moved with `transform: translate()`.\n- Pointer position updates on `pointermove` into a ref (`pointer`), not state, to avoid a re-render per mousemove.\n- A `requestAnimationFrame` loop lerps a second ref (`current`) toward `pointer` each frame at `LERP_FACTOR = 0.18`, then writes the transform directly to the DOM node.\n- Target elements set `activeLabel` on `pointerenter`/`pointerleave`; the follower reads `expanded = activeLabel !== null` to switch size.\n- Size interpolates via CSS transition on `width`/`height`/`opacity`, `duration-[250ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]` (an ease-back-out overshoot), between `BASE_SIZE = 12px` (0.75rem observed) and `EXPANDED_SIZE = 114px` (7.15rem observed).\n- The label text is only rendered into the DOM once `expanded` is true, so it doesn't clip during the size transition.\n\n## States\n\n- Outside area: native cursor visible, follower opacity 0.\n- Inside area, no target: follower visible at `BASE_SIZE`, tracking the pointer with lerp lag.\n- Inside area, over target: follower expands to `EXPANDED_SIZE` and shows the target's label (View / Play / Drag).\n- Leaving a target: follower shrinks back to `BASE_SIZE`; leaving the area: opacity fades to 0.\n\n## Variants\n\n- Label set: swap `TARGETS` labels/kinds per surface (View/Play/Drag is one instance, not the contract).\n- Shape: circular follower is the observed default; a rounded-rect follower with the same lerp+expand mechanics also works.\n- Icon instead of text: swap the injected `<span>` for an icon when the label needs to be language-agnostic.\n- Link-local coordinate space with bounce entry (noth.in work-link label, research/m55-round2/p2-noth-in.json row 6 + research/m56-round3/noth-in-deep.json): the label's position is relative to the hovered link's own rect (clientX - rect.left), lerped at .09 per rAF; activation happens on the first mousemove (mouseenter is a no-op), which snaps the label to the pointer then plays a back.out(1.8) entry tween (observed live: scale 0 to ~1.1 overshoot to 1); the rAF loop only stops when inactive and within .05px of rest, and duplicate mousemove coordinates skip tween updates (overwrite:'auto').\n\n## Code\n\n```tsx\nconst tick = () => {\n current.current.x += (pointer.current.x - current.current.x) * LERP_FACTOR;\n current.current.y += (pointer.current.y - current.current.y) * LERP_FACTOR;\n cursor.style.transform = `translate(${current.current.x}px, ${current.current.y}px) translate(-50%, -50%)`;\n rafId.current = requestAnimationFrame(tick);\n};\nrafId.current = requestAnimationFrame(tick);\n```\n\n## Checks\n\n- The follower div must always be mounted, not conditionally rendered on `inArea`: mounting it conditionally means `cursorRef` is null when the `useEffect` runs and the rAF loop never attaches (this was the exact defect that failed the prior gate and was fixed for this pass).\n- `pointermove`/`pointerenter`/`pointerleave` listeners and the rAF are removed/cancelled on unmount.\n- Position updates go through a ref and a direct `style.transform` write, not React state, to keep the per-frame tracking off the render path.\n- `pointer-events-none` on the follower so it never blocks the underlying target's own pointer events.\n\n## Anti-patterns\n\n- Conditionally rendering the follower node based on hover/active state: breaks ref timing and silently kills the animation loop (see Checks).\n- Driving the follower position with React state per `pointermove`: causes a re-render per mouse event instead of a single rAF-driven DOM write.\n- Skipping the lerp and snapping the follower directly to the pointer: loses the wake-like tracking feel that motivates using a custom cursor over the native one.\n\n## Agent notes\n\n- prompt_phrases: \"cursor that expands into a label over targets\", \"custom cursor shows what a click will do\", \"lerped cursor follower with action text\".\n- fallbacks: on touch devices there is no hover to trigger the label; keep `cursor: auto` and skip the follower entirely rather than showing a stuck expanded state. Reduced-motion users should keep the lerp (it's positional, not decorative) but the size-change ease-back overshoot can be swapped for a plain fade."},{"id":"crt-ascii-video","name":"CRT ASCII Video","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites"],"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/crt-ascii-video.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/crt-ascii-video.md","body":"## Intent\n\nA CRT ASCII video treatment redraws a moving image as a live grid of ASCII glyphs, frames it inside a curved tube mask, warps the grid around the pointer, and opens the whole thing with a power-on flare, so arriving at the screen is itself the effect rather than a background behind one. Observed on Revelatio Studio's hero (`scripts/video-ascii.js` L58-572, ledger `research/revelatio/p4-rows.json` demos[\"crt-ascii-video\"]). The demo was repaired twice during M58 and passed re-judgment in the gate frozen in `research/2026-08-16-m58-gate-verdicts.md`.\n\n## Anatomy\n\n- The source frame is sampled on a `cellSize = 6` grid; each cell's luminance picks a glyph from a 29-character ramp ordered dense to sparse (`\"@#W$9876543210?!abc;:+=-,._ \"`). Density, not color, carries the image.\n- Tone controls sit before the glyph lookup: `contrast 0.5`, `brightness 0.5`, `dotScale 1.6`. Pushing contrast collapses the ramp to two glyphs and the image stops reading.\n- The tube is an SDF, not an image: `tvSize = mix((1,1),(0.93,0.80),tvness)`, `tvBulge = mix(0,0.035,tvness)`, with separate top and bottom vertical bulges (`0.14` / `0.15`). A rectangular mask reads as a filter; the asymmetric bulge reads as glass.\n- Power-on runs `980ms` in two segments: `t<0.35 → (t/0.35)^0.58 * 0.28`, then `0.28 + ((t-0.35)/0.65)^1.3 * 0.72`. Two Gaussian flares ride on top (`mainFlare` at `t=0.12`, `tailFlare` at `t=0.32`), which is what makes it read as a tube warming up rather than a fade-in.\n- The aperture opens from `0.0010` to `1.0`, so the picture starts as a horizontal slit and unfolds vertically. That single value is most of the CRT association.\n- Pointer warp is a trail of points, not a single position: `mouseRadius 108`, `warpStrength 46.5`, `trailDecay 0.839`, `TRAIL_MAX 24`, with `chromaAmount 2` splitting channels inside the warp.\n\n## States\n\n- Powered off: aperture near zero, nothing legible.\n- Powering on: slit widens, two flares pass, glyph grid resolves.\n- Idle: glyphs update per frame from the source; the grid is stable.\n- Pointer inside: the grid bends around a resident warp point plus its motion trail.\n- Pointer left: the trail decays and the grid returns flat.\n- Reduced motion: hold the settled state and skip the power-on sequence.\n\n## Variants\n\n- Source: the observed original uses a background video. This asset substitutes a procedural noise gradient so it ships without a video file; any luminance source works, and a still image reduces it to a texture rather than an effect.\n- `tvness` is animated with scroll on the original (tube zoom). This asset fixes it at `1` and treats the scroll coupling as a separate pattern.\n- Glyph ramp length trades detail for texture. A short ramp reads as a stencil, a long one as a photograph made of type.\n\n## Code\n\n```tsx\n// 파워온 — 두 구간 + 가우시안 플레어 2개\nconst t = elapsed / 980;\nconst powerState = t < 0.35\n ? Math.pow(t / 0.35, 0.58) * 0.28\n : 0.28 + Math.pow((t - 0.35) / 0.65, 1.3) * 0.72;\nconst mainFlare = Math.exp(-Math.pow((t - 0.12) / 0.08, 2)) * 1.55;\nconst tailFlare = Math.exp(-Math.pow((t - 0.32) / 0.14, 2)) * 0.55;\n```\n\n## Checks\n\n- Keep one resident warp point at the current pointer position while the pointer is inside. A trail made only of movement decays in about `0.4s`, so the effect dies whenever the pointer stops. That was the defect repaired here.\n- The glyph ramp must run dense to sparse. Reversing it inverts the image and the source becomes unreadable.\n- `cellSize` is in device pixels. On a small surface a value tuned for a full viewport produces a grid too coarse to carry the image, and on a large one it costs frames for detail nobody sees.\n- Release the WebGL context and cancel the frame loop on unmount. A hero that survives a route change keeps a GPU context alive per visit.\n- Give the underlying content a text path. A picture made of glyphs is still a picture to assistive technology.\n\n## Anti-patterns\n\n- Running it behind live copy. The glyph grid is high-frequency texture and text sitting on it stops being readable, which is why the original keeps it as a hero rather than a page background.\n- Treating the power-on as a fade. Opacity from 0 to 1 over `980ms` reads as a slow page, not a tube; the slit-then-unfold aperture is the whole association.\n- Warping on raw pointer position with no trail. The distortion snaps to the cursor and reads as a bug rather than as glass.\n- Shipping it as decoration on a page that also has other motion. It is loud enough that it wants to be the only effect in view.\n- Using it where the source image carries information (a product shot, a face, a chart). The treatment destroys detail by design.\n\n## Agent notes\n\n- prompt_phrases: \"ASCII art video background\", \"CRT monitor effect for a hero\", \"retro terminal video treatment\", \"text-mode video shader\".\n- fallbacks: without WebGL, render the source frame directly with a rounded mask and skip the glyph pass. With reduced motion, hold the settled state and drop the power-on sequence and the pointer warp."},{"id":"cursor-trail-shader","name":"Cursor Trail Shader","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"tokens_used":["color.semantic.surface.muted","color.semantic.action.primary","color.semantic.text.default","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/cursor-trail-shader.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/cursor-trail-shader.md","body":"## Intent\n\nA cursor trail shader accumulates pointer history into a small offscreen intensity texture (30x30 Canvas2D) and feeds it to a WebGL noise-gradient fragment shader, where the trail's spatial derivative warps the UV field. The cursor's wake visibly bends the background. Two observed techniques compose: a shader gradient background with tuned uniforms, and the Canvas2D-to-texture trail. Observed at partizan.com (research/m52-round1/p2-partizan.json rows 1 and 2); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2. Shader colors are not shipped from the origin site: they are resolved from the project's own tokens at mount (harvest-contract restyle rule).\n\n## Anatomy\n\n- A tiny trail canvas: each pointermove pushes a point with a force derived from move distance; every frame, points age and are drawn as radial-gradient dots whose alpha follows an ease-in-then-out life curve.\n- The trail canvas uploads to a GL texture each frame; the fragment shader samples it and uses `dFdx/dFdy` of the intensity as a UV warp vector.\n- A value-noise gradient mixes three colors; grain is added per pixel; trail intensity also brightens the field slightly.\n- Token resolution: CSS custom property values are painted to a 1x1 canvas and read back as bytes, so any color format (oklch included) becomes RGB floats for uniforms.\n\n## States\n\n- Idle: gradient drifts on its own time-based noise.\n- Tracing: pointer active, trail texture non-empty, field warps along the wake.\n- Decaying: pointer left; points age out over MAX_AGE frames and the field relaxes.\n\n## Variants\n\n- Palette: swap which semantic tokens map to uColor1..3.\n- Trail-only: skip the gradient and use the intensity texture to reveal an image or video underneath.\n\n## Code\n\n```tsx\nconst color1 = resolveTokenRgb(container, \"--muted\", fallback1)\nconst color2 = resolveTokenRgb(container, \"--primary\", fallback2)\n// fragment shader:\n// float cursorIntensity = texture(uCursorTexture, vUv).r;\n// vec2 warp = vec2(dFdx(cursorIntensity), dFdy(cursorIntensity)) * 60.0 * uNoiseStrength;\n// uv += warp;\n```\n\n## Checks\n\n- Trail texture stays tiny (about 30px square); uploading a large canvas per frame kills frame rate.\n- rAF cancelled, GL resources (texture, buffer, program) deleted on unmount.\n- DPR capped (about 2) when sizing the drawing buffer.\n- No origin-site palette in uniforms; colors come from tokens at mount.\n\n## Anti-patterns\n\n- Drawing the trail into the full-resolution canvas: the sprite-sized intensity map is the entire performance budget of this pattern.\n- Hardcoding shader RGB floats: origin brand colors leaking into an asset is a restyle violation and invisible to CSS-level lint.\n- Skipping shader compile/link error checks: a silent black canvas with no console signal.\n\n## Agent notes\n\n- prompt_phrases: \"cursor wake distorts background\", \"webgl gradient with pointer trail\", \"shader background from tokens\".\n- fallbacks: when WebGL2 is unavailable the component renders nothing behind content; page remains fully usable. Reduced-motion users should get a static gradient (no warp loop)."},{"id":"declarative-reveal-engine","name":"Declarative Reveal Engine","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/declarative-reveal-engine.tsx","component_refs":["scroll-driven-reveal"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/declarative-reveal-engine.md","body":"## Intent\n\nA declarative reveal engine moves entry animation out of component code and into markup attributes. An element marked `line` reveals line by line, `letter` reveals per character, `opacity` and `scale` are the plain cases, and a single scanner finds all of them after the fonts have settled. The payoff is that adding a reveal costs one attribute instead of one wiring job, and every reveal on the site shares the same timing vocabulary by construction. Observed on noth.in, where one scanner function handles four attribute APIs (research/m56-round3/noth-in-disposition.json row p2-noth-in.json rows[7], deepened in noth-in-deep.json d3[0], d3[1], d3[3], d3[4]). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Four attribute APIs, each with its own parameters: `line` (`duration 1.2`, `power4.inOut`, `stagger 0.05`), `letter` (`duration 1.2`, `power4.inOut`, `stagger 0.03`), `opacity` (`duration 1`, `power3.out`), `scale` (`0 to 1`, `duration 1`, `power3.out`).\n- Initial hiding is CSS, not JavaScript: the attribute selectors themselves set `opacity: 0` (and `scale(0)`), so nothing flashes before the scanner runs.\n- The font gate is a race, not a wait: the scanner runs when `document.fonts.ready` resolves or after a `1000ms` timeout, whichever comes first. Without it, line splitting measures the fallback face and the line breaks move when the real font arrives.\n- Line splitting measures with a hidden probe: words are laid out in a throwaway span and grouped by their offset positions, so the split follows the real wrap rather than a guess.\n- Descender clipping is corrected in pairs: each split child gets `padding-bottom: 0.15em` and its mask gets `margin-bottom: -0.15em`, which keeps the g and y tails visible without changing line height.\n- Teardown is explicit. On a route change the engine kills its tweens and scroll triggers, reverts the splits, clears the init marker, and rescans, because split DOM left in place double-splits on the next pass.\n\n## States\n\n- `idle`: elements hidden by their attribute selector, scanner not yet run.\n- `gate-pending`: waiting on the font race, still hidden.\n- `revealed`: the tween for each attribute type has played.\n- Gate resolution is exposed separately as `ready` or `timeout`, so a slow font network is visible in the DOM rather than silent.\n\n## Variants\n\n- Trigger: immediate on scan (this demo) or on scroll entry per element. The observed original had four trigger values on the same attribute vocabulary.\n- Split unit: line, letter, or word. Letter splitting on long copy is expensive and rarely worth it past a headline.\n- Masking: each split child can be wrapped in an `overflow: hidden` mask so lines rise from behind an edge, which is where the descender correction pair earns its place.\n\n## Code\n\n```tsx\n// fonts.ready 와 타임아웃의 레이스 — 폰트 교체가 줄 분할을 어긋내는 것을 막는다\nconst gate = (run: () => void) => {\n let done = false;\n const fire = (state: GateState) => { if (!done) { done = true; setGate(state); run(); } };\n document.fonts?.ready.then(() => fire(\"ready\"));\n window.setTimeout(() => fire(\"timeout\"), FONTS_GATE_TIMEOUT_MS);\n};\n```\n\n## Checks\n\n- Hide with CSS selectors on the attributes themselves. Hiding in an effect guarantees a visible flash of unstyled content on a slow frame.\n- Mark scanned elements with a dataset flag and clear it on teardown, or a re-scan splits already-split DOM.\n- Keep the font race timeout: waiting on `fonts.ready` alone means a font that never resolves leaves the page permanently invisible.\n- Revert splits before unmount so the original text nodes come back, otherwise text selection and copy behave oddly for the rest of the session.\n- Verify a paragraph with descenders. The padding and margin pair is invisible until a `g` gets clipped.\n\n## Anti-patterns\n\n- Wiring reveals per component: the parameters drift within a month and the site loses one shared entry rhythm, which is the whole reason for the engine.\n- Splitting lines before the fonts are ready: the split is measured against the fallback face and the animation reveals lines that no longer match the rendered wrap.\n- Correcting descender clipping by increasing line height: it changes the typography to fix an animation artifact. Compensate with the padding and margin pair instead.\n- Leaving the engine running across client-side navigations without teardown: tweens and triggers accumulate and the second page animates twice.\n\n## Agent notes\n\n- prompt_phrases: \"reveal animation declared with an HTML attribute\", \"one engine for line, letter, opacity and scale reveals\", \"split text reveal that waits for fonts\".\n- fallbacks: with reduced motion, run the scanner but set the final state immediately, so elements are visible without motion. If `document.fonts` is unavailable, the timeout branch alone is a correct fallback."},{"id":"deco-equalizer","name":"Deco Equalizer","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"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/deco-equalizer.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/deco-equalizer.md","body":"## Intent\n\nA decorative equalizer animates a row of bars with randomized CSS keyframe durations and negative delays so they read as live audio levels while being pure decoration: no audio analysis, one paused/running play state. The negative delays start every bar mid-cycle, which is what sells the \"already playing\" illusion. Observed at wrapped-party.activetheory.dev (research/m52-round1/p2-wrapped-party.json row 7, keyframes pulse/muted on scaleY); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- N bars, `origin-bottom`, full height, animated only on `transform: scaleY` (compositor-friendly).\n- Two keyframe profiles: a lively pulse and a subdued \"muted\" profile assigned to some bars for texture.\n- Per-bar randomized duration (0.9s..1.7s) and negative delay (0..-1.5s), generated once on mount so first paint stays deterministic.\n- Play state maps to `animation-play-state`.\n\n## States\n\n- Running: bars cycling out of phase.\n- Paused: bars frozen mid-pose via play-state (not reset to a uniform height).\n- Reduced motion: honor `prefers-reduced-motion` by rendering a static mid-height pose.\n\n## Variants\n\n- Bar count and gap density per surface; color via any semantic foreground token.\n- True audio-reactive upgrade: swap the keyframes for AnalyserNode-driven heights; the markup contract is unchanged.\n\n## Code\n\n```tsx\nstyle={{\n animationName: bar.muted ? \"deco-eq-muted\" : \"deco-eq-pulse\",\n animationDuration: `${bar.duration}s`,\n animationDelay: `${bar.delay}s`, // negative: starts mid-cycle\n animationIterationCount: \"infinite\",\n animationPlayState: playing ? \"running\" : \"paused\",\n}}\n```\n\n## Checks\n\n- Animation is transform-only (no width/height layout thrash).\n- Bar randomization happens client-side once, not per render.\n- The component is honest about being decorative: `aria-hidden` on the bar row when adjacent to real audio UI.\n\n## Anti-patterns\n\n- Wiring a real AnalyserNode \"because it is more correct\": the observed pattern is deliberately fake; analysis costs an AudioContext and a permission story for zero visual gain.\n- Uniform durations/delays: bars sync up within seconds and the illusion collapses.\n- Animating height instead of scaleY: forces layout every frame.\n\n## Agent notes\n\n- prompt_phrases: \"fake equalizer bars\", \"decorative audio visualizer\", \"music-feel animation without audio\".\n- fallbacks: for reduced motion render a static pose; never keep infinite motion running against that preference."},{"id":"diagonal-clip-reveal","name":"Diagonal Clip Reveal","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"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/diagonal-clip-reveal.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/diagonal-clip-reveal.md","body":"## Intent\n\nA diagonal clip-path reveal wipes content in along a slanted polygon edge, pure CSS keyframes on `clip-path`. Three observed variants share the grammar: a hero title reveal, a full-menu diagonal wipe (open and close), and a Ken Burns zoom with a clipped title on top. Observed at bleach-anime.com (research/m52-round1/p2-bleach-anime.json rows for hero, menu, and streaming-selector reveals); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Each variant is a keyframe pair on `clip-path: polygon(...)` where one vertex sweep opens the visible area diagonally.\n- Opacity snaps from 0 to 1 at the 1% frame so the element never flashes pre-clip.\n- The menu wipe animates a full overlay panel; open and close use different durations (0.7s open, 0.3s close) matching the observed asymmetry.\n- Ken Burns variant layers a 5s scale(1.1 to 1) zoom under the 0.8s clipped title.\n\n## States\n\n- Hidden: zero-area polygon plus opacity 0.\n- Revealing: polygon sweeping; `forwards` fill keeps the end state.\n- Open/Closed (menu): class swap chooses the open or close animation.\n- Replayed: a React key bump restarts the CSS animation cleanly.\n\n## Variants\n\n- Direction: mirror the polygon coordinates for right-to-left or bottom-up sweeps.\n- Stacked reveals: stagger multiple clipped elements with animation-delay for editorial sequences.\n\n## Code\n\n```css\n@keyframes dcr-hero-reveal {\n 0% { clip-path: polygon(0 0, 0 0, 0 100%, 0% 100%); opacity: 0; }\n 1% { opacity: 1; }\n 100% { clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%); opacity: 1; }\n}\n```\n\n## Checks\n\n- Replay works repeatedly (key-based remount, not toggling a class on a live node).\n- The 1% opacity snap exists in every reveal keyframe (prevents a pre-clip flash).\n- Menu close is faster than open (observed asymmetry; equal timings feel sluggish).\n- No layout properties animate; clip-path and transform only.\n\n## Anti-patterns\n\n- Animating width/height with overflow hidden to fake a wipe: cheaper-looking edge and layout cost.\n- Easing the Ken Burns zoom with a bouncy curve: the observed grammar is a settling decelerate; overshoot reads as parody.\n- Reusing one keyframe name across variants with different polygons: browsers key by name; collisions produce half-updated reveals.\n\n## Agent notes\n\n- prompt_phrases: \"diagonal wipe reveal\", \"clip-path title entrance\", \"menu wipe open\".\n- fallbacks: reduced motion gets opacity-only fades at the same timings."},{"id":"directional-hover-fill","name":"Directional Hover Fill","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/directional-hover-fill.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/directional-hover-fill.md","body":"## Intent\n\nA directional hover fill detects which edge the pointer entered from and slides the fill layer in from that edge, then back out toward the exit edge on leave. Buttons feel aware of cursor motion instead of playing one canned hover. Observed at normalisboring.es (research/m52-round1/p2-normalisboring.json row 6, rollovers.js:105-160 and main.css:855-894, 102% offsets on CSS custom properties); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- A relatively positioned button with `overflow-hidden` and an absolutely positioned fill layer behind the label (`-z-10`).\n- Nearest-edge math on enter/leave: compare pointer distance to all four rect edges.\n- The fill position lives in two CSS custom properties (`--pos-x`/`--pos-y`) driven by GSAP; 102% offsets guarantee full off-screen parking without seams.\n- Label color flips via group-hover to keep contrast over the fill.\n\n## States\n\n- Rest: fill parked off one edge (102% out).\n- Enter: fill tweens from the entry edge to 0/0 (0.5s power3.out).\n- Leave: fill tweens out toward the exit edge, which may differ from the entry edge.\n- Re-enter mid-leave: the new tween overwrites; GSAP handles the interruption continuously.\n\n## Variants\n\n- Vertical-only or horizontal-only variants for list rows and nav items.\n- Color pairs: any surface/foreground token pair with sufficient contrast for the label flip.\n\n## Code\n\n```tsx\nconst offset = edgeOffset(nearestEdge(e, rect))\ngsap.set(fill, { \"--pos-x\": offset.x, \"--pos-y\": offset.y })\ngsap.to(fill, { \"--pos-x\": \"0%\", \"--pos-y\": \"0%\", duration: 0.5, ease: \"power3.out\" })\n```\n\n## Checks\n\n- Enter from each of the four edges fills from that edge (four-direction check).\n- Exit direction follows the leave edge, not the remembered entry edge.\n- Custom properties are kebab-case (uppercase breaks static var extraction and CSS convention).\n- Fill layer is behind the label and clipped by `overflow-hidden`.\n\n## Anti-patterns\n\n- Transitioning `background-position` or width for the fill: directionality is the point; a one-direction sweep reads as a generic hover.\n- Computing direction from mousemove deltas: enter/leave rect math is cheaper and stable at edges.\n- Leaving the fill color hardcoded: the fill is the brand moment, it must ride the token system.\n\n## Agent notes\n\n- prompt_phrases: \"hover fill from cursor direction\", \"directional button rollover\", \"edge-aware hover\".\n- fallbacks: on touch surfaces (no hover), keep the pressed state as a plain fill; do not simulate directions."},{"id":"dual-mode-line-reveal","name":"Dual Mode Line Reveal","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/dual-mode-line-reveal.tsx","component_refs":["declarative-reveal-engine"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/dual-mode-line-reveal.md","body":"## Intent\n\nA dual mode line reveal lets each line of a passage declare how it wants to arrive. Two lines wipe in behind a curtain, the third resolves out of blur, and both modes are driven by one entrance stage shared with the illustration beside them. The reason to mix modes inside one passage is emphasis: when every line enters identically, the reader gets rhythm but no hierarchy, and switching the mode on the closing line marks it without changing its type. Observed on bleach-mh's introduction section, where a data array assigns `curtain` or `blur` per line number (research/m56-round3/bleach-mh-deep.json d3[0] and d3[17]). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- The line list is data: `[{ n: 1, reveal: \"curtain\" }, { n: 2, reveal: \"curtain\" }, { n: 3, reveal: \"blur\" }]`. The mode is an attribute on the line, not a branch in the animation code.\n- Each line renders with `data-line` and `data-reveal`, so the mode is inspectable in the DOM and styleable from CSS.\n- Curtain mode animates a mask edge across the line; blur mode animates `filter: blur()` toward zero with opacity. They share the duration and the stagger, which is what keeps the two modes reading as one system.\n- One `IntersectionObserver` with `rootMargin: \"0px 0px -10% 0px\"` decides the entrance stage. The negative bottom margin means the passage starts once it is properly on screen rather than at the moment its first pixel appears.\n- The illustration next to the text consumes the same stage value, so the image and the copy enter together rather than in two negotiated timings.\n- The source's exact curtain and blur keyframes were not recoverable from the observed CSS bundle. The demo picks values that make the contrast legible (`0.6s`, `0.18s` stagger, `power2.out`) and records that they are demo choices, while the array order, mode split, and stage sharing come from the observation.\n\n## States\n\n- Before the stage fires: all lines hidden, illustration hidden.\n- Entering: lines animate in list order with the stagger, each in its own mode.\n- Entered: text at rest, no residual filter or mask left on the nodes.\n- Reduced motion: the stage fires and everything is placed in its final state immediately.\n\n## Variants\n\n- Mode set: curtain and blur here. Any pair works as long as the two share timing; three or more modes in one passage stops reading as emphasis.\n- Stage sharing: text alone, or text plus adjacent media (observed). Sharing is what makes the section feel composed rather than sequenced.\n- Trigger margin: the `-10%` bottom margin is a taste decision about how early the reveal starts.\n\n## Code\n\n```tsx\nconst LINES: { n: number; reveal: RevealType; text: string }[] = [\n { n: 1, reveal: \"curtain\", text: \"...\" },\n { n: 2, reveal: \"curtain\", text: \"...\" },\n { n: 3, reveal: \"blur\", text: \"...\" },\n];\n```\n\n## Checks\n\n- Clear the blur filter completely at the end. A residual sub-pixel blur leaves text permanently soft, and it is easy to miss on a high-density display.\n- Fire the observer once and disconnect. A passage that re-reveals on every scroll pass reads as a glitch.\n- Keep the text in the DOM as text through the whole animation, so it is selectable and readable by assistive technology from the start.\n- Share one stage value between the text and the media. Two observers on the same section will disagree by a frame or more.\n- Verify at small widths, where the line count changes with wrapping and per-line modes may land on unintended lines.\n\n## Anti-patterns\n\n- Branching the animation inside the component rather than declaring the mode in data: adding a fourth line then means editing code instead of the list.\n- Giving each mode its own duration: the passage stops reading as one entrance.\n- Using blur mode on small text: blurred small type reads as a rendering fault, and the resolve is too subtle to register.\n- Revealing the illustration on its own observer: the composition splits into two events.\n\n## Agent notes\n\n- prompt_phrases: \"text lines that reveal in different ways\", \"curtain wipe and blur resolve in one passage\", \"line by line reveal with per-line mode\".\n- fallbacks: with reduced motion, show the final state directly. If `IntersectionObserver` is unavailable, reveal on mount instead of leaving the passage hidden."},{"id":"edge-flare-lines","name":"Edge Flare Lines","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites","documentation"],"tokens_used":["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/edge-flare-lines.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/edge-flare-lines.md","body":"## Intent\n\nEdge flare lines fill an otherwise empty hero panel with slow directional motion that points at the headline instead of competing with it. Ten light streaks enter from the four edges of the panel and travel toward the center, but each one stops somewhere between 28% and 98% of the way there, so the streaks gather around the middle without ever piling onto a single point. That partial travel is the whole trick: a converging animation that actually converges reads as a target reticle, while one that stops short reads as ambient depth. Observed on inspiring.nk.studio (research/m58-round4/p2-nk-studio.json rows[0], ledger id r4-01), where the generator `createDefaultRandomCenterFlares` was read directly out of chunk `0ogyncvab3r8x.js`. The original renderer loop lived outside that chunk, so the drawing code here is reconstructed from the captured parameters rather than copied. Colors are this project's semantic tokens, not the source site's palette.\n\n## Anatomy\n\n- Ten lines (`lineCount = 10`). Each start point is drawn from one of the four edges with equal probability, placed slightly outside the panel so nothing pops into existence on screen.\n- The target is sampled uniformly from an ellipse around the center, `x ± 30` and `y ± 18` in percent, using `radius = sqrt(random())` so the samples spread evenly across the disc instead of clustering at the middle.\n- The actual end point is a partial interpolation from start to target at `t = 0.28` to `0.98`. This is what keeps the streaks from converging on one pixel.\n- Two variants: a curved one at probability `0.2` (`curveAmount 5` to `18`, `waveAmplitude 12` to `28`) and a straight one (`curveAmount 0`, `waveAmplitude 10` to `26`, `waveFrequency 1.55` to `2.85`). The offset is applied along the path normal and wrapped in `sin(pi * p)` so both ends stay pinned.\n- Each flare is a head plus a tail of `tailLength = 24` segments spanning `0.06 * trailLengthMul` (`trailLengthMul = 3`) of the path, with a `blur(1px)` filter over the whole flare.\n- Head size breathes from `0.72` to `0.88` up to `1.15` to `1.65` across the traversal, following `sin(head * pi)`, so a streak is brightest mid-flight.\n- Per-line speed is `min 0.0024` to `0.0038` plus `0.0014` to `0.0034`, then modulated each frame by two out-of-phase sines. Ten lines on identical speeds would visibly beat together.\n- The path itself is drawn at `opacity 0.07`. In the source it was hidden entirely (`show: false`); the demo keeps it faintly visible so the structure can be read.\n- Restyle: streak body and core use `--foreground`, the halo and the guide path use `--muted-foreground`, the stage is `bg-muted` inside a `border-border` panel. The head glow, a radial gradient in the source, is drawn here as eight concentric discs with accumulating alpha so no color literal is needed.\n\n## States\n\n- Idle loop: all ten lines run continuously, each restarting at a random negative offset (`progress -= 1 + random() * 0.35`) so they never re-sync after the first cycle.\n- Mid-flight: head at maximum size, tail at full length.\n- Near the end point: the head shrinks back toward its minimum and the streak fades out where it stops, short of the center.\n- Reduced motion: one static frame with the guide paths and a single head position per line, so the panel is not blank.\n- Theme change: the resolved token colors are re-read on `data-theme` or `class` mutations and on `prefers-color-scheme` changes.\n\n## Variants\n\n- Line count: fewer than about six stops reading as a field and starts reading as individual objects. More than about sixteen turns the center into a bright blob.\n- Target ellipse: a wider `x` spread than `y` (the observed 30 by 18) matches landscape panels. On a square or portrait panel the two radii should move toward each other.\n- Curve probability: at `0.2` the curved lines read as variation. Above roughly half they read as a swirl, which is a different effect.\n- Guide path visibility: hidden (as in the source) is the ambient reading; visible at `0.07` is the explanatory reading used here.\n\n## Code\n\n```tsx\n// 목표는 중심이지만 실제 끝점은 경로의 28~98% 지점이다 — 이 부분 보간이 핵심\nconst t = rand(0.28, 0.98)\nconst endX = clamp(startX + (tx - startX) * t, -4, 104)\nconst endY = clamp(startY + (ty - startY) * t, -4, 104)\n\n// 중심 근방 타원 디스크의 uniform 샘플: sqrt(random()) 이 균등 분포를 만든다\nconst angle = Math.random() * Math.PI * 2\nconst radius = Math.sqrt(Math.random())\nconst tx = clamp(50 + 30 * radius * Math.cos(angle), 4, 96)\nconst ty = clamp(50 + 18 * radius * Math.sin(angle), 4, 96)\n\n// 색은 런타임에 토큰에서 읽는다 — canvas 에는 클래스를 걸 수 없다\nconst flareColor = resolveTokenColor(root, \"--foreground\", \"black\")\nconst haloColor = resolveTokenColor(root, \"--muted-foreground\", \"gray\")\n```\n\n## Checks\n\n- Sample the target disc with `sqrt(random())`. Using `random()` directly concentrates the end points at the center and the effect collapses back into a reticle.\n- Keep the `0.28` to `0.98` partial travel. Ending every line at its target is the single change that makes this look like a loading spinner.\n- Give every line its own speed and phase. Shared timing makes the ten streaks pulse in unison, which reads as a machine, not as ambience.\n- Wrap the wave offset in `sin(pi * p)` so the deviation is zero at both ends. Without it the streaks visibly start and end off their own path.\n- Start points must sit outside the panel bounds (the observed ranges run to `-2` and `102`) or heads appear from nowhere at the edges.\n- Redraw with `clearRect` every frame. Compositing without clearing turns the accumulating tails into permanent smears.\n- Re-read the token colors when the theme flips. A canvas keeps whatever color string it was given at mount.\n- Cancel the animation frame and disconnect the resize and theme observers on unmount.\n\n## Anti-patterns\n\n- Carrying the source site's palette across. The dark stage and the cyan accent are that studio's brand identity, and the recipe is the geometry, not the color.\n- Putting body copy over the panel. The streaks cross the center, which is exactly where a paragraph would sit; only a short display line survives on top of this.\n- Running it full bleed behind an entire page. The parameters are tuned for a bounded panel, and the percentage based target ellipse smears into a band at page width.\n- Raising the guide path opacity to make the structure clearer. Above roughly `0.15` the ten static lines become the dominant graphic and the motion stops being the subject.\n- Adding a glow via `shadowBlur` per segment. With ten lines and twenty four tail segments each that is 240 blurred draws per frame, and the panel drops frames on mid range hardware.\n\n## Agent notes\n\n- prompt_phrases: \"light streaks converging toward the center of a hero\", \"ambient flare lines coming in from the edges\", \"background motion that points at the headline\".\n- fallbacks: with reduced motion, draw one static frame including the guide paths so the panel is not empty. Without a 2D context, leave the panel as a plain `bg-muted` surface.\n- token contract: this asset resolves `--foreground` and `--muted-foreground` at runtime for the canvas, and uses `bg-muted`, `border-border`, and `rounded-lg` for the DOM chrome. No color literal appears in the source."},{"id":"entrance-three-stage","name":"Entrance Three Stage","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.border.default","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/entrance-three-stage.tsx","component_refs":[],"term_refs":["loading-state","progress-bar"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/marketing/entrance-three-stage.md","body":"## Intent\n\nThree entrance stages of different durations run concurrently and pass the baton while still moving, so the arrival reads as one breath. Run them sequentially and the same three effects read as three waits.\n\nObserved at https://summer-afternoon.vlucendo.com/ (SMA-02 + SMA-04 + SMA-05, accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Loader: an SVG stroke-dash arc that grows and shrinks while the whole ring rotates, two animations at once, leaving over 750ms with a 250ms removal tail.\n- Crossfade: a full-bleed overlay ramps out linearly over four seconds, standing in for the source's intro-material swap.\n- Pull-back: the stage scales from 1.6 to 1 over six seconds on a cubic in-out approximation of the observed easing.\n- A three-row timeline visualizes the overlap, and an overlap-off toggle queues the same stages sequentially for comparison.\n\n## Pitfalls\n\n- Entrance choreography is unjudgeable without a replay control, since it plays once.\n- Sequential execution makes total time the sum of all three and the seams become visible.\n\n## States\n\n- 재생 중 — 세 단계가 서로 다른 길이로 겹쳐 돈다.\n- 겹침 끔 — 같은 세 단계가 순차로 줄 선다.\n- 완료 — 본 콘텐츠만 남는다.\n- 모션 축소 — 완료 상태로 시작한다.\n\n## Variants\n\n- 겹침 / 순차 두 배치.\n- 0.25x~2x 속도 배율.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/entrance-three-stage.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 세 단계를 순차로 이어 총 대기 시간을 합으로 만드는 것.\n- 재생 컨트롤 없이 한 번만 보여 주는 것.\n\n## Agent notes\n\n세 단계의 길이는 관측값이고, 카메라 풀백을 CSS scale 로 옮긴 것은 티어 하향 판단이다(three.js 도입 없이 같은 인상).\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--foreground`\n- `--muted`\n- `--muted-foreground`\n- `--radius`","code_source":"// 출처: research/m68/p2-summer-afternoon.json SMA-02 + SMA-04 + SMA-05 (합성)\n// SMA-05 로더: @keyframes loaedrrotator rotate(0→270deg) linear infinite 2.5s\n// + @keyframes loaderdash stroke-dashoffset 187→46.75(+rotate135deg)→187 ease-in-out infinite (동시 진행)\n// 아웃트로 duration 750ms, 커스텀 이징 t<.5?4t³:.5*(2t-2)³+1, 이후 setTimeout 250ms 뒤 완전 제거.\n// 본 콘텐츠는 로더가 사라지는 동안 이미 같은 자리에 겹쳐 마운트되어 있다(순차 아님).\n// SMA-04 셰이더 크로스페이드: gsap.to(uTransition,{value:1,duration:4,ease:\"none\"}) 종료 후 본 머티리얼 교체\n// SMA-02 카메라 줌-풀백: followSphericalZoom 12→0, ease:\"inOut3\", duration:6, delay:0\n// 세 단계(750ms/4s/6s)가 서로 다른 길이로 \"동시에\" 진행되며 겹친다 — 이 겹침 자체가 합성 데모의 요점.\n// visual: 로더는 SVG stroke-dasharray/offset 이중 애니메이션, 셰이더 크로스페이드는 opacity 블렌드,\n// 카메라 줌-풀백은 CSS scale + transform-origin으로 표현(3D 미사용).\nimport { useEffect, useRef, useState } from \"react\";\n\n// 관측값 그대로: 세 단계 duration(ms)\nconst LOADER_DURATION = 750;\nconst LOADER_REMOVE_DELAY = 250;\nconst CROSSFADE_DURATION = 4000;\nconst ZOOM_DURATION = 6000;\n\n// SMA-05 아웃트로 커스텀 이징: t<.5 ? 4t³ : .5*(2t-2)³+1\nfunction loaderOutEasing(t: number) {\n return t < 0.5 ? 4 * t * t * t : 0.5 * Math.pow(2 * t - 2, 3) + 1;\n}\n\n// gsap ease:\"none\" (linear)\nfunction linear(t: number) {\n return t;\n}\n\n// gsap ease:\"inOut3\" 근사 (cubic in-out)\nfunction inOut3(t: number) {\n return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;\n}\n\ntype StageKey = \"loader\" | \"shader\" | \"camera\";\n\nconst STAGE_DEFS: { key: StageKey; label: string; duration: number }[] = [\n { key: \"loader\", label: \"로더 (750ms)\", duration: LOADER_DURATION + LOADER_REMOVE_DELAY },\n { key: \"shader\", label: \"셰이더 크로스페이드 (4s)\", duration: CROSSFADE_DURATION },\n { key: \"camera\", label: \"카메라 줌-풀백 (6s)\", duration: ZOOM_DURATION },\n];\n\nexport default function EntranceThreeStage() {\n const [reducedMotion, setReducedMotion] = useState(false);\n const [speed, setSpeed] = useState(1);\n const [overlapOff, setOverlapOff] = useState(false);\n const [playId, setPlayId] = useState(0);\n const [now, setNow] = useState(0); // ms elapsed since this play started\n\n const rafRef = useRef<number | null>(null);\n const startRef = useRef<number>(0);\n\n useEffect(() => {\n const mql = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n setReducedMotion(mql.matches);\n const handleChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches);\n mql.addEventListener(\"change\", handleChange);\n return () => mql.removeEventListener(\"change\", handleChange);\n }, []);\n\n // stage start offsets (ms, at speed=1): overlapping = all start at 0;\n // sequential = each starts after the previous ends\n const offsets = (() => {\n if (overlapOff) {\n let acc = 0;\n const result: Record<StageKey, number> = { loader: 0, shader: 0, camera: 0 };\n for (const s of STAGE_DEFS) {\n result[s.key] = acc;\n acc += s.duration;\n }\n return result;\n }\n return { loader: 0, shader: 0, camera: 0 };\n })();\n\n const totalDuration = overlapOff\n ? STAGE_DEFS.reduce((sum, s) => sum + s.duration, 0)\n : Math.max(...STAGE_DEFS.map((s) => s.duration));\n\n useEffect(() => {\n if (reducedMotion) {\n setNow(totalDuration);\n return;\n }\n\n setNow(0);\n startRef.current = performance.now();\n\n const tick = (t: number) => {\n const elapsed = (t - startRef.current) * speed;\n if (elapsed >= totalDuration) {\n setNow(totalDuration);\n rafRef.current = null;\n return;\n }\n setNow(elapsed);\n rafRef.current = requestAnimationFrame(tick);\n };\n rafRef.current = requestAnimationFrame(tick);\n\n return () => {\n if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);\n rafRef.current = null;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [playId, speed, overlapOff, reducedMotion]);\n\n const replay = () => setPlayId((id) => id + 1);\n\n // progress per stage: 0..1 clamped, based on its own offset+duration\n const progress = (key: StageKey) => {\n const def = STAGE_DEFS.find((s) => s.key === key)!;\n const start = offsets[key];\n const local = now - start;\n if (local <= 0) return 0;\n if (local >= def.duration) return 1;\n return local / def.duration;\n };\n\n const loaderP = progress(\"loader\"); // 0..1 across 1000ms (750 anim + 250 removal)\n const loaderAnimP = Math.min(loaderP * (STAGE_DEFS[0].duration / LOADER_DURATION), 1);\n const loaderVisible = loaderP < 1;\n const loaderOpacity = loaderP >= LOADER_DURATION / STAGE_DEFS[0].duration\n ? 1 - (loaderP - LOADER_DURATION / STAGE_DEFS[0].duration) / (LOADER_REMOVE_DELAY / STAGE_DEFS[0].duration)\n : 1;\n const loaderEase = loaderOutEasing(loaderAnimP);\n\n const shaderP = linear(progress(\"shader\")); // uTransition 0..1\n const cameraP = inOut3(progress(\"camera\")); // followSphericalZoom 12->0 normalized\n\n // camera: scale from 1.6 (zoomed in / \"당겨진\") down to 1 (normal), via transform-origin center\n const cameraScale = 1.6 - 0.6 * cameraP;\n\n const stageActive = (key: StageKey) => {\n const start = offsets[key];\n const def = STAGE_DEFS.find((s) => s.key === key)!;\n return now >= start && now < start + def.duration;\n };\n\n return (\n <div className=\"flex w-full flex-col gap-4 rounded-lg border border-border bg-background p-6\" style={{ minHeight: 320, maxHeight: 420 }}>\n <div className=\"flex flex-wrap items-center justify-between gap-3\">\n <p className=\"text-sm font-medium text-foreground\">진입 3단 합성 (로더 + 셰이더 크로스페이드 + 카메라 줌-풀백)</p>\n <div className=\"flex flex-wrap items-center gap-2\">\n <label className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <input\n type=\"checkbox\"\n data-testid=\"overlap-toggle\"\n checked={overlapOff}\n onChange={(e) => setOverlapOff(e.target.checked)}\n />\n 겹침 끄기 (순차 실행)\n </label>\n <label className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n 속도\n <input\n type=\"range\"\n data-testid=\"speed-slider\"\n min={0.25}\n max={2}\n step={0.25}\n value={speed}\n onChange={(e) => setSpeed(Number(e.target.value))}\n aria-label=\"재생 속도 배율\"\n />\n <span className=\"font-mono\">{speed.toFixed(2)}x</span>\n </label>\n <button\n type=\"button\"\n data-testid=\"replay-button\"\n onClick={replay}\n className=\"rounded-md border border-border bg-muted px-3 py-1.5 text-xs text-foreground hover:bg-border\"\n >\n 다시 재생\n </button>\n </div>\n </div>\n\n {/* 무대: 본 콘텐츠 + 셰이더 크로스페이드 오버레이 + 로더, 카메라 스케일은 무대 전체에 적용 */}\n <div className=\"relative flex-1 overflow-hidden rounded-lg border border-border bg-muted\">\n <div\n className=\"absolute inset-0 flex items-center justify-center\"\n style={{\n transform: `scale(${cameraScale})`,\n transformOrigin: \"center center\",\n }}\n >\n {/* 본 콘텐츠: 카드 + 텍스트 블록 스탠드인 */}\n <div className=\"flex gap-4 p-6\">\n <div className=\"flex h-28 w-40 flex-col justify-between rounded-md border border-border bg-background p-3\">\n <div className=\"h-3 w-3/4 rounded bg-foreground/70\" />\n <div className=\"h-2 w-1/2 rounded bg-muted-foreground/60\" />\n </div>\n <div className=\"flex h-28 w-40 flex-col justify-between rounded-md border border-border bg-background p-3\">\n <div className=\"h-3 w-2/3 rounded bg-foreground/70\" />\n <div className=\"h-2 w-1/3 rounded bg-muted-foreground/60\" />\n </div>\n </div>\n </div>\n\n {/* 셰이더 크로스페이드: 인트로 표면 -> 본 장면, opacity 블렌드로 표현 */}\n <div\n className=\"pointer-events-none absolute inset-0 bg-foreground\"\n style={{ opacity: 0.5 * (1 - shaderP) }}\n data-testid=\"shader-crossfade\"\n data-progress={shaderP.toFixed(2)}\n />\n\n {/* 로더: 두 keyframe(회전 + 대시) 동시 안무 */}\n {loaderVisible && (\n <div\n className=\"absolute inset-0 flex items-center justify-center bg-background\"\n style={{ opacity: loaderOpacity }}\n data-testid=\"loader-overlay\"\n >\n <svg\n width=\"48\"\n height=\"48\"\n viewBox=\"0 0 48 48\"\n aria-hidden\n style={{ transform: `rotate(${loaderEase * 270}deg)` }}\n >\n <circle\n cx=\"24\"\n cy=\"24\"\n r=\"18\"\n fill=\"none\"\n strokeWidth=\"4\"\n className=\"stroke-foreground\"\n strokeDasharray={187}\n strokeDashoffset={\n loaderEase < 0.5\n ? 187 - (187 - 46.75) * (loaderEase / 0.5)\n : 46.75 + (187 - 46.75) * ((loaderEase - 0.5) / 0.5)\n }\n style={{\n transform: loaderEase >= 0.5 ? `rotate(135deg)` : undefined,\n transformOrigin: \"center\",\n }}\n />\n </svg>\n </div>\n )}\n </div>\n\n {/* 타임라인: 세 단계가 겹치는지 순차인지 가로 막대로 표시 */}\n <div className=\"flex flex-col gap-1.5\" data-testid=\"timeline\">\n {STAGE_DEFS.map((def) => {\n const start = offsets[def.key];\n const leftPct = (start / totalDuration) * 100;\n const widthPct = (def.duration / totalDuration) * 100;\n const cursorPct = Math.min((now / totalDuration) * 100, 100);\n return (\n <div key={def.key} className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <span className=\"w-40 shrink-0 font-mono\">{def.label}</span>\n <div className=\"relative h-3 flex-1 rounded bg-muted\">\n <div\n className=\"absolute inset-y-0 rounded bg-foreground/60\"\n style={{ left: `${leftPct}%`, width: `${widthPct}%` }}\n data-active={stageActive(def.key)}\n />\n <div\n className=\"absolute inset-y-0 w-px bg-foreground\"\n style={{ left: `${cursorPct}%` }}\n aria-hidden\n />\n </div>\n </div>\n );\n })}\n </div>\n\n <p className=\"text-sm text-muted-foreground\">\n 로더(750ms) · 셰이더 크로스페이드(4s) · 카메라 줌-풀백(6s)이 서로 다른 길이로 동시에\n 진행되며 바통을 넘깁니다. &quot;겹침 끄기&quot;로 순차 실행과 비교해 보세요. 속도 슬라이더로\n 6초 전체를 기다리지 않고도 관측할 수 있습니다.\n </p>\n </div>\n );\n}\n"},{"id":"entry-veil-color-relay","name":"Entry Veil Color Relay","pattern_group":"marketing","kind":"motion-pattern","status":"draft","surface_refs":["websites","documentation"],"tokens_used":["color.semantic.surface.base"],"code_asset":"pending","component_refs":[],"term_refs":[],"source_refs":["https://github.com/Yoodeit/MBN_Hackathon"],"last_verified":"2026-08-09","source_path":"recipes/marketing/entry-veil-color-relay.md","body":"## Intent\n\nAn entry veil makes a hard route change feel continuous without a SPA transition framework: the departing page records its dominant background color, and the arriving page paints a full-screen veil of that color on its very first frame, then fades it out. The eye reads one surface flowing into the next instead of a white flash. Works across real navigations (MPA, `location.href`) where View Transitions and layout animations cannot reach. Harvested from MBN_Hackathon (K-RABBIT) `components/rabbit-hole/entry-veil.tsx` (39 lines).\n\n## Anatomy\n\n- **Departure**: just before navigating, write the current context's background color to `sessionStorage` (one well-known key).\n- **Arrival**: a veil component reads the key synchronously on mount; if present, render a `position: fixed; inset: 0` layer in that color above everything, and clear the key (one-shot — a reload must not replay it).\n- **Release**: wait two `requestAnimationFrame` ticks (first paint committed, layout settled), then transition the veil's opacity to 0 and unmount on `transitionend`.\n- The veil is `pointer-events: none` and `aria-hidden` — purely visual, never blocks input.\n\n## States\n\n- No stored color (direct entry, reload, external link): no veil — the page renders normally.\n- Relay entry: solid veil on frame one → fade (≈300–500ms) → removed from the DOM.\n- Reduced motion: skip the fade, remove the veil immediately after first paint (still prevents the flash without animating).\n\n## Code\n\n```tsx\nconst KEY = \"entry-veil-color\"\n\nexport function recordVeil(color: string) {\n try { sessionStorage.setItem(KEY, color) } catch { /* private mode: relay is optional */ }\n}\n\nexport function EntryVeil() {\n const [color] = useState(() => {\n try { const c = sessionStorage.getItem(KEY); sessionStorage.removeItem(KEY); return c } catch { return null }\n })\n const [gone, setGone] = useState(false)\n const [fading, setFading] = useState(false)\n useEffect(() => {\n if (!color) return\n let raf2 = 0\n const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setFading(true)) })\n return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2) }\n }, [color])\n if (!color || gone) return null\n return (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none fixed inset-0 z-[9999] motion-safe:transition-opacity motion-safe:duration-500\"\n style={{ background: color, opacity: fading ? 0 : 1 }}\n onTransitionEnd={() => setGone(true)}\n />\n )\n}\n```\n\n## Variants\n\n- **첫 방문 1회** — 세션 저장으로 재방문 시 베일을 건너뛴다. 반복 노출은 로딩으로 읽힌다.\n- **색 릴레이 단계 축소** — 색 전환 단계를 줄여 짧게. 단계가 많을수록 진입이 지연되므로 단계 수는 예산이다.\n- **베일 없음(폴백)** — `prefers-reduced-motion` 또는 콘텐츠 즉시 접근이 필요한 진입(딥링크·검색 유입). 최종 화면은 동일하다.\n\n## Checks\n\n- The veil renders on the **first** frame of the arriving page — mounted at the root, state read synchronously (an effect-read veil appears after the flash it exists to hide).\n- The key is cleared on read: reload and back-navigation show no stale veil.\n- `sessionStorage` failures (private mode, disabled) degrade to no veil, never to an error.\n- Veil ignores pointer events and is hidden from the accessibility tree.\n\n## Anti-patterns\n\n- **Persisting the color in `localStorage`** — the relay is one navigation's context, not a preference; stale colors veil unrelated visits.\n- **Fading out on a timer before first paint settles** — releasing on `setTimeout(0)` races the browser; the two-rAF wait is the point.\n- **Using the veil as a loading screen** — it must fade immediately; holding it while data loads turns a continuity trick into a blocking splash.\n\n## Agent notes\n\n- 베일은 **콘텐츠를 가리는 장치**다. 진입 시간을 늘린 만큼 무엇을 얻는지 답할 수 없으면 쓰지 않는다.\n- 딥링크·검색 유입 진입에는 기본적으로 걸지 않는다 — 목적지가 이미 정해진 방문자에게 베일은 순수 지연이다.\n- `status: draft` 이고 `code_asset` 이 아직 없다."},{"id":"falling-image-trail","name":"Falling Image Trail","pattern_group":"marketing","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m67","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.action.primary","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/falling-image-trail.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/marketing/falling-image-trail.md","body":"## Intent\n\n커서 트레일의 잔상은 제자리에서 사라지는 것이 기본값처럼 쓰이지만, 그것은 하나의 선택일 뿐이다. Made With Gsap 은 잔상을 **탄성 팝 → 가로 관성 → 중력 낙하** 3단으로 퇴장시켜 무대 밖으로 떨어뜨린다. 같은 「임계 기반 생성」 위에 전혀 다른 퇴장을 얹은 변형이다.\n\n## Anatomy\n\n- **무대** — 포인터를 받는 영역. 계산 기준은 뷰포트가 아니라 이 컨테이너다.\n- **금지대** — 무대 하단 여백. 여기서는 조각을 생성하지 않는다(떨어질 거리가 없다).\n- **조각** — 임계마다 하나 생겨 3단으로 퇴장하고 DOM 에서 사라진다.\n- **임계 슬라이더** — 이동 거리 기반 생성이라는 규칙 자체를 읽게 한다.\n\n## States\n\n- **정지** — 커서가 멈추면 아무것도 생기지 않는다(시간 기반이 아니다).\n- **이동 중** — 누적 이동거리가 임계를 넘을 때마다 하나.\n- **퇴장 중** — 탄성 팝 → 가로 관성 → 중력 낙하.\n- **상한 도달** — 동시 존재가 상한을 넘으면 가장 오래된 것부터 제거한다.\n\n## Variants\n\n- **임계 계수** — 무대 폭 / N. 원본은 8(포인터 없는 기기 6).\n- **낙하 강도** — 생성 높이의 함수. 위에서 생긴 것일수록 세게 떨어진다.\n- **터치 대체 조작** — 탭하면 여러 개를 한 번에 흩뿌린다.\n\n## Code\n\n```ts\n// 생성은 시간이 아니라 이동 거리로 센다.\ntravel += Math.abs(x - prevX) + Math.abs(y - prevY)\nif (travel > stageWidth / 8 && y < stageHeight * 0.78) { travel = 0; spawn(x, y, speed) }\n\n// 3단 퇴장 — 낙하 강도가 생성 높이의 함수다.\nconst tl = gsap.timeline({ onComplete: destroy })\ntl.fromTo(el, { scaleX: 1.3, scaleY: 1.3, rotation: 20 * (Math.random() - 0.5) },\n { scaleX: 1, scaleY: 1, ease: \"elastic.out(2, 0.6)\", duration: 0.4 })\ntl.to(el, { x: \"+=\" + 2 * speed, rotation: 0, ease: \"power1.in\", duration: 0.4 }, \"<\")\ntl.to(el, { y: \"+=\" + (stageHeight - y), scale: 0.9, yPercent: -95, ease: \"back.in(1.1)\", duration: 0.4 }, \"<\")\ntl.to(el, { yPercent: 150, ease: \"back.in(\" + (1 - y / stageHeight + 1.5) + \")\", duration: 0.3 })\n```\n\n## Checks\n\n- 커서를 멈췄을 때 생성이 멈추는지 확인(시간 기반이면 계속 쌓인다).\n- 무대 하단에서 조각이 생기지 않는지 확인.\n- 빠르게 휘저은 뒤 DOM 노드 수가 상한 안에 머무는지 확인.\n- 언마운트에서 살아 있는 타임라인과 생성 노드가 **전부** 정리되는지 확인.\n- 조각이 무대와 구별되는지 라이트·다크 양쪽에서 확인.\n\n## Anti-patterns\n\n- **시간 간격으로 생성하는 것.** 커서를 멈춰도 계속 쌓여 트레일이 아니라 분수가 된다.\n- **전부 같은 이징으로 떨어뜨리는 것.** 낙하가 아니라 일괄 슬라이드로 읽힌다.\n- **상한 없이 두는 것.** 빠른 조작에서 노드가 무한히 늘어난다.\n- **뷰포트 기준으로 계산하는 것.** 컨테이너 안에 놓는 순간 임계와 낙하 거리가 어긋난다.\n- **원본 이미지를 그대로 가져오는 것.** 가져오는 것은 파라미터와 규칙이지 에셋이 아니다.\n\n## Agent notes\n\n- `prompt_phrases`: \"마우스 따라 이미지 뿌려줘\", \"커서 트레일\", \"히어로에 장난스러운 효과\", \"잔상이 그냥 사라지는 게 심심해\".\n- **퇴장을 먼저 고른다** — 제자리 소멸이면 `image-trail-cursor`, 무대 밖으로 떨어뜨리면 이 recipe.\n- 상한과 cleanup 은 선택이 아니다 — 이 계열의 사고는 전부 거기서 난다.\n- 이 recipe 의 화면은 원 사이트와 대조되지 않았다(라우트 미특정). 파라미터 재현물로 다룬다.\n\n## 관측 (Made With Gsap · 2026-08-17)\n\n- **생성 임계**: 커서 이동 누적거리 `|Δx| + |Δy|` 가 `innerWidth / 8`(포인터 없는 기기는 `/6`)을 넘을 때마다 하나. **화면 하단 200px 안에서는 생성하지 않는다** — 태어나자마자 떨어질 것을 만들지 않는다.\n- **1단 탄성 팝**: `fromTo({xPercent: 80*(rand-.5)-50, yPercent: 10*(rand-.5)-50, scaleX/Y: 1.3, rotation: 20*(rand-.5)}, {scaleX/Y: 1, ease: \"elastic.out(2, 0.6)\", duration: .4})`\n- **2단 가로 관성**(동시): `x += 2*speed`, `rotation → 0`, `power1.in`, `.4`\n- **3단 낙하**(동시): `y += (vh - y)`, `scale .9`, `yPercent -95`, `ease \"back.in(1.1)\"`, `.4` → 이어서 `x += 1.6*speed`, `rotation 40*(rand-.5)`, `.3` → `yPercent 150`, `ease back.in(1 - y/vh + 1.5)`\n- **낙하 이징의 강도가 생성 높이에 따라 달라진다** — 위에서 생긴 것일수록 세게 떨어진다. 이 한 줄이 물리를 흉내로 보이지 않게 만든다.\n- 타임라인 완료 시 요소를 DOM 에서 제거한다.\n\n## 규칙\n\n1. **생성은 시간이 아니라 이동 거리로 센다.** 시간 기반이면 커서를 멈춰도 계속 쌓인다.\n2. **금지대를 둔다.** 하단 여백 안에서는 생성하지 않는다 — 퇴장할 거리가 없으면 연출이 성립하지 않는다.\n3. **퇴장 강도를 생성 위치의 함수로 둔다.** 전부 같은 이징으로 떨어지면 낙하가 아니라 슬라이드로 읽힌다.\n4. **동시 존재 개수에 상한을 둔다**(원본에 없는 안전장치 — 아래 참조).\n5. **완료 시 DOM 에서 제거한다.** 남겨 두면 빠르게 휘저었을 때 노드가 무한히 쌓인다.\n\n## 우리가 정한 값\n\n- **이미지를 쓰지 않는다.** 원 사이트는 실제 이미지를 뿌리지만 우리는 에셋을 복제하지 않는다 — 조각은 토큰으로 그린 빈 블록이다.\n- **동시 존재 상한 30** 과 가장 오래된 것부터 제거하는 규칙은 우리가 넣은 안전장치다.\n- 뷰포트 기준값(`innerWidth`·하단 200px)은 전부 컨테이너 `getBoundingClientRect()` 기준으로 옮겼다.\n- 임계 계수 슬라이더(4~16, 원본 8)는 규칙 자체를 읽게 하려고 우리가 붙였다.\n\n## ⚠ 파라미터 재현물이지 겉모습 재현물이 아니다\n\n이 행(`mwg-15`)은 **시각 확인이 `미확인`** 이다 — 코드는 공용 번들에 완전한 형태로 있지만 그것을 렌더하는 라우트를 특정하지 못했다(SPA 캐치올 폴백). 즉 파라미터는 직독이고 화면은 대조한 사람이 없다.\n\n## term 대응\n\n**빈 배열이다.** 커서·포인터 축의 term 이 `terms.yml` 에 없다 — [[idle-cursor-prewarm]] 과 같은 사유로 용어 신설 후보 큐에 남긴다(M67 D6).\n\n## 관련\n\n[[image-trail-cursor]](같은 임계 기반 생성, 퇴장이 제자리 페이드 — 이 recipe 가 그 반대편) · `research/m67-batch2-css/p2-ledger.json` 행 `mwg-15`\n\n## Changelog\n\n- 2026-08-18 — m67-batch2-css 회차 신설. 사용자 관측 게이트 통과."},{"id":"filter-stagger-grid","name":"Filter Stagger Grid","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/filter-stagger-grid.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/filter-stagger-grid.md","body":"## Intent\n\nA filter stagger grid replaces the instant re-layout of a filtered card grid with a three-beat sequence: the visible cards leave in stagger, the set swaps during a short gap, and the new cards enter in stagger with a different easing. The asymmetry is the point. Leaving uses a symmetric ease so the exit reads as dismissal, entering uses a strong ease-out so the arrival reads as settling. A hover on a tag dims the non-matching cards first, so the visitor can preview the filter before committing to it. Observed on Revelatio Studio's projects filter (research/m56-round3/revelatio-disposition.json, row p2-revelatio.json rows[12], `scripts/projects-filter.js` L8-124) with the dimming channel from research/m56-round3/revelatio-deep.json d3[11]. Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Timing constants come from the observation: out `0.34s`, in `0.52s`, per-card stagger `0.08s`, and a `70ms` gap between the two halves.\n- Out transform is `translateY(6px)` with `scale(0.992)` on `cubic-bezier(0.4, 0, 0.2, 1)`; in transform starts at `translateY(10px)` with `scale(0.988)` on `cubic-bezier(0.16, 1, 0.3, 1)`.\n- Those two curves are rebuilt as GSAP ease functions by a local `cubicBezier()` solver (Newton iteration on x, then evaluation on y), so the CSS-authored curves survive without the paid CustomEase plugin.\n- The set swap happens in a `setTimeout` inside the out tween's `onComplete`, so React state changes only once the old cards are already invisible.\n- The in tween runs from an effect keyed on the displayed list: it compares the previous id list, `gsap.set`s the new nodes to their start offsets, then tweens them in and clears the `animating` latch.\n- An `animating` flag blocks tag clicks mid-sequence, which is what keeps two overlapping sequences from fighting over the same nodes.\n- Tag hover fills a `dimSet` of non-matching ids, applying `opacity 0.35` with a short CSS transition. Dimming is suppressed while `animating` so the two channels never overlap.\n\n## States\n\n- Idle: the active tag is pressed (`aria-pressed`), all cards at full opacity.\n- Tag hover: non-matching cards at `DIM_OPACITY = 0.35`, no layout change.\n- Filtering out: `data-animating=\"true\"`, all visible cards tweening to opacity 0.\n- Filtering in: new cards tween up from their offset; the latch clears on the last card.\n- Reduced motion: the filter applies immediately with no tween and no gap.\n\n## Variants\n\n- Stagger order: `stagger: 0.08` runs in DOM order. GSAP's `from: \"random\"` or `from: \"center\"` changes the character without touching the timing budget.\n- Dimming trigger: hover (this demo) or scroll proximity (the observed original synchronized dimming to the scroll position on mobile).\n- Gap: the `70ms` pause is what separates the two beats. Removing it makes the swap read as a crossfade instead of a sequence.\n\n## Code\n\n```tsx\ngsap.to(outNodes, {\n opacity: 0, y: OUT_Y, scale: OUT_SCALE,\n duration: OUT_DURATION_S, ease: OUT_EASE, stagger: STAGGER_S,\n onComplete: () => {\n gapTimeoutRef.current = setTimeout(() => {\n setActiveFilter(nextFilter);\n setDisplayed(nextItems);\n }, TRANSITION_GAP_MS);\n },\n});\n```\n\n## Checks\n\n- Set the entry start values with `gsap.set` before the in tween, not as inline style: inline styles fight the tween's own writes.\n- Keep a ref map of card nodes keyed by item id and delete on unmount, otherwise the tween list holds detached nodes after a filter change.\n- Kill tweens and clear the gap timeout on unmount; the timeout fires into unmounted state otherwise.\n- Total sequence time grows with card count (`stagger * n`). Above roughly a dozen visible cards, lower the stagger or the filter starts to feel slow.\n- The reduced-motion path must skip the whole sequence, including the gap, not just shorten the durations.\n\n## Anti-patterns\n\n- Using the same easing for exit and entry: the sequence flattens into one long fade and the \"settling\" read disappears.\n- Animating the grid container's height alongside the cards: the cards already carry the motion, and container reflow makes the stagger jitter.\n- Letting a second filter click start while the first sequence runs: nodes get double-tweened and cards can be left at opacity 0 permanently. Latch it.\n- Dimming and staggering at the same time: two opacity channels on one node produce values neither channel intended.\n\n## Agent notes\n\n- prompt_phrases: \"filter grid where cards leave and arrive in sequence\", \"staggered filter transition for a project grid\", \"hover a tag to dim non-matching cards\".\n- fallbacks: with reduced motion, apply the filter instantly. Without GSAP, the same shape works with CSS transitions plus per-card `transition-delay`, at the cost of the asymmetric easing being harder to keep in sync."},{"id":"flip-heading-scrub","name":"Flip Heading Scrub","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/flip-heading-scrub.tsx","component_refs":["shared-element-flip-transition"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/flip-heading-scrub.md","body":"## Intent\n\nA flip heading scrub takes a heading whose letters are scattered across a stage and gathers them into a line as the visitor scrolls, then lets them scatter back on the way up. The mechanism is FLIP: record the letters' current geometry, move them in the DOM or in CSS, and let the library animate the difference. Because the whole thing is one paused timeline mapped to scroll progress, the visitor is scrubbing the assembly rather than watching it play. Observed on noth.in's works heading (research/m56-round3/noth-in-deep.json d3[18]), a technique that round 2 never recorded at all. Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- `Flip.getState()` captures the scattered layout, the letters' coordinates are then set to the gathered line, and `Flip.from(state, ...)` animates the delta.\n- The timeline is built `paused: true` with `ease: \"power4.inOut\"`, `duration: 1.4`, `stagger: { each: 0.2, from: \"end\" }`, plus `repeat: 1` and `yoyo: true` so one timeline holds both directions.\n- Scroll progress drives `tl.progress()`. The source used ScrollTrigger with `scrub: 3`; this asset computes the container's own scroll ratio in an rAF, which keeps the plugin surface smaller and matches the other scroll assets here.\n- A scale pulse rides the same timeline: at `offset = (length - 1 - index) * 0.1` each letter tweens `1 to 0.2` over `0.8s`, then back `0.2 to 1` starting `0.8s` later, on the same easing.\n- `from: \"end\"` reverses the stagger order, so the letters assemble from the tail of the word rather than the head. That single parameter is most of the character.\n- Positions are percentages for the scattered state and `calc(50% + offset)` for the gathered line, so the layout survives a resize between scrubs.\n\n## States\n\n- progress 0: scattered layout, letters at full scale.\n- Mid-scrub: letters in flight, some mid-pulse, at whatever frame the scroll position selects.\n- progress 1: gathered line.\n- Scrubbing backward replays the same frames in reverse, because the state is a timeline position rather than a boolean.\n\n## Variants\n\n- Driver: container scroll ratio (this asset), window scroll, or a ScrollTrigger scrub. A higher scrub value adds lag and makes the assembly feel heavier.\n- Stagger origin: `from: \"end\"`, `\"start\"`, `\"center\"`, or `\"random\"`. Each reads as a different kind of assembly.\n- Pulse: optional. Without it the motion is pure translation, which reads calmer and is easier to keep legible.\n\n## Code\n\n```tsx\nconst state = Flip.getState(letters);\nletters.forEach(applyGatherLayout);\ntlRef.current = Flip.from(state, {\n ease: \"power4.inOut\", duration: 1.4,\n stagger: { each: 0.2, from: \"end\" },\n repeat: 1, yoyo: true, paused: true,\n});\n```\n\n## Checks\n\n- Register the Flip plugin once at module scope, not inside the effect, or a remount registers it repeatedly.\n- Capture the state before any layout write. Reading after the write animates a zero delta and nothing moves.\n- Map scroll progress to `tl.progress()` inside an rAF, not directly in the scroll handler, so the timeline is written once per frame.\n- Kill the timeline on unmount, and rebuild it on resize: FLIP deltas are pixel values captured at build time, so a resize invalidates them.\n- Keep the heading readable at progress 0 and 1 both. A word that is only legible mid-scrub is a decoration pretending to be a heading.\n\n## Anti-patterns\n\n- Animating scattered-to-gathered by hand with transforms: you end up re-deriving what FLIP already computes, and the result breaks on every layout change.\n- Playing the timeline on scroll entry rather than scrubbing it: the visitor loses control of the assembly, which is the point of the pattern.\n- Using a fractional scale pulse deep enough to make letters vanish (`0.2` is already extreme) on a heading that carries meaning: reserve it for display type.\n- Rebuilding the timeline on every scroll frame: capture once, scrub many.\n\n## Agent notes\n\n- prompt_phrases: \"letters that gather into a heading as you scroll\", \"FLIP scrub animation for a display heading\", \"scattered word assembles on scroll\".\n- fallbacks: with reduced motion, render the gathered layout and skip the timeline entirely. Without the Flip plugin, the same effect requires manual delta measurement, so prefer omitting the animation to approximating it."},{"id":"fluid-cursor-background","name":"Fluid Cursor Background","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m56","reinforced_round":"m66","surface_refs":["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.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/fluid-cursor-background.tsx","component_refs":[],"term_refs":["animated-gradient-background","aurora-background","hover-reveal"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/marketing/fluid-cursor-background.md","body":"## Intent\n\nA fluid cursor background runs a low-resolution Navier-Stokes-style fluid simulation (velocity/dye/pressure fields, splat/divergence/pressure/gradient-subtract passes) as the site's global backdrop, and every pointer move injects a splat that stirs the field in real time. The background stops being a static gradient and behaves like a living reactive surface. Observed at izanami-official.com (research/m55-round2/p2-izanami.json row 2, guiParams `{cursorSize:.5, cursorPower:25, distortionPower:.5}`; the origin runs this at full resolution with 2 canvases, ported here at reduced simulation resolution); user gate pass frozen in research/2026-08-15-m55-gate-verdicts.md section 2 (회차 2 신규, 통과 9 중 하나).\n\n\n**2026-08-18 정식 이식 승격 (M66 회차 8).** 회차 2 판은 셰이더 본문을 못 읽어 축소이식이었다. 회차 8 이 원본 번들에서 패스 전문을 확보해 관측값을 그대로 넣은 판으로 교체했다 — 압력 Jacobi 를 프레임당 1회만 돌리고, color advection 의 dt 를 velocity 의 8배로 비대칭 적용하며, 포인터 델타를 6배 증폭해 속도장과 색장에 서로 다른 값으로 이중 주입한다. 시뮬 결과는 배경에서 끝나지 않고 씬 전체를 velocity 방향으로 미는 후처리 왜곡의 입력으로 재사용된다. 원본이 DPR 무상한에 전해상도 FBO 7장을 쓰는 것은 따르지 않았다(저사양에서 감당이 안 된다) — 시뮬 해상도를 절반, DPR 상한을 1.5 로 잡았다. **원본에 없는 것 하나를 우리가 더했다**: 원본은 `prefers-reduced-motion` 분기가 0이라, 감속 요청 시 루프를 멈추고 정적 표면을 보이는 경로를 신설했다. 근거 행 izanami#4·#5·#26 (`research/m66-round8/p2-ledger.json`), 판정 동결 `research/2026-08-17-m66-gate-verdicts.md`.\n\n## Anatomy\n\n- Simulation grid: `SIM_RES = 128` texels, run through six shader passes per frame (splat, advect, divergence, pressure, gradient-subtract, display), with `PRESSURE_ITERATIONS = 14` Jacobi iterations for the pressure solve.\n- Double-buffered FBOs (velocity, dye, pressure) ping-pong read/write each pass; a single divergence FBO holds the intermediate.\n- `SPLAT_RADIUS = 0.003`; each pointermove computes a velocity delta from the previous point scaled by `VELOCITY_SPLAT_SCALE = 6.0` and injects it as a splat into the velocity field, then a dye splat cycles through the token color list.\n- Field decay: `VELOCITY_DISSIPATION = 0.985`, `DYE_DISSIPATION = 0.97` per advect pass, so motion and color both settle back to rest without a pointer.\n- Token resolution: `--primary`, `--foreground`, `--muted-foreground` are painted to a 1x1 canvas and read back as RGB floats to build the dye color cycle; `--background` is resolved the same way and composited under the dye in the display pass, so the field is visible as a tint over the page background rather than an opaque canvas.\n\n## States\n\n- Idle: no queued splat; the field only advects and dissipates toward flat.\n- Tracing: pointermove queues a splat; velocity and dye fields pick up motion and color at the pointer position.\n- Left: pointerleave clears the tracked points so no stale splat carries over; the field keeps decaying under dissipation.\n- Unsupported: WebGL2 or `EXT_color_buffer_float` missing, container renders a text fallback instead of a canvas.\n- Reduced motion: `prefers-reduced-motion: reduce` detected before any GL setup runs; no simulation starts at all.\n\n## Variants\n\n- Palette: swap which semantic tokens feed the dye color cycle (3-color rotation in the source).\n- Resolution/cost trade: raising `SIM_RES` or `PRESSURE_ITERATIONS` sharpens the fluid detail at real GPU cost; both are tunable constants at the top of the file.\n- Splat-only trail: drop the pressure/divergence passes and drive only advect+splat for a cheaper smoke-trail look instead of full incompressible flow.\n\n## Code\n\n```tsx\nconst SIM_RES = 128;\nconst PRESSURE_ITERATIONS = 14;\nconst SPLAT_RADIUS = 0.003;\nconst VELOCITY_DISSIPATION = 0.985;\nconst DYE_DISSIPATION = 0.97;\nconst VELOCITY_SPLAT_SCALE = 6.0;\n\n// per pointermove:\nqueuedSplat = {\n x, y,\n dx: (x - pointerPrev.x) * VELOCITY_SPLAT_SCALE,\n dy: (y - pointerPrev.y) * VELOCITY_SPLAT_SCALE,\n};\n\n// per frame, splat -> advect -> divergence -> pressure (x14) -> gradient-subtract -> advect dye -> display\n```\n\n## Checks\n\n- Simulation resolution stays low (128 texels); this is the entire performance budget, not the display canvas resolution.\n- rAF cancelled and pointermove/pointerleave/resize listeners removed on unmount.\n- DPR capped at 2 when sizing the drawing buffer.\n- `prefers-reduced-motion` checked before GL context creation; no canvas, no rAF loop, no listeners attached at all when it matches.\n- WebGL2 + `EXT_color_buffer_float` support checked before use; missing support falls back to a text notice, page stays usable.\n- No origin-site palette baked into uniforms; dye colors come from tokens resolved at mount, matching the cursor-trail-shader harvest-contract restyle rule.\n\n## Anti-patterns\n\n- Running the simulation at full canvas resolution: the fluid solve is per-pixel iterative (divergence + 14 pressure iterations), so display-resolution grids are a frame-rate killer.\n- Skipping the reduced-motion check and only gating the render output: this pattern must skip GL setup and event listeners entirely, not just hide the canvas, since the simulation loop itself is the sustained cost.\n- Treating this as a decorative overlay independent of dissipation: without VELOCITY_DISSIPATION/DYE_DISSIPATION pulling the field back to rest, idle pages keep an ever-brightening or never-settling background.\n\n## Agent notes\n\n- prompt_phrases: \"fluid simulation cursor background\", \"pointer stirs a webgl fluid field\", \"living reactive background surface\".\n- fallbacks: when WebGL2 float render targets are unsupported, show a short static text notice in place of the canvas rather than a blank area. When reduced-motion is set, skip the simulation entirely and let the page's static background token show through."},{"id":"fullpage-snap-landing","name":"Fullpage Snap Landing","pattern_group":"marketing","kind":"block","status":"draft","surface_refs":["websites","mobile-apps"],"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.xl","typography.weight.medium"],"mobile":{"platforms":{"ios":"paged scroll view","android":"paged scroll container"},"gestures":["scroll","tap"],"touch_targets":["section indicator dot"],"safe_area":"none","reduced_motion":"스냅·점프 이동은 유지(위치 변화는 콘텐츠) — 스무스 스크롤 트윈만 제거","modal_depth":0,"port_notes":"URL `replaceState` 동기화는 네이티브에 대응물이 없다 — 딥링크 라우팅으로 옮기거나 축에서 내린다. **섹션 인디케이터 점은 웹에서 44px 미만이므로 옮길 때 히트 영역을 키워야 한다**"},"code_asset":"examples/ui-vocabulary-site/src/components/fullpage-snap-landing.tsx","component_refs":[],"term_refs":["scroll-snap","sticky-scroll-section","hero"],"source_refs":[],"last_verified":"2026-08-10","source_path":"recipes/marketing/fullpage-snap-landing.md","body":"## Intent\n\nA fullpage snap landing divides a marketing narrative into viewport-height chapters that scroll-snap into place, with a section indicator that stays in sync with scroll position and with the URL. The recipe's obligations are the four ways this pattern typically breaks people: hijacked scroll (`mandatory` snap trapping mid-read), a poisoned back button (`pushState` per section), motion that ignores vestibular preferences, and content held hostage by JS (`body` hidden until a script decides otherwise).\n\n## Anatomy\n\n- Snap container: `scroll-snap-type: y proximity` — proximity, not mandatory, so partial reads and small viewports never trap the user mid-scroll.\n- Sections: viewport-height chapters, each a labeled region with its own heading.\n- Section indicator: dot/label nav — each dot is a real `<button>` with an accessible name and `aria-current` on the active section.\n- Scroll sync: an IntersectionObserver (scoped to the container, disconnected on teardown) drives the indicator and the URL readout.\n- URL sync: `history.replaceState` per section — back must exit the page, not replay every scroll stop.\n\n## States\n\n- Active section: indicator and URL reflect the section occupying the viewport.\n- Jump navigation: clicking a dot scrolls to the section — smooth only under `motion-safe`; instant otherwise.\n- Reduced motion: snapping and jumps still function (position changes are content, not decoration) but tweened smooth-scrolling is disabled.\n- No-JS / unsupported: plain document flow — every section readable top to bottom; scroll-snap absent simply means normal scrolling.\n\n## Variants\n\n- Dot rail (demo) vs labeled side nav.\n- Alternating section surfaces (base/muted) vs full-bleed media sections (media slots left empty for the consumer).\n\n## Code\n\n```tsx\n// URL sync: replaceState, never pushState — a 10-section read must not\n// cost ten Back presses to leave the page.\nconst onSectionChange = (id: string) => {\n history.replaceState(null, \"\", `#${id}`)\n}\n```\n\n```css\n.container { scroll-snap-type: y proximity; } /* not mandatory */\n.section { scroll-snap-align: start; }\n@media (prefers-reduced-motion: no-preference) {\n .container { scroll-behavior: smooth; }\n}\n```\n\n## Checks\n\n- Snap is `proximity` — a reader can rest between snap points; `mandatory` is reserved for true slide decks where partial positions are meaningless.\n- Section changes write `replaceState`, not `pushState`; Back leaves the page in one press.\n- Smooth scrolling and snap animation are gated behind `prefers-reduced-motion: no-preference`; jumps still work with reduced motion.\n- Content is never hidden while waiting for JS — no `body { overflow: hidden }` or opacity-0 gate that a script must release; the no-JS render is a readable document.\n- Both theme faces of each section surface pass contrast independently.\n- Indicator dots are `<button>`s with accessible names and `aria-current`; the observer is disconnected on unmount.\n- The fallback boundary only disables decoration: browsers without scroll-snap get plain scrolling, not a broken layout.\n\n## Anti-patterns\n\n- **`mandatory` everywhere**: snap that yanks the viewport whenever the user pauses between sections — the single biggest reason fullpage sites feel hostile.\n- **`pushState` per section**: scrolling the page silently fills the history stack; Back becomes a replay of the visit.\n- **Scroll-jacking via JS**: wheel listeners that re-implement scrolling with custom easing — breaks input devices the author didn't test (trackpad inertia, keyboard paging, screen-reader scrolling).\n- **JS-gated content**: the document hidden until an entrance animation is ready — a script error leaves a blank page.\n\n## Agent notes\n\n- prompt_phrases: \"viewport-height snap sections with proximity snapping, replaceState URL sync, and a dot indicator\", \"fullpage landing that respects reduced motion and the back button\"\n- fallbacks: on short viewports (`max-height` query), release snapping entirely and let sections size to content.\n- canonical guidance: `knowledge/expressive-stack.md` (판정 절차 10 — 폴백은 장식만 끈다), kg `pushstate-vs-replacestate-scroll-sync`, `knowledge/dark-mode.md` §2."},{"id":"grid-rows-accordion","name":"Grid Rows Accordion","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites","documentation"],"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/grid-rows-accordion.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/grid-rows-accordion.md","body":"## Intent\n\nA grid rows accordion animates disclosure by transitioning `grid-template-rows` from `0fr` to `1fr` instead of animating height. Because `1fr` resolves to the content's natural height at animation time, the panel opens smoothly without anyone measuring the content, without `max-height` guesses, and without a `ResizeObserver`. Three channels move together on one gesture: the row track, the answer's opacity, and the marker's rotation, with the opacity running slightly shorter so text does not arrive before the space that holds it. Observed on Revelatio Studio's /approach accordion (research/m56-round3/revelatio-disposition.json, row p2-revelatio.json rows[14], `scripts/approach-accordion.js` L1-98). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- The panel is a two-node stack: a `display: grid` wrapper whose `gridTemplateRows` toggles `0fr` / `1fr`, and an `overflow: hidden` child that clips the content while the track collapses.\n- The track transition is `0.6s cubic-bezier(0.625, 0.05, 0, 1)`, a curve with a long slow head and a fast tail, which is what keeps a tall panel from feeling like it snaps.\n- Opacity transitions over `0.45s ease`, shorter than the track, so the text fades in against a space that is already opening.\n- The marker rotates `180deg` over `0.45s` on the same curve as the track, tying the control to the panel.\n- Open state is a single `openId`, so opening one item closes the other. The trigger is a real `<button>` carrying `data-state=\"open\" | \"closed\"`.\n- Reduced motion is read from a `matchMedia` listener and collapses all three transitions to `none`; the disclosure still works, it just arrives instantly.\n\n## States\n\n- Closed: track at `0fr`, content clipped to zero height, answer at opacity 0, marker unrotated.\n- Open: track at `1fr`, answer at opacity 1, marker at `180deg`.\n- Switching items: the previous panel closes while the next opens, both on the same curve, so the block's total height moves once rather than twice.\n- Reduced motion: identical states with no transition.\n\n## Variants\n\n- Multi-open: replace the single `openId` with a `Set` of open ids. The row mechanics are unchanged.\n- Marker: a rotating chevron (this demo) or a plus that becomes a minus. Rotation must share the track's curve either way.\n- Divider treatment: `divide-y` between rows reads as a document; separate bordered cards read as a list of panels.\n\n## Code\n\n```tsx\n<div\n className=\"grid\"\n style={{ gridTemplateRows: isOpen ? \"1fr\" : \"0fr\", transition: rowTransition }}\n>\n <div className=\"overflow-hidden\">\n <p style={{ opacity: isOpen ? 1 : 0, transition: opacityTransition }}>\n {item.answer}\n </p>\n </div>\n</div>\n```\n\n## Checks\n\n- The `overflow: hidden` child is not optional. Without it the content keeps its full height while the track collapses and the text spills over the next row.\n- Do not put padding on the grid wrapper: padding does not collapse with the track, so a closed panel keeps a visible gap. Put it on the content instead.\n- The trigger must be a `<button>` with `type=\"button\"`, so keyboard and screen reader users get the same disclosure.\n- Verify with content of at least two different heights. The whole reason to use this over `max-height` is that unknown heights work, and a single fixture hides a regression to fixed sizing.\n- Re-read `prefers-reduced-motion` through a `change` listener, not once at mount.\n\n## Anti-patterns\n\n- Animating `height: auto`: it does not transition at all, which is the defect this pattern exists to route around.\n- Animating a `max-height` guess: too small clips long content, too large makes the close look like it stalls, and every content edit re-breaks the guess.\n- Rotating the marker on a different duration or curve from the track: the control and the panel visibly disagree about how fast the disclosure is.\n- Fading the answer in over the full track duration: the text lands late and the panel looks empty while it opens.\n\n## Agent notes\n\n- prompt_phrases: \"accordion that opens smoothly without knowing the content height\", \"grid-template-rows 0fr to 1fr disclosure\", \"FAQ accordion with animated chevron\".\n- fallbacks: with reduced motion, toggle the state with no transition. In browsers without animatable `fr` tracks the panel still opens and closes correctly, just without the tween, so no separate fallback path is needed."},{"id":"hero-reflection-stage","name":"Hero Reflection Stage","pattern_group":"marketing","kind":"visual-treatment","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/hero-reflection-stage.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/hero-reflection-stage.md","body":"## Intent\n\nA hero reflection stage builds the hero out of four stacked layers instead of one media box: an oversized background, the main source, a mirrored and blurred copy of that same source under it, and a transparent frame on top. The reflection is what does the work. It implies a surface the hero is sitting on, so the media reads as an object in a room rather than a rectangle in a page. Observed on noth.in's hero, where the reflection is a separately encoded reflection video played in sync rather than a CSS flip (research/m56-round3/noth-in-disposition.json row p2-noth-in.json rows[5], layer stack in noth-in-deep.json d3[20]). This asset reproduces the geometry with a procedural canvas source because the source clips cannot be redistributed. Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Layer 1: a background wash at `scale(1.8)`. Oversizing is what keeps the backdrop from reading as a bordered image.\n- Layer 2: the main source, here a canvas drawn per frame.\n- Layer 3: the reflection. A second canvas receives the same draw call at the same timestamp, then CSS applies `scaleY(-1)`, `blur(20px)`, `opacity 0.65`, and a `linear-gradient(to bottom, black, transparent)` mask so it fades out with distance.\n- Layer 4: a transparent frame at `scale(1.01)` with `pointer-events: none`, which reads as the edge of the stage without boxing the media in.\n- Both canvases share one rAF and one timestamp. Sharing the time value, not copying pixels, is what keeps the reflection exactly in step.\n- Canvas colors are read from `--muted` and `--foreground` at draw time, so the stage follows the consuming project's theme.\n- Blur scales with stage size. The observed `80px` belongs to a full-bleed hero; this demo uses the source's own `20px` small-breakpoint value rather than inventing a number.\n\n## States\n\n- Playing: both canvases update per frame, `data-playing=\"true\"`.\n- Reduced motion: one frame is drawn to both canvases and the loop never starts, so the composition is intact and still.\n- Resize: both canvases are re-sized from the main canvas's rect, keeping the mirror aligned.\n\n## Variants\n\n- Source: video (observed) or procedural canvas (this asset). With video, the mirror is a second `<video>` sharing `currentTime`, and the observed site went further by encoding a dedicated reflection clip.\n- Reflection depth: the mirror occupies 42 percent of the stage here. Deeper reflections read as water, shallower as polished floor.\n- Frame: a hairline border (this asset) or a subtle vignette. Either way it stays non-interactive.\n\n## Code\n\n```tsx\nconst tick = (t: number) => {\n // 두 레이어가 같은 시각을 그린다 — 픽셀 복사가 아니라 시간 공유가 동기화의 핵심\n drawFrame(mainCtx, w, h, t);\n drawFrame(reflectCtx, w, h, t);\n rafRef.current = window.requestAnimationFrame(tick);\n};\n```\n\n## Checks\n\n- Draw both layers from the same timestamp in one rAF. Two independent loops drift within seconds and the reflection stops belonging to the source.\n- The mask is what sells the reflection. Without the gradient fade the mirror reads as an upside-down duplicate.\n- Scale the blur to the stage. A blur tuned for a full-bleed hero erases the reflection entirely in a small container.\n- Keep the frame `pointer-events: none`, or it swallows clicks meant for the media.\n- With a video source, sync `currentTime` on `seeked` and `play`, not once at mount.\n\n## Anti-patterns\n\n- Reflecting with `transform: scaleY(-1)` on the same element: it flips the source itself. The mirror has to be a second layer.\n- A reflection at full opacity and no blur: it reads as a rendering bug rather than a surface.\n- Baking the stage colors as literals: the hero then ignores the consuming project's theme, which is the exact failure the token read avoids.\n- Adding a shadow under the media as well as the reflection: two conflicting light stories on one object.\n\n## Agent notes\n\n- prompt_phrases: \"hero video with a reflection underneath\", \"media sitting on a reflective surface\", \"four layer hero stack with mirrored copy\".\n- fallbacks: with reduced motion, draw a single frame into both layers. On low-end devices, drop layer 3 entirely rather than lowering the blur quality; the three-layer version still reads as intentional."},{"id":"hover-flicker-link","name":"Hover Flicker Link","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/hover-flicker-link.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/hover-flicker-link.md","body":"## Intent\n\nA hover flicker link answers the pointer with two small signals at once: the characters blink in random order on a four-step opacity keyframe, and the link itself scales down by 1.5 percent. Nothing about the text changes, so unlike a scramble the label stays readable through the whole response. It is the cheap end of the same vocabulary: a link that feels physically touched rather than merely highlighted. Observed on Revelatio Studio's nav and accordion links (research/m56-round3/revelatio-deep.json d3[1], converging with d3[20], `scripts/scramble-text.js` L169-219). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Every character is wrapped in an `inline-block` span at render time, and the spans are collected into a per-link ref array.\n- The flicker is a GSAP timeline of per-character keyframes `[0, 1, 0, 1]` with durations `[0.06, 0.04, 0.05, 0.08]` and `ease: \"none\"`. Linear easing is what makes it read as a switch rather than a fade.\n- Character order is shuffled per play (Fisher-Yates), then each character is offset by `index * 0.03` inside the timeline. The randomness lives in the order, not in the timing.\n- The scale channel is a separate tween on the link element: `0.985` on enter, `1` on leave, both `0.35s power2.out`, with `transform-origin: center`.\n- The flicker plays on both enter and leave, so withdrawing the pointer gets the same acknowledgement as arriving.\n- Each link keeps its timeline in a map and kills the previous one before starting a new one, which is what makes rapid enter/leave sequences stay stable.\n- `data-hover-state=\"idle\" | \"active\"` is written on the anchor for tests and for any dependent styling.\n\n## States\n\n- idle: characters at opacity 1, link at scale 1.\n- enter: shuffled flicker plays, scale eases to `0.985`.\n- leave: the same flicker plays, scale eases back to 1.\n- Reduced motion: the timeline sets all characters to opacity 1 and returns, so hovering changes nothing visually.\n\n## Variants\n\n- Channels: flicker alone, scale alone, or both. Both together is the observed pairing and reads as the most physical.\n- Keyframe shape: more steps read as an unstable bulb, fewer read as a single blink.\n- Stagger source: shuffled order (this demo) or GSAP's `from: \"random\"` stagger, which is equivalent at the cost of losing the explicit order.\n\n## Code\n\n```tsx\nconst order = shuffledIndices(chars.length);\norder.forEach((charIndex, i) => {\n tl.to(chars[charIndex], { keyframes: FLICKER_KEYFRAMES, ease: \"none\" }, i * STAGGER_EACH);\n});\n```\n\n## Checks\n\n- Characters must be `inline-block`. Inline spans ignore transforms and can collapse whitespace differently once split.\n- Kill the previous timeline per link before starting the next, or a fast pointer sweep leaves characters stranded at opacity 0.\n- Kill every timeline on unmount; a running timeline holds references to detached nodes.\n- Keep the scale change small. Past a few percent it stops reading as pressure and starts reading as a layout shift, because neighbouring links do not move.\n- Read `prefers-reduced-motion` through a `change` listener stored in a ref, so the check inside the play function sees the current value.\n\n## Anti-patterns\n\n- Splitting the label into spans without keeping the original string available: the accessible name degrades, and any text selection copies oddly.\n- Flickering the whole link node instead of its characters: that is a blink, and it reads as a rendering fault rather than a response.\n- Adding an easing curve to the opacity keyframes: the flicker turns into a soft pulse, which is a different effect with a different meaning.\n- Using this on primary calls to action: the ambiguity between \"flickering\" and \"broken\" is a real cost, and it belongs on secondary navigation.\n\n## Agent notes\n\n- prompt_phrases: \"nav link whose letters flicker on hover\", \"link that shrinks slightly when hovered\", \"character-level glitch feedback without scrambling text\".\n- fallbacks: with reduced motion, hold every character at opacity 1 and skip the scale tween. On touch surfaces there is no hover, so the link simply behaves as a normal link."},{"id":"hover-preview-follower","name":"Hover Preview Follower","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["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.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/hover-preview-follower.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/hover-preview-follower.md","body":"## Intent\n\nA hover preview follower is a floating panel that trails the pointer across a list and swaps its content per row, so hovering an item previews what it holds before a click. Two observed techniques compose: revelatio.studio's cursor-follow preview panel over a project list, and Glitch&Grit's card-hover follower with a slide transition on content swap. Observed at revelatio.studio (research/m55-round2/p2-revelatio.json row 11) and glitchandgrit (research/m55-round2/p2-glitchandgrit.json row 7); round-2 new demo pass frozen in research/2026-08-15-m55-gate-verdicts.md section 2.\n\n## Anatomy\n\n- A hover area wraps a list of rows; `onPointerMove` records pointer position relative to the area, not the panel itself.\n- A rAF loop (`tickFollow`) lerps the panel's displayed position toward the raw pointer position each frame, factor `FOLLOW_LERP = 0.16`, only while `followActive` is true and only on desktop (`isDesktop`, breakpoint `MOBILE_BREAKPOINT = 991px`).\n- The panel holds two stacked layers (`layerRefs`, front/back index tracked in `frontLayer`). Switching rows crossfades and slides the outgoing layer out and the incoming layer in: `yPercent` offset `SLIDE_OFFSET = 100`, `SLIDE_DURATION = 0.5s`, ease `power2.inOut`, direction set by whether the new row index is above or below the current one.\n- Row switches are debounced (`SWITCH_DEBOUNCE_MS = 110ms`) via `setTimeout` on `onPointerEnter`, so a fast sweep across rows doesn't fire a transition per row.\n- Panel visibility itself is a separate fade (`FADE_DURATION = 0.5s`, ease `power2.out`) triggered on the first row enter and reversed on area leave, independent of the row-switch slide.\n- On narrow viewports the panel drops the cursor-follow transform and docks to a fixed position (bottom-center) as a static preview instead.\n\n## States\n\n- Idle: panel opacity 0, `followActive` false, no rAF-driven transform writes.\n- Hovering, first entry: panel fades in (`fromTo` opacity 0 to 1); row switch scheduled after debounce.\n- Hovering, following: panel position lerps toward pointer every frame; row switches slide-crossfade the two content layers.\n- Leaving: `followActive` set false, pending switch timer cleared, panel fades out; rAF loop keeps running but stops writing transform.\n- Mobile/narrow: `desktopMode` false, panel renders as a static docked preview, follow lerp is skipped entirely.\n- Reduced motion: slide duration collapses to `0.01s`; the crossfade itself still runs but is imperceptible.\n\n## Variants\n\n- Row-count and panel size: content-driven, no logic change.\n- Direction-aware slide: reused verbatim for any vertical list where preview order should read as \"up\" or \"down\" relative to the current row.\n- Static mobile fallback: swap the cursor-follow transform for a fixed dock position, as done here, rather than disabling the preview outright.\n- CSS-only visibility propagation (revelatio.studio, research/m56-round3/revelatio-deep.json): `:has()` on the list parent toggles the follower's opacity/scale from pure CSS hover state, leaving JS only for position. Cuts listener work on dense lists.\n- Dimming sync: while one row is focused, non-active rows drop to opacity 0.35 so the followed preview and the focused row read as one unit. Note: the origin's mobile scroll-follow branch is touch-input gated; viewport-width emulation alone does not trigger it (research/m56-round3/revelatio-layer3.json).\n\n## Code\n\n```tsx\nconst tickFollow = () => {\n const panel = panelRef.current;\n if (panel && followActive.current && isDesktop.current) {\n current.current.x += (pointer.current.x - current.current.x) * FOLLOW_LERP;\n current.current.y += (pointer.current.y - current.current.y) * FOLLOW_LERP;\n panel.style.transform = `translate(${current.current.x}px, ${current.current.y}px) translate(1.25rem, -50%)`;\n }\n rafId.current = requestAnimationFrame(tickFollow);\n};\n```\n\n## Checks\n\n- `prefers-reduced-motion` is read once on mount and collapses slide duration to near-zero (`reducedMotion.current ? 0.01 : SLIDE_DURATION`); the panel still swaps content, it just doesn't visibly slide.\n- rAF loop is cancelled on unmount (`cancelAnimationFrame(rafId.current)`); the desktop/mobile media query listener is removed on unmount as well.\n- Row-switch timer (`switchTimer`) and all GSAP tweens on the panel and both layers are killed on unmount (`gsap.killTweensOf`), preventing tweens from firing after teardown.\n- Panel is `pointer-events-none`, so it never steals hover/click from the row list it floats over.\n- Desktop/mobile branch is read from a live `matchMedia` listener, not a one-time width check, so resizing across the breakpoint mid-session correctly toggles follow behavior.\n\n## Anti-patterns\n\n- Driving the panel position directly from the raw pointer event instead of lerping: the preview then snaps rather than trails, losing the signature \"catching up\" motion.\n- Switching row content on every `pointerEnter` without a debounce: a fast sweep across the list fires a transition per row and the slide never completes before the next one starts.\n- Reusing the cursor-follow transform on mobile: there is no persistent pointer position on touch, so the panel would either freeze at its last desktop coordinate or never appear.\n- Leaving the rAF loop unconditional: without the `followActive` guard it keeps writing `transform` every frame even while idle, for no visible effect.\n\n## Agent notes\n\n- prompt_phrases: \"preview panel follows the cursor over a list\", \"hover row to swap preview content\", \"cursor-trailing thumbnail panel\".\n- fallbacks: below the desktop breakpoint, drop the follow transform and dock the panel to a fixed position rather than hiding the preview feature entirely. Under `prefers-reduced-motion`, keep the content swap but collapse the slide duration instead of removing the transition altogether."},{"id":"idle-cursor-prewarm","name":"Idle Cursor Prewarm","pattern_group":"marketing","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m67","surface_refs":["websites"],"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.border.focus"],"code_asset":"examples/ui-vocabulary-site/src/components/idle-cursor-prewarm.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/marketing/idle-cursor-prewarm.md","body":"## Intent\n\n입력에 반응하는 표면은 대개 입력이 오기 전까지 죽어 있다. 첫 방문자는 그 표면이 살아 있다는 것을 모른 채 지나갈 수 있다. Partizan 은 로더가 걷히는 동안 **실제 커서 파이프라인에 가짜 좌표를 흘려** 표면을 먼저 움직여 둔다.\n\n핵심은 「데모 모드」를 따로 만들지 않는다는 것이다. 가짜 좌표가 실제 포인터와 **같은 입구**(`addPoint`)로 들어가므로, 사용자가 손을 대는 순간 궤적이 끊기지 않고 그대로 이어진다.\n\n## Anatomy\n\n- **무대** — 포인터에 반응하는 표면. 예열 켠 쪽과 끈 쪽 둘.\n- **같은 입구** — 가짜 좌표와 실제 포인터가 함께 들어가는 단일 함수. 이 구조가 이 recipe 의 전부다.\n- **예열 트윈** — 정규화 좌표를 10초 동안 흘린다. 실입력이 오면 놓는다.\n- **궤적 배열** — 예열과 실입력이 **공유**한다. 그래서 전환에 튐이 없다.\n\n## States\n\n- **예열 중** — 입력 전인데 표면이 이미 움직인다. 대기 구간을 덮는 용도다.\n- **실입력 인계** — 첫 포인터에 예열 트윈만 kill 하고 궤적은 이어받는다.\n- **정지** — 예열을 끈 쪽은 입력 전까지 완전히 멈춰 있다(대조군).\n- **reduced-motion** — 예열을 재생하지 않고 그 사실을 화면에 적는다.\n\n## Variants\n\n- **예열 켬** — 마운트 직후 10초.\n- **예열 끔** — 대조군.\n- **터치 기기** — 포인터가 없으므로 예열만으로 표면의 성격이 읽힌다.\n\n## Code\n\n```ts\n// 가짜 좌표와 실제 포인터가 같은 입구로 들어간다 — 별도 데모 모드를 만들지 않는다.\nconst addPoint = (stage: Stage, p: { x: number; y: number }) => {\n stage.points.push({ x: p.x, y: p.y, t: performance.now() })\n}\n\n// 예열 (pz2-15) — 정규화 좌표, x 가 1.2 까지 나가 무대 밖으로 빠진다.\ntween = gsap.to(fake, {\n keyframes: { x: [0, 1.2], y: [0.3, 0.5, 0.6] },\n duration: 10,\n ease: \"M0,0 C0.2,0 0,1 1,1\",\n onUpdate: () => addPoint(warm, fake),\n})\n\n// 실입력이 오면 예열만 놓고 궤적은 이어받는다.\nconst onMove = (e: PointerEvent) => {\n if (!warm.live) { warm.live = true; tween?.kill(); tween = null }\n addPoint(warm, { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height })\n}\n```\n\n## Checks\n\n- 손을 대는 순간 궤적이 **끊기지 않고 이어지는지** 확인 — 끊기면 입구가 둘로 갈린 것이다.\n- 예열이 대기 구간에서 끝나는지 확인. 조작 중에도 흐르면 소음이다.\n- `prefers-reduced-motion` 에서 예열이 꺼지는지 확인.\n- 다크·라이트 양쪽에서 궤적이 무대와 구별되는지 확인 — 이 계열에서 가장 흔한 사고다.\n- 언마운트에서 rAF·트윈·리스너·ResizeObserver 가 전부 정리되는지 확인.\n\n## Anti-patterns\n\n- **예열용 별도 경로를 만드는 것.** 가짜에서 진짜로 넘어갈 때 반드시 튄다.\n- **예열을 상시 재생하는 것.** 예열은 대기를 덮는 일이지 배경 애니메이션이 아니다.\n- **캔버스 색을 하드코딩하는 것.** 테마 전환에서 궤적이 사라진다 — 토큰을 다시 읽는 경로를 둔다.\n- **초기화 가드에 걸린 채 방치하는 것.** ref 하나가 안 붙으면 effect 전체가 조용히 return 하고 화면은 그냥 비어 보인다.\n\n## Agent notes\n\n- `prompt_phrases`: \"커서 따라다니는 효과\", \"로딩 중에 배경이 심심해\", \"마우스 궤적\", \"첫 화면이 죽어 보여\".\n- 대기 시간이 있는 화면이면 예열을 **로더 타임라인에 물린다** — 그 자리가 원본의 자리다.\n- 초기화 가드 회귀 주의: 이 컴포넌트는 ref 미부착으로 effect 가 매 마운트 return 하는 결함이 한 번 있었다(m67-repair). 캔버스가 `300x150` 이면 그 증상이다 — 그리기 코드를 의심하기 전에 ref 부착부터 본다.\n- 커서 궤적 자체가 목적이면 `image-trail-cursor`·`cursor-trail-shader` 가 다른 답이다.\n\n## 관측 (Partizan · 2026-08-17)\n\n```\ngsap.to(params.fakeAnimation, {\n keyframes: { x: [0, 1.2], y: [.3, .5, .6] },\n duration: 10,\n ease: \"beaucoup.inOut2\", // CustomEase \"M0,0 C0.2,0 0,1 1,1\"\n onUpdate: () => cursorTexture.addPoint(params.fakeAnimation)\n})\n```\n- 좌표는 **정규화 값**이다. `x` 가 `1.2` 까지 나가 무대 밖으로 빠지는 것도 원본 그대로다 — 궤적이 화면을 가로질러 나가는 인상을 만든다.\n- 로더 `hide()` 타임라인의 offset 0 에서 호출된다. 즉 예열은 대기 시간을 덮는 일이지 상시 연출이 아니다.\n\n## 규칙\n\n1. **가짜 입력은 진짜 입력과 같은 입구로 흘린다.** 별도 경로를 만들면 전환 순간에 튄다.\n2. **실입력이 오면 예열을 놓되 궤적은 이어받는다.** 예열 트윈만 kill 하고 좌표 배열은 그대로 둔다.\n3. **예열은 대기 구간에서 끝낸다.** 사용자가 이미 조작 중인데도 계속 흐르면 그것은 예열이 아니라 소음이다.\n4. **`prefers-reduced-motion` 에서는 예열을 재생하지 않는다.**\n5. **포인터가 없는 기기를 위한 답이기도 하다** — 터치에서는 예열만으로 표면의 성격이 읽힌다.\n\n## 우리가 정한 값\n\n궤적 표현 전부. 원본은 WebGL 셰이더 텍스처이고 여기서는 Canvas2D 폴리라인이다 — 이 recipe 의 요지는 GL 이 아니라 **좌표 주입**이라 GL 을 쓰지 않았다. 잔상 수명 1200ms, 선 두께 2~12px, 머리 점 반지름 7px 도 우리 값이다. 캔버스 색은 `getComputedStyle` 로 토큰을 다시 읽어 테마 전환을 따라온다.\n\n## 수리 기록 (m67-repair)\n\n원 판정은 **보류(①결함)** — \"두 캔버스 모두 포인터에 반응 없음\". 원인은 포인터 배선이 아니라 초기화였다: `rootRef` 가 선언·사용만 되고 어떤 엘리먼트에도 붙어 있지 않아, effect 첫 줄의 가드(`!root`)가 매 마운트마다 return 했다. 캔버스가 브라우저 기본값 `300×150` 에서 한 번도 리사이즈되지 않았고 예열 트윈도 리스너도 등록된 적이 없다. ref 를 붙이자 캔버스 `345×256`, 포인터 40회에 좌표 40건 유입(`prewarming=true`)으로 재현 불가.\n\n## term 대응\n\n**빈 배열이다.** `terms.yml` 에 커서·포인터 축의 term 이 없다(`cursor`·`pointer` 검색 0건). 인접 term 을 억지로 붙이면 태그 축이 오염되므로 비워 두고 **용어 신설 후보**로 큐에 남긴다(M67 D6).\n\n## 관련\n\n[[cursor-trail-shader]]·[[image-trail-cursor]](같은 커서 궤적 계열 — 이쪽은 입력 전 구간을 다룬다) · `research/m67-batch2-css/p2-ledger.json` 행 `pz2-15`·`pz2-03`\n\n## Changelog\n\n- 2026-08-18 — m67-batch2-css 회차 신설. 보류(결함) 1회 수리 후 재판정 통과."},{"id":"image-trail-cursor","name":"Image Trail Cursor","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["websites"],"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/image-trail-cursor.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/image-trail-cursor.md","body":"## Intent\n\nAn image trail cursor turns pointer travel itself into a gallery preview: as the pointer crosses a container, short-lived clones spawn along the path and fade out behind it instead of a single cursor-follower. Observed at revelatio.studio (research/m55-round2/p2-revelatio.json row 3, \"커서 추종 이미지 트레일 — 마우스 이동 거리가 카드 폭의 50%를 넘을 때마다 클론 스폰, 400ms 후 트랜지션아웃 클래스, 1200ms 후 DOM 제거\"); round-2 new-demo user gate pass frozen in research/2026-08-15-m55-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Spawn gate: a card is only spawned when pointer travel distance since the last spawn point exceeds `stepDistance = cardWidth * 0.5` (observed `CARD_WIDTH = 96`, so `STEP_DISTANCE = 48`). Distance under that threshold is ignored, so the trail density tracks pointer speed, not raw pointermove event rate.\n- Each spawned card is positioned at the pointer point relative to the container, numbered with an incrementing index, and mounted visible (`scale-100 opacity-100`).\n- Lifecycle timers per card: after `OUT_DELAY_MS` (400ms) the card gets a leaving flag and transitions to `scale-90 opacity-0`; after `REMOVE_DELAY_MS` (1200ms) it is filtered out of state entirely. The transition itself runs on a 300ms ease-out.\n- Pointer leaving the container resets the last-point ref to null, so the next re-entry does not measure travel distance against a stale point.\n\n## States\n\n- Idle: no last point recorded; next pointermove only seeds the reference point, no spawn.\n- Tracing: pointer moving inside the container fast enough to cross the step threshold repeatedly; cards spawn in sequence along the path.\n- Leaving: a card past its 400ms out-delay, scaling down and fading, still in the DOM.\n- Gone: a card past its 1200ms remove-delay, filtered out of state.\n\n## Variants\n\n- Card count as gallery gate: swap the numbered placeholder card for an actual image/media element per index, cycling through a fixed image set.\n- Threshold tuning: a smaller `stepDistance` produces a denser trail at the cost of more concurrent DOM nodes; a larger one reads as sparser, more deliberate marks.\n- Reduced-motion collapse: shorten both delays to the same value so a card appears and disappears without a visible hold, rather than removing the effect outright.\n\n## Code\n\n```tsx\nconst dx = e.clientX - last.x;\nconst dy = e.clientY - last.y;\nconst dist = Math.hypot(dx, dy);\nif (dist >= STEP_DISTANCE) {\n lastPointRef.current = { x: e.clientX, y: e.clientY };\n spawn(e.clientX, e.clientY);\n}\n```\n\n## Checks\n\n- `prefers-reduced-motion` is read once on mount and collapses both delays to near-zero (out delay 0ms, remove delay equal to the normal out delay) instead of disabling the pattern.\n- Every `setTimeout` is tracked in a `Set` and cleared on unmount; `pointermove`/`pointerleave` listeners are removed on cleanup.\n- Card lifecycle is driven by tracked timers against fixed constants, not by CSS animation duration alone, so state and visual transition cannot drift apart.\n- Spawn is gated by distance, not by raw event count, so a device with a high-frequency pointer does not spawn a card per pixel.\n\n## Anti-patterns\n\n- Spawning a card on every `pointermove` event: without the distance gate the trail becomes a dense smear instead of discrete, readable marks, and DOM node count grows unbounded during fast movement.\n- Removing a card immediately at the out-delay instead of running a separate remove-delay: the fade-out transition gets cut short and reads as a pop instead of a dissolve.\n- Leaving spawn timers running after pointerleave without resetting the last-point ref: re-entry after a pause measures against a stale point and spawns immediately regardless of actual travel distance.\n\n## Agent notes\n\n- prompt_phrases: \"mouse trail spawns image clones\", \"cursor drags a gallery preview behind it\", \"numbered cards follow pointer path\".\n- fallbacks: on touch devices with no pointermove trail, replace with a static preview grid or a tap-to-reveal single card; do not attempt to simulate the trail from touchmove, which fires too coarsely for the distance-gated spawn logic to read as intentional."},{"id":"kv-frame-stack-reveal","name":"KV Frame Stack Reveal","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/kv-frame-stack-reveal.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/kv-frame-stack-reveal.md","body":"## Intent\n\nA key visual frame stack reveal builds the hero out of nine stacked layers that appear one at a time at a fixed interval, so the image assembles in front of the visitor instead of arriving finished. The loading strategy is part of the pattern, not an optimization bolted on afterward: only the first frame is fetched at high priority, the remaining eight at low, so the hero paints immediately and the assembly is paid for with bandwidth that was going to be idle anyway. A returning visitor above the fold gets an instant state that skips the sequence. Observed on bleach-mh's key visual (research/m56-round3/bleach-mh-disposition.json row p2-bleach-mh.json rows[1], deepened in bleach-mh-deep.json d3[3]). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Nine absolutely positioned layers share one stacking context; a counter increments and every layer below the counter is visible.\n- Scheduling is one `setTimeout` per frame at `index * 200ms`, so the whole reveal lasts `1600ms` and no frame depends on the previous one having painted.\n- Each layer carries `data-state` of `hidden`, `visible`, or `instant`. The third value is not a variant of the first two: it means \"show without transition\" for a repeat visit.\n- `fetchPriority=\"high\"` on frame one and `\"low\"` on the rest is what keeps the sequence from competing with the first paint.\n- The demo replaces the source's nine webp frames with procedural silhouettes at slightly different rotations and scales, since the artwork cannot be redistributed. The stacking, timing, and state vocabulary are unchanged.\n\n## States\n\n- `hidden`: layer is transparent and not yet scheduled.\n- `visible`: layer has faded in on its scheduled tick.\n- `instant`: layer is shown with no transition, for a revisit where the sequence would be a delay rather than an event.\n- Sequence complete: all nine layers visible, timers cleared.\n\n## Variants\n\n- Frame count and interval trade assembly texture against total time. Nine at 200ms is the observed pair; fewer frames need a longer interval to still read as assembly.\n- Layer difference: rotation and scale offsets (this demo) or genuinely different artwork per frame, which is what the source does.\n- Trigger: on mount (observed) or on viewport entry, which suits a key visual placed below the fold.\n\n## Code\n\n```tsx\nuseEffect(() => {\n const timers = Array.from({ length: FRAME_COUNT }, (_, i) =>\n window.setTimeout(() => setShown(i + 1), i * FRAME_INTERVAL_MS),\n );\n return () => timers.forEach(window.clearTimeout);\n}, []);\n```\n\n## Checks\n\n- Clear every timer on unmount. Nine pending timeouts writing state into an unmounted component is the classic version of this bug.\n- Give the container a fixed aspect ratio before the frames arrive, or the page reflows nine times during the reveal.\n- Only the first frame gets high fetch priority. Marking all nine high is the same as marking none.\n- The `instant` state must be decided before the first paint, otherwise the revisit shows a flash of the animated path.\n- Check the sequence with the network throttled. If frames arrive slower than the interval, the reveal degrades into a random assembly, which is acceptable only if it still reads as intentional.\n\n## Anti-patterns\n\n- Chaining each frame's timeout onto the previous frame's completion: one slow decode then delays everything after it.\n- Animating each layer's transform as well as its opacity: the layers are already offset relative to each other, and adding motion makes the stack read as clutter.\n- Loading all nine at high priority: the hero's first paint now waits on eight images the visitor cannot see yet.\n- Using this for content that must be readable immediately, such as a headline. It is an image assembly device.\n\n## Agent notes\n\n- prompt_phrases: \"hero image that assembles from stacked layers\", \"key visual revealed frame by frame\", \"staggered image stack reveal with fetch priority\".\n- fallbacks: with reduced motion, jump to the `instant` state so all layers are present without the sequence. If images fail to load, the layer stack must still resolve to a valid composition rather than an empty box."},{"id":"landing-hero","name":"Landing Hero","pattern_group":"marketing","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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- Shell and content are separate exports. The reusable asset is the shell: the centered column, the copy measure, and the vertical rhythm, with named slots (`headline`, `subhead`, `actions`, `search`, `showcase`, `backdrop`). A product's own hero is that shell with its content poured in.\n- Slots ship empty. No sample headline, no stand-in gradient, no bordered box where a screenshot would go — a placeholder is a design decision the consumer did not make, and it survives into their product more often than it gets replaced.\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":"layered-noise-terrain","name":"Layered Noise Terrain","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.border.default","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/layered-noise-terrain.tsx","component_refs":[],"term_refs":["canvas-toolbar","slider"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/marketing/layered-noise-terrain.md","body":"## Intent\n\nA seeded heightmap whose character comes from layer count rather than amplitude. Each depth pushes resolution up by upresFactor times the layer index, and a temper term damps height near a baseline so the surface stays usable.\n\nObserved at https://slowroads.io/ (SLW-05 (+ SLW-04), accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Layer resolutions are generated, not listed: starting at resolution, each step adds upresFactor times the layer index. The observed default (5, 2, depth 3) yields [5, 7, 11].\n- The generated array is printed on screen, because the formula producing it is the transferable part.\n- A hand-rolled seeded value noise keeps the same seed reproducible, which is why the source preloads a seeded PRNG globally.\n- Temper damps heights below a threshold so noise does not spike against a road or baseline.\n\n## Pitfalls\n\n- One noise octave with a bigger amplitude is not the same thing. The readable effect comes from layers of different resolution overlapping.\n\n## States\n\n- depth 1 — 단일 층, 밋밋한 굴곡.\n- depth 3~5 — 큰 굴곡 위에 잔결이 얹힌다.\n- temper 켬 — 기준선 근처 높이가 완충된다.\n\n## Variants\n\n- depth·resolution·upresFactor·시드 4축.\n- temper on/off.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/layered-noise-terrain.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 층 수 대신 진폭만 키워 결이 아니라 굴곡만 커지게 하는 것.\n- 시드를 쓰지 않아 같은 입력이 다른 그림을 내는 것.\n\n## Agent notes\n\n층 해상도 배열을 화면에 인쇄한다. 공식이 배열을 만드는 과정이 이 기법의 전달 대상이다.\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--foreground`\n- `--muted`\n- `--muted-foreground`\n- `--radius`","code_source":"// 출처: research/m68/p2-slowroads.json SLW-05 (+ SLW-04 temper) — Slow Roads\n// https://slowroads.io/ (접근일 2026-08-17), 청크 HillsHeightmap.c27dcb3d.js 직독:\n// generateLayerResolutions(){ let t=this.config.resolution;\n// for(let h=0;h<this.config.depth;h++) this.layerResolutions.push(t),\n// t = t + this.config.upresFactor*(h+1) }\n// 기본 config: seed, scale:1, offset:0, resolution:5, upresFactor:2, depth:3,\n// depthHeightFactor:1 → layerResolutions=[5,7,11]. SLW-04 temper: temperBelow 아래\n// 높이를 완충해 기준선 근처가 튀지 않게 한다.\n// visual: 층수를 올릴수록 성긴 큰 굴곡 위에 촘촘한 잔결이 얹혀 단일 노이즈가 아니라\n// \"층이 겹쳐 보이는\" 지형이 된다. 이 데모는 2D 그레이스케일 높이맵으로 보여준다.\n//\n// 우리가 정한 값(관측값 아님 — 명시한다): 층별 진폭 가중치는 원본이 관측되지 않아\n// amp(l) = 1/(l+1) 로 감쇠시켜 fBm 형태를 만들었다(depthHeightFactor 는 상수 1로 고정\n// 흡수). temper 강도(TEMPER_STRENGTH=0.45)와 임계값(TEMPER_BELOW=0.35)도 우리가 정했다.\nimport { useEffect, useMemo, useRef, useState } from \"react\"\n\nconst NOISE_W = 320\nconst NOISE_H = 180\nconst TEMPER_BELOW = 0.35\nconst TEMPER_STRENGTH = 0.45\n\n/** SLW-05 원문 그대로: t 는 매 층마다 upresFactor*(h+1) 씩 누적된다. */\nfunction generateLayerResolutions(resolution: number, upresFactor: number, depth: number): number[] {\n const layers: number[] = []\n let t = resolution\n for (let h = 0; h < depth; h++) {\n layers.push(t)\n t = t + upresFactor * (h + 1)\n }\n return layers\n}\n\n/** 32비트 정수 해시 — 시드가 같으면 항상 같은 값(재현성). */\nfunction hash01(ix: number, iy: number, seed: number): number {\n let h = ix * 374761393 + iy * 668265263 + seed * 2246822519\n h = Math.imul(h ^ (h >>> 13), 1274126177)\n h = h ^ (h >>> 16)\n return ((h >>> 0) % 1000000) / 1000000\n}\n\n/** 시드 기반 value noise — 격자점 해시를 smoothstep 보간한다. */\nfunction valueNoise(nx: number, ny: number, res: number, seed: number, layerIndex: number): number {\n const gx = nx * res\n const gy = ny * res\n const ix = Math.floor(gx)\n const iy = Math.floor(gy)\n const fx = gx - ix\n const fy = gy - iy\n const layerSeed = seed + layerIndex * 977\n const h00 = hash01(ix, iy, layerSeed)\n const h10 = hash01(ix + 1, iy, layerSeed)\n const h01 = hash01(ix, iy + 1, layerSeed)\n const h11 = hash01(ix + 1, iy + 1, layerSeed)\n const sx = fx * fx * (3 - 2 * fx)\n const sy = fy * fy * (3 - 2 * fy)\n const a = h00 + (h10 - h00) * sx\n const b = h01 + (h11 - h01) * sx\n return a + (b - a) * sy\n}\n\n/** 층을 누적 합산해 최종 높이를 만든다. temper 는 기준선(TEMPER_BELOW) 아래를 완충한다. */\nfunction heightAt(nx: number, ny: number, layerResolutions: number[], seed: number, temper: boolean): number {\n let sum = 0\n let weightTotal = 0\n for (let l = 0; l < layerResolutions.length; l++) {\n const amp = 1 / (l + 1)\n weightTotal += amp\n sum += amp * valueNoise(nx, ny, layerResolutions[l], seed, l)\n }\n let h = weightTotal > 0 ? sum / weightTotal : 0.5\n if (temper && h < TEMPER_BELOW) {\n h = TEMPER_BELOW - (TEMPER_BELOW - h) * (1 - TEMPER_STRENGTH)\n }\n return h\n}\n\n/** 토큰 색을 0~255 RGB 로 — ImageData 에 바로 쓸 수 있게. */\nfunction resolveTokenRgb(el: HTMLElement, varName: string, fallback: [number, number, number]): [number, number, number] {\n const raw = getComputedStyle(el).getPropertyValue(varName).trim()\n if (!raw) return fallback\n const probe = document.createElement(\"canvas\")\n probe.width = probe.height = 1\n const ctx = probe.getContext(\"2d\")\n if (!ctx) return fallback\n ctx.fillStyle = raw\n ctx.fillRect(0, 0, 1, 1)\n const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data\n return [r, g, b]\n}\n\nexport default function LayeredNoiseTerrain() {\n const rootRef = useRef<HTMLDivElement>(null)\n const canvasRef = useRef<HTMLCanvasElement>(null)\n\n const [depth, setDepth] = useState(3)\n const [resolution, setResolution] = useState(5)\n const [upresFactor, setUpresFactor] = useState(2)\n const [seed, setSeed] = useState(1337)\n const [temper, setTemper] = useState(false)\n\n const layerResolutions = useMemo(\n () => generateLayerResolutions(resolution, upresFactor, depth),\n [resolution, upresFactor, depth],\n )\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n\n const draw = () => {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n const low = resolveTokenRgb(root, \"--muted-foreground\", [140, 140, 140])\n const high = resolveTokenRgb(root, \"--foreground\", [23, 23, 23])\n const img = ctx.createImageData(NOISE_W, NOISE_H)\n for (let j = 0; j < NOISE_H; j++) {\n const ny = j / (NOISE_H - 1)\n for (let i = 0; i < NOISE_W; i++) {\n const nx = i / (NOISE_W - 1)\n const h = heightAt(nx, ny, layerResolutions, seed, temper)\n const idx = (j * NOISE_W + i) * 4\n img.data[idx] = low[0] + (high[0] - low[0]) * h\n img.data[idx + 1] = low[1] + (high[1] - low[1]) * h\n img.data[idx + 2] = low[2] + (high[2] - low[2]) * h\n img.data[idx + 3] = 255\n }\n }\n ctx.putImageData(img, 0, 0)\n }\n\n draw()\n const schemeQuery = window.matchMedia(\"(prefers-color-scheme: dark)\")\n const onSchemeChange = () => draw()\n schemeQuery.addEventListener(\"change\", onSchemeChange)\n const themeObserver = new MutationObserver(draw)\n themeObserver.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n\n return () => {\n schemeQuery.removeEventListener(\"change\", onSchemeChange)\n themeObserver.disconnect()\n }\n }, [layerResolutions, seed, temper])\n\n return (\n <div\n ref={rootRef}\n data-testid=\"layered-noise-terrain\"\n data-layer-resolutions={layerResolutions.join(\",\")}\n className=\"flex w-full max-w-3xl flex-col gap-3\"\n >\n <div className=\"overflow-hidden rounded-lg border border-border bg-muted\">\n <canvas\n ref={canvasRef}\n width={NOISE_W}\n height={NOISE_H}\n className=\"block h-[320px] w-full\"\n />\n </div>\n\n <p className=\"break-keep text-sm text-foreground\">\n layerResolutions = [{layerResolutions.join(\", \")}]\n </p>\n\n <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-2\">\n <label className=\"flex flex-col gap-1 text-xs text-muted-foreground\">\n <span>층수 depth: {depth}</span>\n <input\n type=\"range\"\n min={1}\n max={5}\n step={1}\n value={depth}\n onChange={(e) => setDepth(Number(e.target.value))}\n className=\"w-full accent-foreground\"\n aria-label=\"층수 depth\"\n />\n </label>\n\n <label className=\"flex flex-col gap-1 text-xs text-muted-foreground\">\n <span>첫 층 해상도 resolution: {resolution}</span>\n <input\n type=\"range\"\n min={2}\n max={12}\n step={1}\n value={resolution}\n onChange={(e) => setResolution(Number(e.target.value))}\n className=\"w-full accent-foreground\"\n aria-label=\"첫 층 해상도 resolution\"\n />\n </label>\n\n <label className=\"flex flex-col gap-1 text-xs text-muted-foreground\">\n <span>층당 증분 upresFactor: {upresFactor}</span>\n <input\n type=\"range\"\n min={0}\n max={6}\n step={1}\n value={upresFactor}\n onChange={(e) => setUpresFactor(Number(e.target.value))}\n className=\"w-full accent-foreground\"\n aria-label=\"층당 증분 upresFactor\"\n />\n </label>\n\n <label className=\"flex flex-col gap-1 text-xs text-muted-foreground\">\n <span>시드 seed</span>\n <input\n type=\"number\"\n value={seed}\n onChange={(e) => setSeed(Number(e.target.value) || 0)}\n className=\"w-full rounded-md border border-border bg-background px-2 py-1 text-foreground\"\n aria-label=\"시드 seed\"\n />\n </label>\n\n <label className=\"flex items-center gap-2 text-xs text-muted-foreground sm:col-span-2\">\n <input\n type=\"checkbox\"\n checked={temper}\n onChange={(e) => setTemper(e.target.checked)}\n className=\"accent-foreground\"\n aria-label=\"temper 감쇠\"\n />\n <span>temper — 기준선({TEMPER_BELOW}) 아래 높이를 완충해 튀지 않게 한다</span>\n </label>\n </div>\n\n <p className=\"break-keep text-xs text-muted-foreground\">\n depth 를 올릴수록 첫 층이 만든 성긴 큰 굴곡 위에 이후 층들의 촘촘한 잔결이\n 겹쳐 얹힙니다. resolution 과 upresFactor 는 각 층이 얼마나 조밀해지는지를,\n 같은 시드는 항상 같은 지형을 재현합니다.\n </p>\n </div>\n )\n}\n"},{"id":"linear-spring-easing","name":"Linear Spring Easing","pattern_group":"marketing","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m67","surface_refs":["websites","components-primitives"],"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"],"code_asset":"examples/ui-vocabulary-site/src/components/linear-spring-easing.tsx","component_refs":[],"term_refs":["easing","duration"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/marketing/linear-spring-easing.md","body":"## Intent\n\n스프링 느낌을 내려고 애니메이션 런타임을 들이는 것이 기본값이 될 필요는 없다. CSS `linear()` 는 다중 스톱으로 임의 곡선을 그릴 수 있어 **오버슈트와 감쇠 진동을 런타임 0 으로 근사**한다. Made With Gsap 은 hover 전이에 그 방법을 쓴다 — 8-스톱, 오버슈트 피크 `1.143` 이 진행률 `19.2%` 지점, `0.3s`.\n\n이 recipe 는 그 근사를 JS 스프링·평범한 `ease-out` 과 **같은 조작으로 나란히** 두고, 목표선을 넘었다 돌아오는 트랙이 어느 것인지 보게 한다.\n\n## Anatomy\n\n- **트랙** — 같은 조작을 동시에 받는 세 줄(CSS `linear()` · JS 스프링 · `ease-out` 대조군).\n- **목표선** — 각 트랙에 그은 점선. 넘었다 돌아오는 트랙이 오버슈트를 가진 쪽이다.\n- **퍽** — 트랙 위를 움직이는 물체. 무대와 다른 표면 층을 쓴다.\n- **지속시간 슬라이더** — 근사와 실제 스프링이 갈라지는 구간을 드러낸다.\n\n## States\n\n- **대기** — 퍽이 출발선에 있다.\n- **진행 중** — 세 트랙이 같은 시각에 출발해 서로 다른 곡선을 그린다.\n- **도착** — 오버슈트가 있는 두 트랙만 목표선을 넘었다 돌아온다.\n- **reduced-motion** — 오버슈트를 끄고 즉시 도착한다.\n\n## Variants\n\n- **CSS `linear()` 8-스톱** — 런타임 0. 관측된 피크 `1.143 @ 19.2%`, `0.3s`.\n- **JS 스프링** — 중단·재타깃이 가능한 대신 런타임이 필요하다.\n- **`ease-out` 대조군** — 오버슈트 없음. 스프링이 필요 없는 전이의 기본값.\n\n## Code\n\n```css\n/* 관측: 스톱 8개 · 피크 1.143 @ 19.2% · 0.3s. 나머지 스톱은 감쇠 진동으로 재구성했다. */\n.pop {\n transition-property: transform;\n transition-duration: 0.3s;\n transition-timing-function: linear(\n 0, 0.63 9.6%, 1.143 19.2%, 0.95 38.4%, 1.017 57.6%, 0.994 76.8%, 1.001 88.4%, 1\n );\n}\n@media (prefers-reduced-motion: reduce) {\n .pop { transition-duration: 0.01s; transition-timing-function: linear(0, 1); }\n}\n```\n\n## Checks\n\n- 오버슈트가 **실제로 목표를 넘는지** 확인 — 넘지 않으면 `linear()` 를 쓸 이유가 없다.\n- 진행 중에 목표가 바뀌는 조작이 있는지 확인 — 있으면 `linear()` 로는 안 되고 JS 스프링이 필요하다.\n- `prefers-reduced-motion` 에서 오버슈트가 꺼지는지 확인.\n- 색·불투명도처럼 넘을 수 없는 속성에 스프링을 걸지 않았는지 확인.\n\n## Anti-patterns\n\n- **스프링이 필요하다는 이유만으로 애니메이션 런타임을 들이는 것.** 중단이 필요 없으면 CSS 로 끝난다.\n- **`linear()` 로 드래그를 처리하려는 것.** 진행 중 재타깃이 안 돼 손을 떼는 순간 튄다.\n- **오버슈트를 모든 전이의 기본값으로 두는 것.** 목록·색 전이에서는 산만함이 된다.\n- **재구성한 스톱을 관측값처럼 문서화하는 것.** 어디까지가 관측이고 어디부터가 우리 값인지 구분을 잃는다.\n\n## Agent notes\n\n- `prompt_phrases`: \"스프링 느낌 주고 싶어\", \"통통 튀는 애니메이션\", \"라이브러리 없이 스프링 되나\", \"이징 뭐 쓸까\".\n- **중단 가능성**을 먼저 묻는다 — 필요 없으면 CSS `linear()`, 필요하면 `spring-drag-snap-card` 계열.\n- 관측값(피크·피크 위치·duration)만 인용하고 나머지 스톱은 이 문서의 재구성값임을 밝힌다.\n\n## 규칙\n\n1. **오버슈트가 필요한지 먼저 묻는다.** 목표선을 넘지 않아도 되는 전이(색·불투명도)에 스프링을 쓰면 산만해질 뿐이다.\n2. **스프링이 필요하고 중단·재타깃이 필요 없으면 `linear()` 로 충분하다.** 런타임을 안 들이는 쪽이 기본값이다.\n3. **중단 가능성이 필요하면 JS 스프링을 쓴다.** 진행 중에 목표가 바뀌는 드래그·제스처는 `linear()` 로 못 한다([[spring-drag-snap-card]] 의 재타깃 성질).\n4. **`prefers-reduced-motion` 에서는 오버슈트를 끈다.**\n\n## 우리가 정한 값\n\n관측된 것은 **스톱 개수(8)·피크 값(1.143)·피크 위치(19.2%)·duration(0.3s)** 넷뿐이다. 원 사이트의 전체 스톱 목록은 확보하지 못했다. 나머지 여섯 스톱은 「첫 오버슈트 = 반주기」라는 성질에서 주기 `0.384` 를 잡고 반주기마다 진폭을 약 0.35배로 감쇠시켜(`0.143 → 0.050 → 0.017 → 0.006`) 재구성했다. 데모 화면에도 이 사실을 캡션으로 적어 두었다 — 관측값과 재구성값을 섞지 않는다.\n\n같은 사이트의 다른 전이는 `cubic-bezier(.9,0,.1,1)` `0.4s` 를 쓴다(마스크 리빌).\n\n## term 대응\n\n- `easing` — 「같은 duration 이라도 easing 에 따라 느낌이 달라진다」는 정의에 **런타임 없이 어디까지 되는가**의 경계를 더한다.\n- `duration` — 이 recipe 의 슬라이더가 duration 을 바꿔 근사와 실제 스프링이 갈라지는 구간을 드러낸다.\n\n## 관련\n\n[[spring-drag-snap-card]](중단 가능한 스프링이 필요한 경우 — 이 recipe 가 그 경계의 반대편) · [[motion-principles]] · `research/m67-batch2-css/premise.md` §4-1 행 #5\n\n## Changelog\n\n- 2026-08-18 — m67-batch2-css 회차 신설. 사용자 관측 게이트 통과."},{"id":"magnetic-hover-button","name":"Magnetic Hover Button","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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-08-17","source_path":"recipes/marketing/magnetic-hover-button.md","body":"## Intent\n\nA magnetic hover button is pulled toward the cursor while the cursor is **near** it, and springs back once the cursor leaves the influence radius. The field starts outside the button: a lean that only begins once the pointer is already inside is a hover state, not magnetism. 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- A window-level pointer-move handler, not an element-level one: the influence radius extends beyond the button, so the element's own pointer events cannot be the trigger.\n- Distance falloff `L = ((R - d) / R) ^ 1.6` driving translate and a small scale; the pull is capped well under the button's own size (attraction, not chase).\n- Interruptible tweens: every pointer move overwrites the running tween, so a re-entry mid-return never snaps.\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- Near (inside the influence radius, outside the button included): pulled toward the cursor, strongest at closest approach.\n- Leave (outside the radius): springs back to origin on an elastic return (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- **Non-interruptible tweens**: duration-based easing is fine only if each pointer move overwrites the running tween. Without overwrite, a cursor re-entering mid-return snaps.\n- **Trigger bound to the element**: `onPointerMove` on the button itself cannot fire before the cursor is inside, which removes the one behaviour that makes this a magnet.\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":"magnetic-repulsion-cluster","name":"Magnetic Repulsion Cluster","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/magnetic-repulsion-cluster.tsx","component_refs":["magnetic-hover-button"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/magnetic-repulsion-cluster.md","body":"## Intent\n\nA magnetic repulsion cluster gives a field of decorative objects one shared physical rule: every object inside the influence radius is pushed directly away from the pointer, rotated and scaled in proportion to how close it is, and returned on an elastic ease when the pointer leaves. Because the rule is shared, a scattered arrangement that would otherwise read as clip art reads as a single responsive material. When the pointer is not over the field, the viewport center acts as a virtual cursor while scrolling, so the field is alive on touch devices too. Observed on noth.in's `.formes-w` block (research/m56-round3/noth-in-deep.json d3[19]). This item also corrects a round-2 misjudgment that recorded the same glyphs as static decoration with no pointer binding, which a full read of the source function overturned. Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Falloff is `L = ((influenceRadius - distance) / influenceRadius) ^ 1.6` with `influenceRadius = 460`. The exponent above 1 is what keeps distant objects almost still while near ones move hard.\n- Displacement is polar, not axis-aligned: `x = -cos(angle) * L * maxDistance`, `y = -sin(angle) * L * maxDistance`, with `maxDistance = 380`.\n- Rotation and scale ride the same `L`: `rotation = base - cos(angle) * L * rotForce` (`rotForce = 30`) and `scale = 1 + L * scaleForce` (`scaleForce = 0.2`).\n- Push and return use deliberately different curves: push is `0.45s power4.out`, return is `1.2s elastic.out(1, 0.35)`. The asymmetry is what makes the field feel elastic rather than mechanical.\n- Objects outside the radius are tweened home rather than left where they were, so the field always resolves to its arrangement.\n- When the pointer is absent, a `ScrollTrigger` over the field's scroll range drives the same math from the viewport center point, so scrolling past the block animates it.\n- Every object is absolutely positioned by percentage, so the arrangement survives resizing without recomputation.\n- The formula was verified numerically against the live site on 2026-08-16 (M58 step-8, research/m56-round3/noth-in-disposition.json `unverifiedResolved`): all eleven objects moved directly away from the pointer (alignment 1.00), and displacement matched `((460 - d) / 460) ^ 1.6 * 380` within 3px at both ends of the range (`d = 121` gave 230px against a predicted 233px, `d = 384` gave 20px against 21px). The field only responds to pointer events dispatched on the container itself, which is why an earlier pass that dispatched on `window` recorded it as barely responsive.\n\n## States\n\n- Rest: all objects at their authored positions, scale 1, base rotation.\n- Under pointer: objects within the radius displaced, rotated, and scaled by their own `L`.\n- Pointer leaves: elastic return, overshooting slightly before settling.\n- Scroll-driven (no pointer): the viewport center substitutes for the cursor while the block is in range.\n\n## Variants\n\n- Field density: five to a dozen objects is the readable range. Past that the rule stops being legible as a rule.\n- Object mix: brand glyphs plus simple shapes (this asset) or images. Shapes tint from tokens, images do not, which matters for theme support.\n- Mobile constants: the source halves the field (`260 / 110 / 12 / 0.1`). Reducing radius and force rather than disabling the effect keeps the character on small screens.\n\n## Code\n\n```tsx\nconst falloff = Math.pow((INFLUENCE_RADIUS - distance) / INFLUENCE_RADIUS, 1.6);\ngsap.to(el, {\n x: -Math.cos(angle) * falloff * MAX_DISTANCE,\n y: -Math.sin(angle) * falloff * MAX_DISTANCE,\n rotation: base - Math.cos(angle) * falloff * ROT_FORCE,\n scale: 1 + falloff * SCALE_FORCE,\n duration: PUSH_DURATION, ease: PUSH_EASE,\n});\n```\n\n## Checks\n\n- Register the ScrollTrigger plugin once and kill the instance on unmount, or a route change leaves a trigger driving detached nodes.\n- Tween objects home when they exit the radius. Skipping that leaves a permanently deformed field after a fast pointer sweep.\n- Measure distance against each object's center, not its top-left corner, or objects push off-axis in a way that reads as a bug.\n- Keep the container `overflow: hidden` or give the field enough padding: `maxDistance = 380` will push objects well outside a small box.\n- Cap the influence radius relative to the container. A radius larger than the field means every object always moves and the falloff stops communicating proximity.\n\n## Anti-patterns\n\n- Attraction instead of repulsion for a decorative field: objects pile onto the cursor and occlude the content behind them.\n- One duration for push and return: the elasticity disappears and the field reads as a stiff mechanism.\n- Driving each object with its own listener: one pointer handler fanning out to the field is both cheaper and guarantees the objects agree about where the pointer is.\n- Putting interactive content inside the field: displaced click targets are a usability problem, and this is a decoration layer.\n\n## Agent notes\n\n- prompt_phrases: \"shapes that scatter away from the mouse\", \"magnetic repulsion field of decorative glyphs\", \"cursor pushes floating objects with elastic return\".\n- fallbacks: with reduced motion, hold the objects at rest and skip both the pointer and scroll drivers. On touch, the scroll-driven virtual cursor is the whole interaction, so keep it rather than disabling the block."},{"id":"name-crossfade-state-machine","name":"Name Crossfade State Machine","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/name-crossfade-state-machine.tsx","component_refs":["character-stack-crossfade"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/name-crossfade-state-machine.md","body":"## Intent\n\nA name crossfade state machine swaps a large background label only after its exit has actually finished, and it learns that from the browser rather than from a timer. The selection changes, the label goes to `exit`, and the `transitionend` event for the opacity property is what commits the new content, bumps a remount key, and returns to `idle`. Pairing that with an immediate foreground swap is the real subject: two elements answering the same selection on deliberately different clocks, so the foreground feels responsive while the background feels weighty. Observed on bleach-mh's character section (research/m56-round3/bleach-mh-deep.json d3[2] and d3[18]). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Two pieces of state: the index currently displayed and the stage, which is `idle` or `exit`. When the selected index differs from the displayed one, the stage goes to `exit` immediately.\n- The commit is a three-step transaction inside the `transitionend` handler: set the displayed index, increment the remount key, return to `idle`.\n- The handler guards on all three conditions: `event.target === event.currentTarget`, `propertyName === \"opacity\"`, and stage is `exit`. Without the first two, any bubbling transition from a child commits the swap early.\n- The remount key on the wrapper forces a fresh DOM node per swap, which restarts the entrance transition from its start value instead of interpolating from wherever the previous one stopped.\n- A `data-initial` flag marks only the very first render, so the entrance flourish is skipped once and the page does not appear to animate itself on load.\n- The foreground silhouette switches instantly and is the deliberate contrast: the observed site crossfades it over `0.4s cubic-bezier(0, .51, .5, 1)`, which this asset leaves to the character stack crossfade asset.\n\n## States\n\n- `idle`: displayed content matches the selection, nothing in flight.\n- `exit`: the current label is fading out; further selections update the target but do not interrupt the exit.\n- Commit: fires exactly once per exit, on the opacity `transitionend`.\n- First render: `data-initial` set, entrance skipped.\n\n## Variants\n\n- Commit signal: `transitionend` (this asset) or `animationend` if the exit is a keyframe animation. A timer is the variant to avoid.\n- Foreground pairing: instant swap (this asset, to expose the timing split) or a crossfade with its own curve.\n- Remount strategy: a key bump (this asset) or explicitly resetting the transition. The key is the cheaper and more reliable of the two in React.\n\n## Code\n\n```tsx\nconst onTransitionEnd = (e: React.TransitionEvent) => {\n if (e.target !== e.currentTarget) return;\n if (e.propertyName !== \"opacity\") return;\n if (stage !== \"exit\") return;\n setDisplayed(selected);\n setRemountKey((n) => n + 1);\n setStage(\"idle\");\n};\n```\n\n## Checks\n\n- Guard on `target === currentTarget`. Child transitions bubble, and an unguarded handler commits the swap the moment any descendant finishes anything.\n- Guard on the property name too, or a simultaneous transform transition fires the commit early.\n- Have a fallback path for the case where the transition never fires, such as a hidden tab or a zero-duration computed style, otherwise the component can wedge in `exit` forever.\n- Let a rapid selection change update the target without cancelling the exit in flight. Interrupting is what produces the half-faded label.\n- Confirm the remount key is on the wrapper that owns the transition, not on a parent that would remount the whole section.\n\n## Anti-patterns\n\n- Committing on a `setTimeout` matched to the CSS duration: the two drift apart the moment anyone edits the stylesheet, and they never drift in the safe direction.\n- Crossfading the label by stacking the old and new copy at once: large display type overlapping itself is unreadable, which is exactly why the source waits for exit.\n- Skipping the initial flag: every visitor sees an entrance for content that was never absent.\n- Giving foreground and background the same timing: the timing split is the whole idea.\n\n## Agent notes\n\n- prompt_phrases: \"swap a background name only after it finishes fading out\", \"transitionend driven state machine for a crossfade\", \"background label and foreground image on different clocks\".\n- fallbacks: with reduced motion, commit immediately without waiting on a transition, since there is no exit to wait for. Keep the same commit function so both paths converge on one place."},{"id":"orbital-image-cloud-landing","name":"Orbital Image Cloud Landing","pattern_group":"marketing","kind":"motion-pattern","status":"draft","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted"],"code_asset":"pending","component_refs":[],"term_refs":[],"source_refs":["https://github.com/Yoodeit/MBN_Hackathon"],"last_verified":"2026-08-09","source_path":"recipes/marketing/orbital-image-cloud-landing.md","body":"## Intent\n\nAn orbital image cloud turns a content catalog into the landing hero itself: dozens of item thumbnails float as a slowly rotating 3D cloud the visitor can spin, and clicking an item focuses it, then navigates. Tier ④ in `knowledge/expressive-stack.md` (WebGL) with a mandatory tier-② CSS fallback — the cloud is browsing, not decoration: every image is a real destination. Harvested as a technique document from MBN_Hackathon (K-RABBIT) `components/landing/OrbitWall.tsx` (a 1,482-line domain-coupled component). The core mechanics now also exist as a working code asset — `orbit-wall` (wrapper) + `orbit-wall-impl` (three.js core, M41 2026-08-10): sphere placement, auto-spin with grab inertia, hover growth, staggered entry. Focus navigation, dominant-color theming, and the CSS fallback remain recipe-level guidance for the consumer.\n\n## Anatomy\n\n- **Fibonacci-sphere placement**: distribute N image planes on a sphere with the golden-angle spiral (`φ = π(3−√5)`, `y = 1−2i/(N−1)`) — even coverage at any N, no clustering at poles.\n- **Idle drift + drag**: constant slow rotation (the cloud must read as alive in a still glance) plus trackball-style drag to spin; wheel moves the camera dolly in/out.\n- **Two-step focus navigation**: first click tweens the camera to face the item and enlarges it with its caption (browse commitment stays low); second click on the focused item navigates. Background click releases focus.\n- **Dominant-color theming**: extract the focused image's dominant tone (downscaled canvas sample) and tint the page background toward its deepened shade — the surface answers the content.\n- **CSS fallback (no WebGL / reduced-motion / low-power)**: the same catalog as layered 2D clusters with wheel-driven dolly (accumulated ref + rAF lerp toward target) and cursor parallax — content parity, softer motion.\n- Lazy-load the WebGL bundle behind interaction/visibility (`lazy-three-object-scene` pattern); the fallback is the default render until then.\n\n## States\n\n- Idle: slow autonomous rotation, all items equal weight.\n- Dragging: user owns rotation; idle drift pauses and resumes after release.\n- Focused: one item enlarged with caption, background tinted from its dominant color, others receded.\n- Reduced motion / no WebGL: static or gently-parallaxed 2D cluster fallback — every item still reachable and clickable.\n\n## Variants\n\n- **자동 공전** — 포인터 입력 없이 천천히 도는 기본형. 히어로 배경.\n- **포인터 반응** — 커서 위치가 궤도 속도·기울기를 민다. 상호작용을 알리는 힌트가 필요할 때만.\n- **정지 프레임(폴백)** — `prefers-reduced-motion` 또는 저성능 기기. 정지 상태에서도 구성이 읽혀야 한다(still appeal 게이트 — [[motion-principles]]).\n\n## Code\n\n```tsx\n// 궤도 배치는 링 반지름과 각도 분배만 정한다 — 이미지 자체는 소비자가 준다.\nconst placeOnRing = (index: number, total: number, radius: number) => {\n const angle = (index / total) * Math.PI * 2\n return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius * 0.38 }\n}\n```\n\n> 구현체는 별도 코드 자산 `orbit-wall`(레지스트리 블록)이 갖는다. 이 recipe 는 **랜딩에서의 사용 규칙**만 소유한다.\n\n## Checks\n\n- Fallback parity: with WebGL disabled, every catalog item is still visible and navigable (the cloud is a browse surface, not eye candy — losing items on fallback is content loss).\n- Still-appeal gate (`knowledge/motion-principles.md`): a paused frame of the cloud must compose — image sizes and spacing hold as a collage without motion.\n- Focus is two-step: a single click never navigates away from the landing.\n- Dominant-color tint meets contrast against foreground text (derive a deepened shade, never the raw sample).\n- three.js stays behind a dynamic import; initial route JS ships without it.\n\n## Anti-patterns\n\n- **Decorative skin**: images that are not real destinations — an orbit of stock photos is the \"3D landing decorative-skin\" failure (`knowledge/motion-references.md`).\n- **Single-click navigation** out of a spinning cloud — misclicks on a moving target.\n- **Raw dominant color as background** — sampled tones are usually mid-luminance and kill text contrast; deepen toward the surface scale first.\n- **No fallback**: a WebGL-only landing renders blank on blocked contexts; the CSS cluster is the floor, not an extra.\n\n## Agent notes\n\n- 궤도 진열은 **쇼케이스용이지 카탈로그용이 아니다.** 읽고 고르는 목록에는 쓰지 않는다(M47 `/blocks` 표현 조사 소견).\n- 회전 중 항목은 초점이 맞지 않아 읽기 어렵다 — 텍스트를 궤도에 태우지 않는다.\n- 키보드 탐색·스크린리더 경로를 별도로 제공하지 않으면 장식으로만 쓰고 `aria-hidden` 을 건다.\n- `status: draft` 이고 `code_asset` 이 아직 없다. `component_refs` 는 recipe id 만 받으므로 비웠다 — orbit-wall 은 recipe 가 아니라 레지스트리 블록이다."},{"id":"paragraph-reveal-modes","name":"Paragraph Reveal Modes","pattern_group":"marketing","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m67","surface_refs":["websites","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"],"code_asset":"examples/ui-vocabulary-site/src/components/paragraph-reveal-modes.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/marketing/paragraph-reveal-modes.md","body":"## Intent\n\n본문을 드러내는 방법은 하나가 아니다. NORMAL IS BORING 은 같은 사이트 안에서 문단 리빌을 **두 메커니즘으로 나눠 쓴다** — 하나는 줄이 움직이며 한 번만 재생되고, 다른 하나는 자리를 지킨 채 스크롤에 물려 밝아진다. 「언제 움직이고 언제 밝기만 바꾸는가」는 정본이 답하지 않던 갈림이다.\n\n## Anatomy\n\n- **위치형** — 줄 단위. `overflow:hidden` 래퍼 안에서 줄이 올라온다. 1회성.\n- **밝기형** — 글자 단위. 자리를 지킨 채 불투명도만 오른다. 스크럽 결박.\n- **분해 래퍼** — 실제 줄바꿈 지점을 측정해 줄을 만든다. 조각은 `aria-hidden`, 원문은 따로.\n- **스크롤 컨테이너** — 트리거의 기준. 페이지 스크롤이 아닌 곳에서는 `scroller` 를 넘긴다.\n\n## States\n\n- **진입 전** — 위치형은 아래에 숨어 있고, 밝기형은 `opacity .3` 으로 이미 읽힌다.\n- **진입 중** — 위치형은 한 번 재생, 밝기형은 스크롤 진행률에 결박.\n- **되감기** — 위치형은 돌아오지 않고 밝기형은 그대로 되감긴다. **두 모드가 갈리는 지점이다.**\n- **reduced-motion** — 분해 자체를 하지 않는다.\n\n## Variants\n\n| | 위치형 | 밝기형 |\n|---|---|---|\n| 분해 단위 | 줄 | 줄+글자 |\n| 움직임 | `y 100% → 0` | 없음(`opacity .3 → 1`) |\n| stagger | `.09` | `.005` (18배) |\n| 트리거 | `top 75%` ~ `bottom 10%`, scrub 없음 | `top 90%` ~ `50% 40%`, `scrub .5` |\n\n## Code\n\n```ts\n// 위치형 — 줄 단위, 1회성\ngsap.from(lineInners, { y: \"100%\", duration: 0.5, stagger: 0.09, ease: \"power3.out\" })\nScrollTrigger.create({ trigger: el, start: \"top 75%\", end: \"bottom 10%\", scroller })\n\n// 밝기형 — 글자 단위, 스크럽 결박\ngsap.fromTo(chars, { opacity: 0.3 }, { opacity: 1, duration: 0.5, stagger: 0.005, ease: \"power3.out\" })\nScrollTrigger.create({ trigger: el, start: \"top 90%\", end: \"50% 40%\", scrub: 0.5, scroller })\n```\n\n## Checks\n\n- 되감았을 때 두 모드가 **다르게** 거동하는지 확인 — 같으면 트리거 모델이 하나로 뭉개진 것이다.\n- stagger 자릿수가 분해 단위를 따라가는지 확인(줄 `.0x`, 글자 `.00x`).\n- 스크린리더가 글자를 하나씩 읽지 않는지 확인(조각 `aria-hidden` + 원문 별도).\n- 밝기형의 시작 불투명도가 다크에서도 읽히는지 확인.\n- 이 컴포넌트가 만든 ScrollTrigger 만 정리되는지 확인(전역 killAll 금지).\n\n## Anti-patterns\n\n- **하나의 stagger 기본값을 모든 분해 단위에 쓰는 것.** 줄과 글자는 자릿수가 다르다.\n- **본문 전체를 위치형으로 거는 것.** 읽으려는 문단이 계속 움직이면 읽기를 방해한다.\n- **분해한 조각을 접근성 트리에 그대로 두는 것.**\n- **페이지 스크롤을 전제하는 것.** 자체 스크롤 컨테이너 안에서는 `scroller` 를 넘겨야 트리거가 성립한다.\n\n## Agent notes\n\n- `prompt_phrases`: \"문단 등장 애니메이션\", \"스크롤에 맞춰 글자 밝아지게\", \"텍스트 리빌\", \"본문이 밋밋해\".\n- **문단이 주인공인지 배경인지** 먼저 묻는다 — 주인공이면 위치형, 배경이면 밝기형.\n- **되돌아왔을 때 다시 보여야 하는가**가 트리거 모델을 정한다.\n- 한 문단 안에서 리빌 종류를 나누는 요청이면 `dual-mode-line-reveal` 이 답이다.\n\n## 관측 (NORMAL IS BORING `/conocenos/` · 2026-08-17)\n\n| | `.anim-line` (위치형) | `.anim-line-op` (밝기형) |\n|---|---|---|\n| 분해 단위 | 줄(`lines`) | 줄+글자(`lines,chars`) |\n| 움직임 | `y 100% → 0` | 없음 — `opacity .3 → 1` |\n| stagger | `.09` | `.005` (**18배 차이**) |\n| duration | `.5` | `.5` |\n| ease | `power3.easeOut` | `power3.easeOut` |\n| 트리거 | `start \"top 75%\"` `end \"bottom 10%\"`, **scrub 없음**(1회성) | `start \"top 90%\"` `end \"50% 40%\"`, **`scrub: .5`** |\n\n되감기 거동이 갈린다 — 위치형은 한 번 재생되면 돌아오지 않고, 밝기형은 스크롤을 되감으면 그대로 되감긴다.\n\n## 규칙\n\n1. **문단이 주인공이면 위치형, 문단이 배경이면 밝기형.** 움직임은 시선을 끌고 밝기는 읽기를 방해하지 않는다.\n2. **stagger 의 자릿수가 단위를 따라간다.** 줄 단위는 `.09` 처럼 세어질 만큼, 글자 단위는 `.005` 처럼 흐를 만큼. 하나의 기본값을 모든 단위에 쓰면 둘 중 하나가 반드시 어색해진다.\n3. **스크럽에 무는 것과 1회성은 되감기에서 갈린다.** 되돌아왔을 때 다시 보여야 하는가를 먼저 정한다.\n4. **글자를 쪼개면 스크린리더가 글자를 하나씩 읽는다.** 쪼갠 조각은 `aria-hidden`, 원문은 컨테이너에 따로 준다.\n5. **`prefers-reduced-motion` 에서는 분해 자체를 하지 않는다.**\n\n## 우리가 정한 값\n\nGSAP SplitText 는 유료 플러그인이라 쓰지 않았다 — 단어를 `inline-block` 으로 깔아 `offsetTop` 으로 실제 줄바꿈을 잡고 그 줄들을 `overflow:hidden` 래퍼로 재조립했다(근사가 아니라 실측 줄바꿈이다). 다만 단어를 `inline-block` 으로 만든 탓에 한글이 어절 중간에서 꺾이는 기본 줄바꿈은 막힌다. 본문 문구는 우리가 썼다(스페인어 원문을 옮기지 않았다). 판정대 카드 안에서 돌도록 자체 스크롤 컨테이너를 두고 ScrollTrigger 의 `scroller` 로 넘겼다.\n\n## term 대응\n\n**빈 배열이다.** 스크롤 트리거 텍스트 리빌에 대응하는 term 이 `terms.yml` 에 없다 — `typing-text-effect` 는 타이핑이라 메커니즘이 다르고, `scroll-fade` 는 스크롤 영역 가장자리 페이드라 대상이 다르다. 억지 태깅 대신 **용어 신설 후보**로 큐에 남긴다(M67 D6).\n\n## 관련\n\n[[dual-mode-line-reveal]](한 문단 안에서 리빌 *종류*를 데이터로 분기 — 이쪽은 *트리거 모델*이 갈린다) · [[char-scroll-reveal]](밝기형 절반과 기법이 겹친다) · `research/m67-batch2-css/p2-ledger.json` 행 `nib-11`\n\n## Changelog\n\n- 2026-08-18 — m67-batch2-css 회차 신설. 사용자 관측 게이트 통과."},{"id":"path-length-timed-drawing","name":"Path Length Timed Drawing","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m66","surface_refs":["websites"],"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/path-length-timed-drawing.tsx","component_refs":[],"term_refs":["highlight-stroke","icon"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/marketing/path-length-timed-drawing.md","body":"## Intent\n\nHand-drawn scribbles that circle a name or underline a phrase read as handwriting only if the pen keeps a roughly constant speed. Give every path the same duration and the illusion collapses: a short underline crawls while a long circle races. The rule observed here maps `getTotalLength()` to duration in two regimes — below a length threshold the duration is linear in length, above it the duration saturates at a constant so very long paths do not take absurdly long. One line of arithmetic is the whole technique. Observed at Spotify Wrapped Party (https://wrapped-party.activetheory.dev) — ledger rows wrapped#6 in `research/m66-round8/p2-ledger.json`, disposition in `research/m66-round8/disposition.json`; user gate pass frozen in `research/2026-08-17-m66-gate-verdicts.md`.\n\n## Anatomy\n\n- **Path geometry** — the SVG path itself, measured at runtime with `getTotalLength()`.\n- **Dash mechanism** — `stroke-dasharray` set to the length and `stroke-dashoffset` animated from the length to zero.\n- **Duration map** — linear below the threshold, constant above it.\n- **Sequence delay** — a small offset before the marks begin, so they land after the text they annotate.\n\n## States\n\n- **Hidden** — dash offset equals path length; nothing is visible.\n- **Drawing** — offset interpolates linearly to zero over the mapped duration.\n- **Drawn** — offset is zero and the mark is complete.\n- **Replay** — offset is reset to the length and the mapping is recomputed, since the path may have been re-laid out.\n\n## Variants\n\n- **Uniform timing (contrast)** — the common approach with one duration for all paths, worth rendering side by side because the difference is instantly legible.\n- **Linear easing** — handwriting reads best with `linear`; easing the stroke makes the pen appear to accelerate mid-mark.\n- **Grouped marks** — several paths sharing one sequence, each still timed by its own length.\n\n## Code\n\n전체 구현은 `code_asset` 이 정본이다. 핵심만 발췌한다 — 이 패턴을 특징짓는 것은 분량이 아니라 아래 규칙 한 줄이다.\n\n```tsx\n// Path Length Timed Drawing — 핵심 규칙\n// 구현 전문: examples/ui-vocabulary-site/src/components/path-length-timed-drawing.tsx\n// 관측 파라미터의 출처: research/m66-round8/p2-ledger.json (wrapped#6)\n```\n\n## Checks\n\n- Two paths of clearly different length receive clearly different durations.\n- Above the threshold, duration stops increasing with length.\n- Replaying recomputes lengths rather than reusing stale values.\n- The stroke uses the current text color rather than a fixed ink color.\n\n## Anti-patterns\n\n- **One duration for every path.** This is the default that the pattern replaces, and it is what makes hand-drawn marks read as generated.\n- **Measuring path length once and caching it across layouts.** Responsive reflow changes the geometry and the pacing goes wrong.\n- **Easing the dash offset.** Constant pen speed is the entire illusion.\n- **Skipping the saturation branch.** Without it a long decorative path can take many seconds and the sequence stalls.\n\n## Agent notes\n\nprompt_phrases: 손그림 낙서가 그려지는 연출 · 선 길이에 비례해서 그리는 시간을 정해줘 · SVG 획을 손으로 그리듯 재생해줘\n\nfallbacks: `getTotalLength()` 가 없는 환경(구형 SVG 엔진)이면 path 마다 길이를 데이터로 들고 있어야 한다. 모션 축소 설정에서는 그리기를 건너뛰고 완성 상태로 둔다."},{"id":"pixelation-reveal","name":"Pixelation Reveal","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["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.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/pixelation-reveal.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/pixelation-reveal.md","body":"## Intent\n\nA pixelation reveal resolves an image from a coarse mosaic to full sharpness in a fixed number of geometric column-count steps, giving the moment of disclosure a decoding texture rather than a plain fade or blur. Observed at revelatio.studio (research/m55-round2/p2-revelatio.json row 16, scripts/pixelated-img-reveal.js L1-191, full read of `regenerateSteps`/`pixelate`); user gate pass frozen in research/2026-08-15-m55-gate-verdicts.md section 2 (round-2 new, pass, listed among the 통과 9).\n\n## Anatomy\n\n- Two canvases: a hidden full-resolution source canvas and a visible display canvas at the same logical size (480x270 in the demo).\n- A tiny intermediate canvas is resized to `cols x rows` each step, drawn from the source with smoothing on, then upscaled onto the display canvas with smoothing off (`imageSmoothingEnabled = false`): that hard nearest-neighbor upscale is what produces the visible mosaic blocks.\n- Column counts follow a geometric ramp: `steps[i] = startCols * ratio^i`, `ratio = (logicalWidth / startCols)^(1/useSteps)`, observed `startCols=12`, `totalSteps=12`, `useSteps = floor(totalSteps * 0.9) = 10`. Steps beyond `useSteps` clamp to full width.\n- Timing: 150ms per step (`STEP_MS`), driven by `requestAnimationFrame` measuring elapsed time against `STEP_MS`, not a fixed interval; a step only fires when the frame's elapsed time crosses into the next step's bucket.\n- Trigger modes observed at source: load / inview / hover / click. This demo reproduces load-on-mount plus a click/button replay only.\n\n## States\n\n- Playing: `startTimeRef` set on first frame, `stepIndexRef` advances through the geometric ramp, canvas repaints only when the step index changes (not every frame).\n- Settled: last step reached (`idx >= steps.length - 1`), rAF loop stops, `playing` flips false.\n- Reduced motion: ramp skipped entirely, image painted at full column count (`LOGICAL_W`) immediately, no animation frames scheduled.\n- Replay: clicking the canvas or the button re-arms `startTimeRef`/`stepIndexRef` and restarts the rAF loop from step 0.\n\n## Variants\n\n- Trigger swap: replace load-trigger with `IntersectionObserver` (inview) or pointer events (hover) around the same `pixelate(cols)` core; the ramp and timing are unchanged.\n- Ramp shape: tune `startCols`/`totalSteps`/`useSteps` for a slower or snappier decode; larger `startCols` reads as a lighter mosaic, smaller reads as blockier.\n- Non-square media: `rows = round(cols * (logicalHeight / logicalWidth))` keeps aspect ratio consistent per step.\n\n## Code\n\n```tsx\nconst pixelate = (cols: number) => {\n const rows = Math.max(1, Math.round(cols * (LOGICAL_H / LOGICAL_W)));\n tiny.width = cols;\n tiny.height = rows;\n tinyCtx.imageSmoothingEnabled = true;\n tinyCtx.drawImage(sourceCanvas, 0, 0, LOGICAL_W, LOGICAL_H, 0, 0, cols, rows);\n\n displayCtx.imageSmoothingEnabled = false;\n displayCtx.clearRect(0, 0, LOGICAL_W, LOGICAL_H);\n displayCtx.drawImage(tiny, 0, 0, cols, rows, 0, 0, LOGICAL_W, LOGICAL_H);\n};\n\nconst ratio = Math.pow(LOGICAL_W / START_COLS, 1 / USE_STEPS);\nconst steps = Array.from({ length: TOTAL_STEPS }, (_, i) =>\n i > USE_STEPS ? LOGICAL_W : Math.round(START_COLS * Math.pow(ratio, i))\n);\n```\n\n## Checks\n\n- `prefers-reduced-motion: reduce` bypasses the ramp and paints the final sharp frame directly; no rAF loop scheduled.\n- `cancelAnimationFrame` on unmount clears any in-flight loop.\n- Replay re-checks reduced-motion at click time (not just on mount) and takes the same static path if active.\n- Source-image pixels are drawn from resolved CSS tokens (`--muted`, `--background`, `--foreground`, `--border`) via a 1x1-canvas readback, not hardcoded RGB.\n- Step repaint is gated on step-index change, not every animation frame; avoids redundant canvas draws.\n\n## Anti-patterns\n\n- Using CSS `image-rendering: pixelated` with a CSS transition on scale/opacity instead of the two-canvas draw: it fades or scales the whole bitmap rather than producing distinct decoding blocks.\n- Fixed-step (linear) column counts instead of the geometric ramp: linear steps make the mosaic feel evenly paced instead of front-loaded and slowing into resolution, which is the observed texture.\n- Leaving `imageSmoothingEnabled` true on the final upscale: bilinear-smooths the mosaic into a blur, losing the hard-edged pixel-block read that defines the pattern.\n- Skipping the reduced-motion branch: forces a mandatory multi-step animation on every load with no static equivalent.\n\n## Agent notes\n\n- prompt_phrases: \"image resolves from pixel mosaic to sharp\", \"pixelation decode reveal\", \"canvas upscale reveal with hard edges\".\n- fallbacks: if canvas 2D context is unavailable, skip the effect and render the source image directly; the mosaic is a decoration on top of an already-loadable asset, not a gate on content availability."},{"id":"procedural-court-line","name":"Procedural Court Line","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.action.primary-hover","color.semantic.border.default","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/procedural-court-line.tsx","component_refs":[],"term_refs":["divider","hero"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/marketing/procedural-court-line.md","body":"## Intent\n\nA court-line surface where the line itself is generated. Segment SDFs are perturbed by multi-octave noise applied to the distance value, then erased where a low-frequency zone threshold is crossed, and a neon mode swaps the floor while keeping the lines legible.\n\nObserved at https://members-play.lacoste.com/ace-breaker-rg/ (LAC-11, accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Twelve court segments are unioned as a distance field; the layout is ours, only the SDF-line technique was observed.\n- Weathering adds two noise octaves to the SDF distance itself (not to the color), so the boundary frays instead of blurring. Amplitude stays below the line half-width, or the line dissolves entirely.\n- Erosion compares a high-frequency sample against a threshold that a low-frequency zone noise moves between .3 and .7, so bald patches cluster into dirt zones rather than scattering evenly.\n- Neon mode replaces the floor with a single token color faded 30% at the borders and re-lays the same line mask on top, so the mode swap keeps the court readable.\n\n## Pitfalls\n\n- The observed constant smoothstep(.94, 1.) belongs to a texture-sampled SDF. Applied to a proximity ratio it paints only 6% of the line width and the court disappears into speckles. Read .94 as the soft-edge fraction instead.\n- Compositing the neon floor over the finished court color erases the lines. Swap the floor, then re-apply the line mask.\n\n## States\n\n- 기본 — 풍화·침식이 중간값으로 걸린 코트.\n- 네온 모드 — 바닥이 단색으로 전환되고 선은 그대로 남는다.\n- 풍화 0 — 깨끗한 벡터 선(기법이 꺼진 상태의 대조군).\n\n## Variants\n\n- 풍화·침식 2축 슬라이더로 낡음의 정도를 고른다.\n- 네온 모드 on/off 로 바닥 스왑을 대조한다.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/procedural-court-line.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 노이즈 진폭을 선 폭보다 크게 잡아 선이 통째로 사라지게 두는 것.\n- 네온 전환을 최종 색 위에 덮어 선을 지우는 것.\n\n## Agent notes\n\n관측 상수를 옮길 때는 그 값이 놓이는 좌표계가 같은지 함께 본다. 원 사이트는 텍스처에서 샘플링한 SDF 라 같은 상수를 근접도에 그대로 걸면 선이 사라진다.\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--foreground`\n- `--muted`\n- `--muted-foreground`\n- `--primary-hover`\n- `--radius`","code_source":"// 출처: research/m68/p2-lacoste-ace.json LAC-11 (\"SDF 코트 라인 + 노이즈 풍화 + 네온 모드 스왑 지면 셰이더\")\n// Lacoste Ace Breaker https://members-play.lacoste.com/ace-breaker-rg/ (접근일 2026-08-17)\n// 핵심 파라미터(관측값 그대로): edgeNoise 배율 .012 · dirtThreshold = mix(.3,.7,zoneNoise)\n// · sharpLine = smoothstep(.94,1.) · borderFade = fadeX*fadeY(가장자리 30% 페이드)\n// · useNeonMode 0..1 스칼라 uniform\n// visual: 코트 바닥 SDF 라인을 노이즈로 도메인 왜곡해 손으로 그은 자국처럼 흐트러뜨리고,\n// 흙 존 노이즈가 임계값을 넘는 자리에서 선을 침식시키며, 네온 모드에서는 바닥 전체가\n// 단색 네온 색 + 가장자리 30% 페이드 알파로 전환된다.\n//\n// 원 사이트는 선 SDF·높이맵을 텍스처 에셋으로 로드하지만, 에셋은 이식하지 않는다.\n// 여기서는 코트 라인을 선분 SDF 로, 흙 존/침식 노이즈를 셰이더 안 value-noise(fbm) 로\n// 절차 생성한다.\n//\n// 우리가 정한 값(관측값 아님 — 명시한다):\n// - 코트 선분 배치(외곽선·싱글스 사이드라인·네트·서비스라인·센터라인·베이스라인 틱)와\n// 그 좌표: 관측된 것은 \"SDF 코트 라인\"이라는 기법뿐이라 배치 자체는 우리가 그렸다.\n// - 라인 두께(LINE_HALF_WIDTH), 존/침식 노이즈 주파수, 노이즈 옥타브 가중치.\n// - 색은 원 사이트 팔레트를 가져오지 않고 런타임에 CSS 토큰(--foreground/--muted/\n// --primary-hover)을 읽는다.\nimport { useEffect, useRef, useState } from \"react\"\n\nconst VERT = `#version 300 es\nin vec2 aPos;\nvoid main() { gl_Position = vec4(aPos, 0.0, 1.0); }\n`\n\nconst FRAG = `#version 300 es\nprecision highp float;\nout vec4 fragColor;\n\nuniform vec2 uResolution;\nuniform float uTime;\nuniform float uEdgeNoise; // 0..1 — 풍화(가장자리 흐트러짐) 세기\nuniform float uDirtStrength; // 0..1 — 침식(흙 존이 선을 지우는) 세기\nuniform float uNeonMode; // 0..1 — 네온 모드 스왑(이징된 값)\nuniform vec3 uLineColor; // --foreground\nuniform vec3 uFloorColor; // --muted\nuniform vec3 uNeonColor; // --primary-hover\n\n// ---- 관측 상수 (p2 장부 원문 그대로) ----\n#define EDGE_NOISE_BASE 0.012\n#define DIRT_LOW 0.3\n#define DIRT_HIGH 0.7\n#define SHARP_LO 0.94\n#define SHARP_HI 1.0\n#define BORDER_FADE_WIDTH 0.3\n\n// ---- 우리가 정한 상수 (관측값 아님) ----\n#define LINE_HALF_WIDTH 0.012\n#define COURT_HALF_X 1.0\n#define COURT_HALF_Y 0.42\n#define SIDELINE_X 0.86\n#define SERVICE_Y 0.21\n#define VIEW_SCALE 1.15\n#define ZONE_FREQ 1.6\n#define EROSION_FREQ 5.0\n\nvec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }\nfloat snoise(vec2 v) {\n const vec4 C = vec4(0.211324865405187, 0.366025403784439,\n -0.577350269189626, 0.024390243902439);\n vec2 i = floor(v + dot(v, C.yy));\n vec2 x0 = v - i + dot(i, C.xx);\n vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);\n vec4 x12 = x0.xyxy + C.xxzz;\n x12.xy -= i1;\n i = mod(i, 289.0);\n vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));\n vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);\n m = m * m; m = m * m;\n vec3 x = 2.0 * fract(p * C.www) - 1.0;\n vec3 h = abs(x) - 0.5;\n vec3 ox = floor(x + 0.5);\n vec3 a0 = x - ox;\n m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);\n vec3 g;\n g.x = a0.x * x0.x + h.x * x0.y;\n g.yz = a0.yz * x12.xz + h.yz * x12.yw;\n return 130.0 * dot(m, g);\n}\n\nfloat fbm(vec2 p) {\n float sum = 0.0;\n float amp = 0.5;\n for (int i = 0; i < 3; i++) {\n sum += snoise(p) * amp;\n p *= 2.02;\n amp *= 0.5;\n }\n return sum;\n}\n\nfloat sdSegment(vec2 p, vec2 a, vec2 b) {\n vec2 pa = p - a, ba = b - a;\n float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);\n return length(pa - ba * h);\n}\n\n// 코트 선분 12개(외곽선 4 · 싱글스 사이드라인 2 · 네트 1 · 서비스라인 2 · 센터라인 1 ·\n// 베이스라인 틱 2). 배치는 관측되지 않았다 — 우리가 그렸다.\nconst vec4 SEGS[12] = vec4[12](\n vec4(-COURT_HALF_X, COURT_HALF_Y, COURT_HALF_X, COURT_HALF_Y),\n vec4(-COURT_HALF_X, -COURT_HALF_Y, COURT_HALF_X, -COURT_HALF_Y),\n vec4(-COURT_HALF_X, -COURT_HALF_Y, -COURT_HALF_X, COURT_HALF_Y),\n vec4( COURT_HALF_X, -COURT_HALF_Y, COURT_HALF_X, COURT_HALF_Y),\n vec4(-SIDELINE_X, -COURT_HALF_Y, -SIDELINE_X, COURT_HALF_Y),\n vec4( SIDELINE_X, -COURT_HALF_Y, SIDELINE_X, COURT_HALF_Y),\n vec4(-COURT_HALF_X, 0.0, COURT_HALF_X, 0.0),\n vec4(-SIDELINE_X, SERVICE_Y, SIDELINE_X, SERVICE_Y),\n vec4(-SIDELINE_X, -SERVICE_Y, SIDELINE_X, -SERVICE_Y),\n vec4( 0.0, -SERVICE_Y, 0.0, SERVICE_Y),\n vec4( 0.0, COURT_HALF_Y, 0.0, COURT_HALF_Y - 0.05),\n vec4( 0.0, -COURT_HALF_Y, 0.0, -COURT_HALF_Y + 0.05)\n);\n\nfloat courtSDF(vec2 p) {\n float d = 1e9;\n for (int i = 0; i < 12; i++) {\n vec4 s = SEGS[i];\n d = min(d, sdSegment(p, s.xy, s.zw));\n }\n return d;\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / uResolution;\n float aspect = uResolution.x / uResolution.y;\n vec2 p = (uv - 0.5) * vec2(aspect, 1.0) * VIEW_SCALE;\n\n // ---- 풍화: 라인 SDF 값 자체에 여러 옥타브 노이즈를 더해 가장자리를 흐트러뜨린다.\n // edgeNoise 배율 .012 를 그대로 계수로 쓰고, 옥타브 가중치(3.0/1.0)와 주파수(40/90)는\n // 우리가 정했다. uEdgeNoise 슬라이더가 0 이면 노이즈 항이 0 이 되어 깨끗한 벡터 선.\n float d = courtSDF(p);\n // 옥타브 가중치는 선 폭 대비로 잡는다 — 원래 3.0/1.0 은 진폭이 LINE_HALF_WIDTH 의 4배라\n // 노이즈가 선을 흐트러뜨리는 것이 아니라 통째로 지웠다(같은 날 실관측 수정).\n float weathering =\n snoise(p * 40.0) * EDGE_NOISE_BASE * 0.6 +\n snoise(p * 90.0 + 3.7) * EDGE_NOISE_BASE * 0.25;\n d += weathering * uEdgeNoise;\n\n // sharpLine = smoothstep(.94, 1.) — 라인 중심까지의 근접도를 이 좁은 밴드로 잘라\n // 얇고 또렷한 코어를 만든다.\n // ⚠ 관측 상수 .94 의 적용 지점을 고쳤다(2026-08-17 실관측 수정). smoothstep(.94, 1.) 를\n // proximity 에 그대로 걸면 |d| < 0.06*LINE_HALF_WIDTH 만 칠해져 선이 서브픽셀로 사라지고,\n // 화면에는 점선 자국만 남는다(실측: 기본값에서 선이 안 보였다). 원 사이트는 텍스처에서\n // 샘플링한 SDF 라 값의 범위가 달랐던 것으로 보인다. 여기서는 .94 를 **가장자리 부드러움\n // 비율**로 읽어(1-.94 = 6% 폭의 소프트 엣지) 선 폭 전체가 칠해지게 한다.\n float proximity = 1.0 - clamp(abs(d) / LINE_HALF_WIDTH, 0.0, 1.0);\n float lineMask = smoothstep(0.0, SHARP_HI - SHARP_LO, proximity);\n\n // ---- 흙 존 침식: zoneNoise 가 임계값(mix(.3,.7,zoneNoise))을 만들고, 그보다 촘촘한\n // erosion 노이즈가 그 임계값을 넘는 자리에서 라인을 지운다.\n float zoneNoise = fbm(p * ZONE_FREQ + 10.0) * 0.5 + 0.5;\n float dirtThreshold = mix(DIRT_LOW, DIRT_HIGH, zoneNoise);\n float erosionSample = fbm(p * EROSION_FREQ - 5.0) * 0.5 + 0.5;\n float erosion = smoothstep(dirtThreshold - 0.08, dirtThreshold + 0.08, erosionSample) * uDirtStrength;\n lineMask *= (1.0 - erosion);\n\n vec3 courtColor = mix(uFloorColor, uLineColor, lineMask);\n // 침식이 심한 자리는 바닥도 살짝 어둡게 가라앉힌다(흙 존을 눈에 보이게).\n courtColor = mix(courtColor, courtColor * 0.9, smoothstep(dirtThreshold, dirtThreshold + 0.15, erosionSample) * 0.4);\n\n // ---- 네온 모드: borderFade = fadeX * fadeY, 가장자리 30% 폭에서 페이드.\n float fadeX = smoothstep(0.0, BORDER_FADE_WIDTH, uv.x) * smoothstep(0.0, BORDER_FADE_WIDTH, 1.0 - uv.x);\n float fadeY = smoothstep(0.0, BORDER_FADE_WIDTH, uv.y) * smoothstep(0.0, BORDER_FADE_WIDTH, 1.0 - uv.y);\n float borderFade = fadeX * fadeY;\n float pulse = 0.92 + 0.08 * sin(uTime * 2.0);\n // ⚠ 수리(m68-step8): 이전에는 네온 바닥이 courtColor 를 통째로 덮어 **선이 사라졌다**.\n // 관측 원문(LAC-11)은 「바닥이 단색 네온 + 가장자리 30% 페이드로 전환」이지 선이 없어진다고\n // 하지 않았다 — 코트 라인은 네온 바닥 위에 그대로 남아야 모드 전환의 뜻이 읽힌다.\n // 바닥만 네온으로 갈아끼우고 그 위에 같은 lineMask 로 선을 다시 얹는다.\n vec3 neonFloor = mix(uFloorColor, uNeonColor * pulse, borderFade);\n vec3 neonLine = mix(uLineColor, uFloorColor, 0.65 * borderFade); // 네온 위에서는 선을 밝게 띄운다\n vec3 neonColor = mix(neonFloor, neonLine, lineMask);\n\n vec3 final = mix(courtColor, neonColor, uNeonMode);\n fragColor = vec4(clamp(final, 0.0, 1.0), 1.0);\n}\n`\n\nfunction compileShader(gl: WebGL2RenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n console.error(\"[procedural-court-line] shader compile failed:\", gl.getShaderInfoLog(shader))\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\nfunction createProgram(gl: WebGL2RenderingContext, vertexSrc: string, fragmentSrc: string) {\n const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc)\n const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc)\n if (!vs || !fs) return null\n const program = gl.createProgram()\n if (!program) return null\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n console.error(\"[procedural-court-line] program link failed:\", gl.getProgramInfoLog(program))\n gl.deleteProgram(program)\n return null\n }\n return program\n}\n\n/** 토큰 색을 RGB 로 — 원 사이트 팔레트를 자산에 싣지 않는다. */\nfunction resolveTokenRgb(el: HTMLElement, varName: string, fallback: [number, number, number]) {\n const raw = getComputedStyle(el).getPropertyValue(varName).trim()\n if (!raw) return fallback\n const probe = document.createElement(\"canvas\")\n probe.width = probe.height = 1\n const ctx = probe.getContext(\"2d\")\n if (!ctx) return fallback\n ctx.fillStyle = raw\n ctx.fillRect(0, 0, 1, 1)\n const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data\n return [r / 255, g / 255, b / 255] as [number, number, number]\n}\n\nexport default function ProceduralCourtLine() {\n const rootRef = useRef<HTMLDivElement>(null)\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const [failed, setFailed] = useState(false)\n const [edgeNoise, setEdgeNoise] = useState(0.45)\n const [dirtStrength, setDirtStrength] = useState(0.35)\n const [neonOn, setNeonOn] = useState(false)\n\n const edgeNoiseRef = useRef(edgeNoise)\n const dirtStrengthRef = useRef(dirtStrength)\n const neonTargetRef = useRef(0)\n edgeNoiseRef.current = edgeNoise\n dirtStrengthRef.current = dirtStrength\n neonTargetRef.current = neonOn ? 1 : 0\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n\n const gl = canvas.getContext(\"webgl2\", { alpha: false, antialias: false })\n if (!gl) {\n setFailed(true)\n return\n }\n const program = createProgram(gl, VERT, FRAG)\n if (!program) {\n setFailed(true)\n return\n }\n\n const vao = gl.createVertexArray()\n gl.bindVertexArray(vao)\n const buffer = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)\n const posLoc = gl.getAttribLocation(program, \"aPos\")\n if (posLoc >= 0) {\n gl.enableVertexAttribArray(posLoc)\n gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)\n }\n\n const u = {\n uResolution: gl.getUniformLocation(program, \"uResolution\"),\n uTime: gl.getUniformLocation(program, \"uTime\"),\n uEdgeNoise: gl.getUniformLocation(program, \"uEdgeNoise\"),\n uDirtStrength: gl.getUniformLocation(program, \"uDirtStrength\"),\n uNeonMode: gl.getUniformLocation(program, \"uNeonMode\"),\n uLineColor: gl.getUniformLocation(program, \"uLineColor\"),\n uFloorColor: gl.getUniformLocation(program, \"uFloorColor\"),\n uNeonColor: gl.getUniformLocation(program, \"uNeonColor\"),\n }\n\n let lineColor: [number, number, number] = [0.1, 0.1, 0.12]\n let floorColor: [number, number, number] = [0.9, 0.9, 0.91]\n let neonColor: [number, number, number] = [0.65, 0.39, 0.8]\n const readTokens = () => {\n lineColor = resolveTokenRgb(root, \"--foreground\", [0.1, 0.1, 0.12])\n floorColor = resolveTokenRgb(root, \"--muted\", [0.9, 0.9, 0.91])\n neonColor = resolveTokenRgb(root, \"--primary-hover\", [0.65, 0.39, 0.8])\n }\n readTokens()\n const themeObserver = new MutationObserver(readTokens)\n themeObserver.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const schemeQuery = window.matchMedia(\"(prefers-color-scheme: dark)\")\n schemeQuery.addEventListener(\"change\", readTokens)\n\n const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n\n let width = 1\n let height = 1\n const resize = () => {\n const rect = canvas.getBoundingClientRect()\n width = Math.max(1, Math.round(rect.width * dpr))\n height = Math.max(1, Math.round(rect.height * dpr))\n canvas.width = width\n canvas.height = height\n gl.viewport(0, 0, width, height)\n }\n resize()\n const ro = new ResizeObserver(resize)\n ro.observe(canvas)\n\n const start = performance.now()\n let raf = 0\n let alive = true\n let curNeon = 0\n\n const loop = () => {\n if (!alive) return\n const now = performance.now()\n const t = (now - start) / 1000\n\n // 네온 모드 스왑을 즉시 전환하지 않고 이징한다 — \"모드 스왑\"의 스왑감을 살린다.\n curNeon += (neonTargetRef.current - curNeon) * (reduced ? 1 : 0.08)\n\n gl.useProgram(program)\n gl.bindVertexArray(vao)\n gl.uniform2f(u.uResolution, width, height)\n gl.uniform1f(u.uTime, reduced ? 0 : t)\n gl.uniform1f(u.uEdgeNoise, edgeNoiseRef.current)\n gl.uniform1f(u.uDirtStrength, dirtStrengthRef.current)\n gl.uniform1f(u.uNeonMode, curNeon)\n gl.uniform3f(u.uLineColor, lineColor[0], lineColor[1], lineColor[2])\n gl.uniform3f(u.uFloorColor, floorColor[0], floorColor[1], floorColor[2])\n gl.uniform3f(u.uNeonColor, neonColor[0], neonColor[1], neonColor[2])\n gl.drawArrays(gl.TRIANGLES, 0, 3)\n\n raf = window.requestAnimationFrame(loop)\n }\n loop()\n\n return () => {\n alive = false\n window.cancelAnimationFrame(raf)\n ro.disconnect()\n themeObserver.disconnect()\n schemeQuery.removeEventListener(\"change\", readTokens)\n gl.deleteProgram(program)\n gl.deleteBuffer(buffer)\n gl.deleteVertexArray(vao)\n // WEBGL_lose_context 로 컨텍스트를 죽이지 않는다 — StrictMode 두 번째 실행이\n // 죽은 컨텍스트를 받아 셰이더가 전건 컴파일 실패한다(voronoi-cell-field 실측 함정).\n }\n }, [])\n\n if (failed) {\n return (\n <div className=\"grid h-64 w-full max-w-3xl place-items-center rounded-lg border border-border bg-muted/30 p-6 text-center text-sm text-muted-foreground\">\n 이 브라우저에서 WebGL2 컨텍스트를 만들 수 없어 코트 라인을 표시하지 않습니다.\n </div>\n )\n }\n\n return (\n <div\n ref={rootRef}\n data-testid=\"procedural-court-line\"\n data-edge-noise={edgeNoise.toFixed(2)}\n data-dirt-strength={dirtStrength.toFixed(2)}\n data-neon-mode={neonOn}\n className=\"flex w-full max-w-3xl flex-col gap-3\"\n >\n <div className=\"relative aspect-[16/9] w-full overflow-hidden rounded-lg border border-border bg-muted\">\n <canvas ref={canvasRef} className=\"absolute inset-0 h-full w-full\" />\n </div>\n <div className=\"flex flex-col gap-2 rounded-md border border-border bg-background p-3 text-sm\">\n <label className=\"flex items-center gap-3\">\n <span className=\"w-24 shrink-0 text-muted-foreground\">풍화</span>\n <input\n type=\"range\"\n min={0}\n max={1}\n step={0.01}\n value={edgeNoise}\n onChange={(e) => setEdgeNoise(Number(e.target.value))}\n className=\"w-full accent-foreground\"\n />\n </label>\n <label className=\"flex items-center gap-3\">\n <span className=\"w-24 shrink-0 text-muted-foreground\">침식</span>\n <input\n type=\"range\"\n min={0}\n max={1}\n step={0.01}\n value={dirtStrength}\n onChange={(e) => setDirtStrength(Number(e.target.value))}\n className=\"w-full accent-foreground\"\n />\n </label>\n <button\n type=\"button\"\n onClick={() => setNeonOn((v) => !v)}\n className=\"w-fit rounded-md border border-border bg-muted px-3 py-1.5 text-xs font-medium text-foreground hover:bg-muted/70\"\n >\n 네온 모드 {neonOn ? \"끄기\" : \"켜기\"}\n </button>\n </div>\n <p className=\"break-keep text-sm text-muted-foreground\">\n 코트 라인은 손으로 그린 이미지가 아니라 선분까지의 거리 함수(SDF)입니다. 풍화\n 슬라이더는 그 거리값에 여러 옥타브 노이즈를 더해 깨끗한 벡터 선을 손으로 그은 자국처럼\n 흐트러뜨리고, 침식 슬라이더는 저주파 흙 존 노이즈가 임계값을 넘는 자리에서 선 자체를\n 지웁니다. 네온 모드는 같은 바닥을 단색 네온 색 + 가장자리 페이드로 바꿔 보여줍니다.\n </p>\n </div>\n )\n}\n"},{"id":"scene-sound-map","name":"Scene Sound Map","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites"],"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/scene-sound-map.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/scene-sound-map.md","body":"## Intent\n\nA scene sound map treats audio as a named stage of the experience rather than a bag of effects. Every cue is keyed to the moment it belongs to (ambient bed, intro to overview transition, cards entering, card hover, detail open, drag), each key carries a fixed volume, and the volumes form a ladder in which the thing the visitor just did is loudest and the background is quietest. That single decision is what stops a sound-enabled site from sounding like a soundboard. Observed at inspiring.nk.studio (research/m58-round4/p2-nk-studio.json rows[2], id r4-03), read directly out of the `0s~ip.mla.pj5.js` chunk (module 71573): the `SOUNDS` map, the per-cue `*_SOUND_OPTIONS` constants, and the `EXPERIENCE_SOUND_PRELOAD` array. The original audio files are not copied (harvest contract section 6 forbids asset duplication), so the demo reapplies the same map and the same volume ladder to Web Audio oscillators, which lets you hear what the numbers actually do.\n\n## Anatomy\n\n- Seven keys, exactly as observed: `ambient`, `introOverviewTransition`, `overviewCardsIntro`, `cardsHover1`, `cardsHover2`, `historyOpen`, `overviewDrag`.\n- The source filenames are numbered `0-`, `1-`, `2-`, `3-1`, `3-2`, `4-`, `5-`, so the map is ordered by stage of the sequence, not alphabetically or by file type. The demo keeps that number as a visible `step` column.\n- Volume ladder, observed values: `historyOpen 0.7` > `ambient` and `introOverviewTransition 0.5` > `overviewDrag 0.4` > `overviewCardsIntro`, `cardsHover1`, `cardsHover2` at `0.1`. Each row renders its volume as a bar so the ladder is visible as well as audible.\n- Looping is a property of the cue, not of the player: `ambient` and `overviewDrag` loop, the other five are one-shots.\n- `CARD_HOVER_SOUNDS = [cardsHover1, cardsHover2]` is an array of two, alternated by an index counter, so a visitor sweeping across a card grid never hears the same file twice in a row.\n- `EXPERIENCE_SOUND_PRELOAD` lists all seven, preloaded together, so the first hover is not the moment a file starts downloading.\n- The audio graph is created lazily on the first user gesture (the original `unlock()`), and every play call passes through an enabled gate that is persisted in `localStorage`.\n- Loops fade rather than cut: start ramps to target with `setTargetAtTime(volume, now, 0.12)`, stop ramps to zero with `setTargetAtTime(0, now, 0.08)` and stops the oscillator `400ms` later. One-shots ramp in over `12ms` and decay exponentially to `0.0001` over the cue duration.\n\n## States\n\n- Muted: the default until the visitor opts in, or whatever the persisted preference says. No context is created and nothing plays.\n- Unlocked: the first gesture creates the `AudioContext`, resumes it if suspended, and marks the preload count.\n- One-shot playing: a transient cue rises and decays, and the last played step is reported.\n- Loop running: `ambient` or `overviewDrag` holds until explicitly stopped, and the control for that row flips to a stop affordance.\n- Gesture ended: pointer up, pointer leave while dragging, or key up all stop the drag loop, so the loop cannot outlive the gesture that owns it.\n- Disabled mid-session: toggling sound off stops every running loop and clears the drag state, rather than only silencing future cues.\n\n## Variants\n\n- Alternation depth: two hover variants is the observed value and is enough to break the repetition. More variants read as a richer texture and cost more to preload.\n- Ladder shape: the gap between `0.7` and `0.1` is what encodes \"this is a decision\" versus \"this is atmosphere\". Flattening the ladder makes every cue feel equally important, which is the same as none of them being important.\n- Loop membership: a drag loop tied to a held gesture reads as friction feedback. Making it a one-shot on drag start reads as a click instead.\n- Ambient bed: the ambient loop can be omitted entirely on surfaces where the visitor is reading, while keeping the interaction cues.\n\n## Code\n\n```tsx\n// 사운드 맵은 효과음 목록이 아니라 연출 단계 번호로 정렬된다\nconst SCENE_SOUNDS = {\n ambient: { step: \"0\", volume: 0.5, loop: true },\n introOverviewTransition: { step: \"1\", volume: 0.5, loop: false },\n overviewCardsIntro: { step: \"2\", volume: 0.1, loop: false },\n cardsHover1: { step: \"3-1\", volume: 0.1, loop: false },\n cardsHover2: { step: \"3-2\", volume: 0.1, loop: false },\n historyOpen: { step: \"4\", volume: 0.7, loop: false },\n overviewDrag: { step: \"5\", volume: 0.4, loop: true },\n}\n\n// 호버는 2종 배열을 교대로 — 같은 소리가 연발되지 않는다\nconst CARD_HOVER_SOUNDS = [\"cardsHover1\", \"cardsHover2\"]\nconst playHover = () => {\n const key = CARD_HOVER_SOUNDS[hoverIndexRef.current % CARD_HOVER_SOUNDS.length]\n hoverIndexRef.current += 1\n play(key)\n}\n\n// 루프는 끊지 않고 재운다\ngain.gain.setTargetAtTime(0, ctx.currentTime, 0.08)\nwindow.setTimeout(() => osc.stop(), 400)\n```\n\n## Checks\n\n- Create the `AudioContext` inside a real user gesture and resume it when `state === \"suspended\"`. Building it on mount gets the context suspended on every major browser and the first cue is silently dropped.\n- Gate every play call on the enabled flag, not just the entry point. A single ungated path (usually the ambient bed) is how a muted site starts humming.\n- Stop the drag loop on pointer up, pointer leave, and key up. Missing the leave case leaves a loop droning after the pointer has left the element.\n- Turning sound off must stop running loops, not only suppress future cues.\n- Stop every oscillator and close the context on unmount. A route change that leaves the ambient loop alive is the worst version of this bug because nothing on screen explains it.\n- Preload all cues together. A hover sound that loads on first hover arrives after the hover has ended and reads as a random noise.\n- Persist the preference. Asking the visitor to mute the site on every page view is worse than never offering sound.\n\n## Anti-patterns\n\n- Naming cues after what they sound like (`click`, `whoosh`, `beep`) instead of the stage they belong to. The stage naming is what keeps the volume ladder honest as the site grows.\n- Giving every cue the same volume. The ladder is the pattern, and a flat map is a soundboard.\n- A single hover sound. Repeated on a card grid it turns into a machine-gun effect within three cards, which is the exact failure the two-item array exists to prevent.\n- Making the ambient bed the loudest layer. It is background by definition, and the observed map puts the deliberate action (`historyOpen 0.7`) above it.\n- Hard-stopping a loop with `osc.stop()` and no ramp. The discontinuity is an audible click.\n- Defaulting to sound on without a persistent, always visible mute control.\n\n## Agent notes\n\n- prompt_phrases: \"sound design for an interactive landing page\", \"add audio cues per interaction stage\", \"hover and drag sounds that do not get annoying\".\n- fallbacks: when the Web Audio API is unavailable, render the map and controls but keep them inert rather than throwing. Under reduced motion the audio layer is unaffected, but pair the mute control with the same preference surface so the visitor finds it in one place.\n- porting note: the seven keys, the step numbers, the volume ladder, the loop flags, and the two-item hover array are observed values. The oscillator frequencies and durations in the demo are synthesis substitutes for the original audio files and carry no observational weight."},{"id":"scramble-text-reveal","name":"Scramble Text Reveal","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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","typography.font.mono"],"code_asset":"examples/ui-vocabulary-site/src/components/scramble-text-reveal.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/scramble-text-reveal.md","body":"## Intent\n\nA scramble text reveal resolves a string out of random symbols, character by character, left to right. The reusable part is not the effect but the engine: one implementation is driven by three different triggers (page load, hover, scroll entry) with only the duration, stagger, and speed differing per trigger. That is what turns a decorative effect into a brand signature, because every text entry on the site shares one motion vocabulary instead of three unrelated ones. Observed on Revelatio Studio as a site-wide utility keyed off a `data-scramble` attribute with four trigger values (research/m56-round3/revelatio-deep.json d3[0], converging with d3[19], `scripts/scramble-text.js` L1-167). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- `runScramble()` is the whole engine: it takes the target string, a duration, a per-character stagger, and a speed, and returns a cleanup function.\n- Each character index settles at `i * staggerMs + durationMs`. Before its settle time it renders a random pick from `SCRAMBLE_CHARS = \"^$%#@!&*?+=\"`; after it, the real character.\n- Spaces are never scrambled, so word boundaries stay readable through the whole run and the string keeps its shape.\n- The observed original used the GSAP ScrambleTextPlugin, whose `speed` is a substitution frequency. Here `speedToTickMs()` converts it to an interval floor of `max(16, round(40 / speed))` so the hand-rolled loop keeps the same character churn without the plugin.\n- Trigger parameter sets are named: load (`duration 1.2`, `stagger 0.01`, `speed 0.85`, `baseDelay 0.2`), scroll (`1.4`, `0.015`, `0.95`), hover (reusing the load set, since no hover row was observed and inventing values would break the capture discipline).\n- The scroll trigger uses an `IntersectionObserver` at `threshold: 0.5` with a `playedRef` latch and disconnects itself after firing once.\n- Rendering is monospace on purpose. Substituted glyphs have to occupy the same advance width as the final ones, or the line reflows on every tick.\n\n## States\n\n- `idle`: the block holds its final text and has not played.\n- `scrambling`: characters churn, `data-state=\"scrambling\"` is exposed for tests.\n- `done`: the target string is set exactly once at the end, not left to the last random frame.\n- Reduced motion: `runScramble` writes the target and calls `onDone` immediately, so the text is simply present.\n\n## Variants\n\n- Trigger set: load, hover, scroll here; the observed original also had per-page load variants. Adding a trigger means adding a parameter set, not a second engine.\n- Character pool: the symbol pool reads technical. A pool of letters reads like decryption, a pool of the target's own characters reads like a shuffle.\n- Settle order: left to right (this demo) or randomized settle order, which reads noisier and hides a long string's length.\n\n## Code\n\n```tsx\nconst render = () => {\n const now = performance.now();\n const out = chars.map((ch, i) => {\n if (ch === \" \") return \" \";\n const settleAt = i * staggerMs + durationMs;\n if (now - startedAt >= settleAt) return ch;\n return randomChar();\n });\n onFrame(out.join(\"\"));\n};\n```\n\n## Checks\n\n- Always write the exact target string in the completion callback. Ending on a random frame leaves one wrong character on screen roughly as often as the tick rate allows.\n- Clear both the interval and the completion timeout in the cleanup function, and call cleanup before starting a new run so a re-trigger cannot leave two loops writing the same node.\n- Total time is `(length - 1) * stagger + duration`. A long headline with a generous stagger runs far longer than the duration alone suggests, so check the real total before using this on anything a visitor is waiting to read.\n- Keep the rendered text in a monospace face, or accept that proportional glyph widths will make the line jitter.\n- The scroll trigger's latch must survive re-renders (a ref, not state), or fast scrolling replays the reveal.\n\n## Anti-patterns\n\n- Writing one scramble implementation per trigger: the parameters diverge, and the site ends up with three slightly different signatures instead of one.\n- Scrambling long body copy: this is an entry accent for short strings. On a paragraph it becomes an accessibility problem and a reading tax.\n- Inventing parameter values for a trigger that was never observed. Reuse an observed set and say so, as the hover trigger does here.\n- Driving the churn from React state at the tick rate for many blocks at once: one interval per block writing one node is what keeps this cheap.\n\n## Agent notes\n\n- prompt_phrases: \"text that decodes from random symbols\", \"scramble reveal on scroll and hover\", \"one scramble engine reused across triggers\".\n- fallbacks: with reduced motion, set the final text immediately. For screen readers, keep the final string in the accessible name or a live-region-free static node so the churn is never announced."},{"id":"scroll-focus-list","name":"Scroll Focus List","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/scroll-focus-list.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/scroll-focus-list.md","body":"## Intent\n\nA scroll focus list maps scroll progress onto a single index inside a list, then fades each item by its distance from that index. Only the item in focus reads at full strength; its neighbours dim continuously rather than switching on and off. The result is that a long flat list acquires a reading position without a highlight bar, a border, or a color change. Observed on Revelatio Studio's locations list (research/m56-round3/revelatio-disposition.json, row p2-revelatio.json rows[8], `scripts/locations-highlight.js` L1-69 read in full). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- The scroll container is the measurement surface: `progress = scrollTop / (scrollHeight - clientHeight)`, clamped to 0..1.\n- The focused index is fractional, not rounded: `focused = progress * (items.length - 1)`. Keeping it fractional is what makes the fade continuous.\n- Each item's opacity is `MIN_OPACITY + (MAX_OPACITY - MIN_OPACITY) * max(0, 1 - abs(index - focused))`, so anything more than one slot away sits at `MIN_OPACITY = 0.08`.\n- Opacity is written straight to the node style, with `opacity .12s linear` as the transition so the rAF-quantized steps read as a smooth ramp.\n- The scroll handler only schedules one rAF (`cancelAnimationFrame` then `requestAnimationFrame`), so a fast scroll does not queue a frame per event.\n- Large top and bottom padding (`py-[20vh]`) lets the first and last item reach full focus instead of being cut off by the container edge.\n\n## States\n\n- At rest at the top: the first item is at `MAX_OPACITY`, everything else is at `MIN_OPACITY`.\n- Mid-scroll between two items: both neighbours share the ramp, neither is fully lit.\n- Reduced motion: the same opacity math runs but the CSS transition is set to `none`, so the mapping stays and only the easing disappears.\n\n## Variants\n\n- Focus width: the `1 - distance` term defines a one-slot falloff. Dividing the distance by a wider constant lights more neighbours at once and reads calmer.\n- Driver: the demo drives from an inner scroll box, but the identical math works against the window scroll or an `IntersectionObserver` ratio.\n- Secondary channel: distance can drive scale, blur, or letter-spacing alongside opacity, though one channel at a time usually reads cleaner.\n\n## Code\n\n```tsx\nconst applyOpacity = () => {\n const max = el.scrollHeight - el.clientHeight;\n const clamped = Math.min(1, Math.max(0, max > 0 ? el.scrollTop / max : 0));\n const focused = clamped * (CITIES.length - 1);\n itemRefs.current.forEach((span, index) => {\n const fade = Math.max(0, 1 - Math.abs(index - focused));\n span.style.opacity = String(MIN_OPACITY + (MAX_OPACITY - MIN_OPACITY) * fade);\n });\n};\n```\n\n## Checks\n\n- Run the mapping once on mount, not only on the first scroll event, or the list starts uniformly dim.\n- Guard `scrollHeight - clientHeight` against zero: a container that does not overflow divides by zero and every item collapses to `MIN_OPACITY`.\n- Register the scroll listener as `{ passive: true }`; the handler never calls `preventDefault`.\n- `MIN_OPACITY` is a legibility decision, not a decorative one. Text at 0.08 is unreadable by design here, so anything a visitor must be able to read at all times does not belong in this list.\n\n## Anti-patterns\n\n- Snapping to a rounded index and toggling an `is-active` class: that produces a stepped highlight, which is the pattern this one exists to replace.\n- Storing the opacity of every row in React state: it re-renders the whole list per scroll frame when a direct style write costs nothing.\n- Using this on a list a visitor is meant to read straight through. Dimming is an emphasis device, not a decoration to sprinkle on body content.\n\n## Agent notes\n\n- prompt_phrases: \"list where the item in focus stays sharp and the rest fade\", \"scroll progress drives which row is highlighted\", \"continuous opacity falloff list\".\n- fallbacks: with reduced motion, keep the mapping and drop the transition. If the list must stay fully legible (accessibility requirement, long text), raise `MIN_OPACITY` to around 0.5 so the effect reads as emphasis rather than concealment."},{"id":"scroll-theme-inversion","name":"Scroll Theme Inversion","pattern_group":"marketing","kind":"visual-treatment","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["websites"],"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/scroll-theme-inversion.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/scroll-theme-inversion.md","body":"## Intent\n\nScroll theme inversion flips the entire chrome (background, foreground, all paired CSS variables) between light and dark as the scroll position crosses section boundaries, so a dark statement section reads as a deliberate mood shift rather than a local color override. The trigger point is the scroll container's center line, not the viewport edge, so a section only flips the theme once it dominates the visible frame. Observed at revelatio.studio (research/m55-round2/p2-revelatio.json row 5); passed the round-2 user gate frozen in research/2026-08-15-m55-gate-verdicts.md section 2 (round-2 new, pass, listed among the 9 promotion candidates).\n\n## Anatomy\n\n- Each section carries a `data-dark` flag; sections are stacked in a scrollable container.\n- On every scroll tick, the container's bounding rect gives a center Y; the code walks the section elements and finds whichever one's rect straddles that center Y.\n- That section's `dark` flag becomes the new global state; a `.dark` class toggles on the wrapper element, flipping the paired CSS variable set for the whole demo (not just the active section).\n- Transition duration is 350ms, `ease-in-out`, applied via `transitionDuration` on the wrapper and Tailwind's `transition-colors`.\n- Scroll handling is rAF-throttled: a `tickingRef` boolean gates a single `requestAnimationFrame(evaluate)` per scroll burst, avoiding one re-evaluation per scroll event.\n- A visible center-line marker (`sticky top-1/2`, 1px, low-opacity foreground) documents the trigger geometry in the demo; it is instrumentation, not part of the shipped pattern.\n\n## States\n\n- Light: no section straddling the center line has `dark: true`; wrapper has no `.dark` class.\n- Dark: the straddling section has `dark: true`; wrapper carries `.dark`, variable pairs inverted, 350ms transition running.\n- Settled: transition finished, colors stable until the next crossing.\n- Reduced motion: transition duration forced to 0ms at mount when `prefers-reduced-motion: reduce` matches; the flip becomes instant with no interpolation.\n\n## Variants\n\n- Trigger line position: center line is the observed choice; a line closer to the top reads sections as \"arrived\" earlier, closer to the bottom reads them as \"about to leave.\"\n- Scope of inversion: demo flips the whole wrapper; a site-level version could scope the flip to a fixed header/nav only, leaving body content locally themed per section.\n- Section count and alternation: the demo alternates light/dark by design; real usage would key off actual content mood per section, not a fixed pattern.\n- Overlay override (revelatio.studio, research/m56-round3/revelatio-deep.json): while a fullscreen menu/overlay is open, suspend the scroll-driven flip and force the overlay's own palette; resume on close. Prevents mid-open theme pops.\n- Alpha-baked opacity utilities: for elements that must survive the flip, bake transparency into the color channel (e.g. 60% alpha variants of the token) instead of `opacity`, so the theme transition and an opacity transition never fight on the same property.\n\n## Code\n\n```tsx\nconst evaluate = () => {\n tickingRef.current = false;\n const scrollRect = scrollEl.getBoundingClientRect();\n const centerY = scrollRect.top + scrollRect.height * 0.5;\n\n let activeDark = false;\n for (const el of sectionEls.current) {\n if (!el) continue;\n const rect = el.getBoundingClientRect();\n if (centerY >= rect.top && centerY <= rect.bottom) {\n activeDark = el.dataset.dark === \"true\";\n break;\n }\n }\n\n setIsDark((prev) => (prev === activeDark ? prev : activeDark));\n wrapper.classList.toggle(\"dark\", activeDark);\n};\n\nconst onScroll = () => {\n if (tickingRef.current) return;\n tickingRef.current = true;\n requestAnimationFrame(evaluate);\n};\n```\n\n## Checks\n\n- Scroll listener is `{ passive: true }` and removed on unmount; no dangling listener after the component leaves the tree.\n- Re-evaluation is rAF-gated through `tickingRef`, so a fast scroll burst does not queue more than one pending frame.\n- `prefers-reduced-motion` is read once at mount and used to zero the transition duration; the flip logic itself still runs, only the animated interpolation is removed.\n- State update is guarded (`prev === activeDark ? prev : activeDark`) so scrolling within one section does not trigger redundant re-renders.\n\n## Anti-patterns\n\n- Triggering the flip on viewport top/bottom instead of a stable center line: edge-based triggers flip and un-flip repeatedly as a section's boundary crosses in and out near the edge.\n- Re-evaluating on every raw scroll event without rAF throttling: the class toggle and state write fire many times per frame during fast scrolls.\n- Skipping the reduced-motion check and always running the 350ms transition: motion-sensitive users get an unrequested full-chrome color animation on every scroll.\n- Scoping `.dark` to each section instead of the shared wrapper: that produces a hard seam at the section boundary instead of the intended coherent chrome-wide flip.\n\n## Agent notes\n\n- prompt_phrases: \"site theme flips dark on scroll\", \"background inverts when a dark section is centered\", \"scroll-triggered global dark mode\".\n- fallbacks: if the page has no scroll container to measure against (fixed-height single view), this pattern does not apply; use a static theme instead. Without reduced-motion handling, do not ship the animated transition."},{"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":"sound-onboarding-gate","name":"Sound Onboarding Gate","pattern_group":"marketing","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"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/sound-onboarding-gate.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/sound-onboarding-gate.md","body":"## Intent\n\nA sound onboarding gate makes the first click carry two meanings at once: user consent to sound, and the browser-required AudioContext unlock. An explicit \"Click to start\" gate starts a synthesized ambient pad, separates background sound from interaction tones, and remembers the mute state across tab visibility changes. Observed at showcase.noomoagency.com (research/m52-round1/p2-noomo.json rows 11 and 4, including the `soundEnabledBeforeTabHidden` flag); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- A gate button as the only initial UI; `AudioContext` is created inside its click handler (autoplay policy compliance).\n- A background pad: three detuned sine oscillators into one gain node, ramped in over 1.5s.\n- Interaction tones: short enveloped oscillators per hover/click, routed separately from the pad.\n- A visibilitychange handler that ducks the pad to 0 when the tab hides and restores the previous user choice when it returns.\n\n## States\n\n- Gated: no audio objects exist yet; single call-to-action visible.\n- Running, sound on: pad audible, interaction tones fire.\n- Running, sound off: pad ramped to 0; the toggle records the user choice.\n- Tab hidden: pad forced silent regardless of the toggle; the pre-hide choice is restored on return.\n\n## Variants\n\n- Sampled audio: swap oscillators for buffer sources; the gate/unlock grammar is identical.\n- Mute-first entry: start with sound off but context unlocked, for quieter brand surfaces.\n\n## Code\n\n```tsx\nfunction unlockAudio() {\n if (audioCtxRef.current) return\n const ctx = new AudioContext() // created in the click handler\n const padGain = ctx.createGain()\n padGain.gain.value = 0\n padGain.connect(ctx.destination)\n padGain.gain.linearRampToValueAtTime(0.05, ctx.currentTime + 1.5)\n}\n```\n\n## Checks\n\n- AudioContext is constructed only inside a user gesture handler.\n- Oscillators are stopped and the context closed on unmount.\n- The tab-hidden duck never overwrites the user's stored preference.\n- Gain ramps (not instant jumps) on every audible change.\n\n## Anti-patterns\n\n- Autoplaying the pad on mount: blocked by browsers and hostile besides; the gate is the pattern.\n- One shared gain for pad and interaction tones: muting then kills interaction feedback the user expects.\n- Forgetting the visibility handler: sound continuing under a hidden tab is the top complaint against sound-on sites.\n\n## Agent notes\n\n- prompt_phrases: \"click to start with sound\", \"audio consent gate\", \"ambient sound toggle that survives tab switches\".\n- fallbacks: always keep the experience fully usable with sound off; sound is a layer, never a dependency."},{"id":"speed-sensitive-marquee","name":"Speed Sensitive Marquee","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/speed-sensitive-marquee.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/speed-sensitive-marquee.md","body":"## Intent\n\nA speed sensitive marquee is a looping logo strip whose scroll speed and direction respond to drag velocity, so a visitor can grab the stream and spin it faster, slower, or backward before it settles back to its idle pace. Observed at No Art `/about` (research/m52-round1/p2-noart.json row 6); user gate pass frozen in research/2026-08-15-m55-gate-verdicts.md section 1 (repaired, re-judged pass).\n\n## Anatomy\n\n- A doubled item list scrolls via `gsap.to(track, { x: -listWidth, duration: 20, ease: \"none\", repeat: -1 })`, wrapped seamlessly with a `modifiers.x` callback using `gsap.utils.wrap(-listWidth, 0)`.\n- Pointer down captures the pointer and kills any in-flight return tween.\n- Pointer move computes instantaneous velocity as `((clientX delta) / dt) * 1000`, scales it by `SENSITIVITY = -0.01`, clamps it to `TIMESCALE_CLAMP = 40` via `gsap.utils.clamp(-40, 40, raw)`, and sets `loop.timeScale(clamped)` directly each move.\n- Pointer up/cancel ends the drag and tweens `loop.timeScale` back to `1` or `-1` (sign of the current value) over `duration: 1.0`, `delay: 0.1`, `ease: \"power2.out\"`.\n\n## States\n\n- Idle: `timeScale` at 1, base loop runs at its 20s/cycle rate.\n- Dragging: `timeScale` tracks drag velocity live, can go negative (reverse) or up to the clamp in either direction.\n- Returning: pointer released, `timeScale` eases back to +1 or -1 depending on which direction it was moving at release.\n\n## Variants\n\n- Content: logo wordmarks, image logos, or any shrink-0 chip row.\n- Sensitivity/clamp tuning: lower `SENSITIVITY` magnitude for a heavier feel, raise `TIMESCALE_CLAMP` for more extreme spin.\n- Touch support: `touchAction: \"none\"` on the track plus pointer events (not mouse events) covers touch and pen alongside mouse.\n- GSAP-free rAF core (revelatio.studio drag-marquee.js, research/m55-round2/p2-revelatio.json row 4): one 20s base loop advanced manually per frame; drag maps `timeScale = clamp(vx * -0.01)` with multiplier 40 and recovers to base speed over 0.9s. Same feel, zero dependency; use when the host page does not already ship GSAP.\n\n## Code\n\n```tsx\nconst onPointerMove = (e: PointerEvent) => {\n if (!dragging) return;\n const now = performance.now();\n const dt = now - lastTime;\n if (dt <= 0) return;\n const velocityX = ((e.clientX - lastX) / dt) * 1000;\n lastX = e.clientX;\n lastTime = now;\n\n const raw = velocityX * SENSITIVITY;\n const clamped = gsap.utils.clamp(-TIMESCALE_CLAMP, TIMESCALE_CLAMP, raw);\n loop.timeScale(clamped);\n};\n```\n\n## Checks\n\n- The wrap modifier must receive and return a `\"-123px\"` string, not a bare number: passing a number into `gsap.utils.wrap` freezes the whole marquee (this was the M52 stall root cause, noted inline in source).\n- All four pointer listeners (`pointerdown`, `pointermove`, `pointerup`, `pointercancel`) are removed and both `returnTween` and `loop` are killed on unmount.\n- `setPointerCapture` is called on pointer down so drag tracking survives the pointer leaving the track element.\n\n## Anti-patterns\n\n- Letting `timeScale` snap to a fixed direction on release instead of easing: the abrupt stop reads as broken rather than as a stream settling.\n- Uncapped velocity-to-timeScale mapping: without the clamp, a fast flick sends the loop spinning at a rate that blurs the content past legibility.\n- Wiring drag directly to `x` instead of `timeScale`: bypassing the GSAP tween's own position loses the wrap-around seam and the idle-speed return.\n\n## Agent notes\n\n- prompt_phrases: \"drag to speed up marquee\", \"velocity-driven logo strip\", \"grab and spin the loop\".\n- fallbacks: source has no `prefers-reduced-motion` branch; the base loop still auto-scrolls at idle speed regardless of motion preference, so when honoring reduced motion, pause the idle tween and drop the drag-to-timeScale interaction rather than leaving it wired up unused."},{"id":"spotlight-mask-reveal","name":"Spotlight Mask Reveal","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"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/spotlight-mask-reveal.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/spotlight-mask-reveal.md","body":"## Intent\n\nA spotlight mask reveal hides a content layer behind a radial-gradient mask that follows the pointer as a small torch circle; clicking expands the mask past the container bounds so exploration becomes full disclosure. The mask geometry lives in three CSS custom properties tweened by GSAP. Observed at wrapped-party.activetheory.dev (research/m52-round1/p2-wrapped-party.json row 4, AwardsMask radial-gradient mask on custom properties); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Two stacked layers: a visible teaser layer and a hidden reveal layer carrying the mask.\n- `--mask-x`/`--mask-y`/`--mask-size` on the container; the mask-image uses them in a radial-gradient with a hard stop (black to transparent at the same radius).\n- Pointermove tweens position and opens the torch to a fixed radius (0.5s power2.out); pointerleave closes it; click tweens the radius to 150% (1.2s power4.inOut).\n\n## States\n\n- Hidden: mask size 0%, teaser layer showing.\n- Torch: pointer inside, a following circle of the hidden layer visible.\n- Revealed: mask expanded past bounds; pointer tracking disabled.\n- Reset: state and properties returned to hidden.\n\n## Variants\n\n- Soft-edge torch: two-stop gradient with a feathered transition zone.\n- Inverted mask (observed original): transparent center over black, hiding instead of revealing under the cursor.\n\n## Code\n\n```tsx\ntweenRef.current = gsap.to(el, {\n \"--mask-x\": `${x}%`, \"--mask-y\": `${y}%`, \"--mask-size\": \"16%\",\n duration: 0.5, ease: \"power2.out\",\n})\n// mask-image: radial-gradient(circle at var(--mask-x) var(--mask-y),\n// black var(--mask-size), transparent var(--mask-size))\n```\n\n## Checks\n\n- Both `mask-image` and `-webkit-mask-image` are set.\n- Every new tween kills the previous one (pointermove floods otherwise).\n- After full reveal, move/leave handlers early-return.\n- Custom properties are initialized inline so the first tween has a starting value.\n\n## Anti-patterns\n\n- Tweening a separate overlay div instead of the mask: the torch then covers content it should be revealing (z-order fights).\n- Re-rendering React state per pointermove for the coordinates: the tween on custom properties exists precisely to bypass render frequency.\n- A soft gradient for a \"torch\" without checking text contrast in the feather zone.\n\n## Agent notes\n\n- prompt_phrases: \"flashlight reveal\", \"mask follows cursor\", \"click to reveal all\".\n- fallbacks: keyboard/touch users need the click-to-reveal path to be reachable (the whole container is the button); reduced motion can skip the torch and reveal on click only."},{"id":"text-explosion-physics","name":"Text Explosion Physics","pattern_group":"marketing","kind":"visual-effect","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"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/text-explosion-physics.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/text-explosion-physics.md","body":"## Intent\n\nA text explosion hands each headline character to a 2D physics engine on click: radial impulse from the centroid, air friction, spin. Reset tweens every glyph back to its exact original position with a random-order stagger, pairing chaos with a controlled reassembly. Observed at madewithgsap.com (research/m52-round1/p2-madewithgsap.json row 3, matter-js text explosion); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- The headline is split into per-character spans (`data-char`), inline-block so they can transform.\n- On explode: measure each span's rect, freeze it to absolute positioning at its measured spot, create a matter-js rectangle body per char, apply an outward velocity proportional to its offset from center plus randomness.\n- A gsap.ticker callback steps the engine and writes each body's delta (position minus origin) into the span's transform.\n- On reset: the ticker stops; proxy objects tween from current physics deltas back to zero, then all inline styles are cleared and the engine world is disposed.\n\n## States\n\n- Intact: plain text, click/Enter/Space affordance on the headline.\n- Exploding: physics stepping, characters drifting and spinning under air friction.\n- Reassembling: tween running, physics stopped, glyphs converging.\n- Cleaned: engine cleared, spans restored to natural flow (no leftover absolute positioning).\n\n## Variants\n\n- Gravity fall: enable engine gravity for a collapse instead of a zero-g burst.\n- Pointer-reactive: apply impulses from pointer position rather than the centroid.\n- Sound pairing: the demo plays a short synthesized pop on burst.\n\n## Code\n\n```tsx\nconst body = Matter.Bodies.rectangle(cx, cy, w, h, {\n frictionAir: 0.08, restitution: 0.4, density: 0.001,\n})\nMatter.Body.setVelocity(body, { x: (dx / dist) * power, y: (dy / dist) * power })\nconst tick = () => {\n Matter.Engine.update(engine, 1000 / 60)\n for (const c of chars)\n c.el.style.transform = `rotate(${c.body.angle}rad) translate(${c.body.position.x - c.cx}px, ${c.body.position.y - c.cy}px)`\n}\ngsap.ticker.add(tick)\n```\n\n## Checks\n\n- Reset restores every span to natural flow (no leftover absolute positioning) and the world is cleared.\n- The ticker is removed both on reset and on unmount.\n- Explode is guarded against double-trigger while animating.\n- The headline is keyboard-activatable (role, tabIndex, Enter/Space).\n\n## Anti-patterns\n\n- Animating chars with independent random tweens and calling it physics: collisionless drift with easing curves reads as confetti, not a burst; the engine's friction/spin coherence is the observed texture.\n- Leaving the physics ticker running during the reassembly tween: two writers on the same transforms.\n- Splitting text without preserving spaces: words collapse after reset.\n\n## Agent notes\n\n- prompt_phrases: \"headline shatters on click\", \"physics text burst with reset\", \"matter-js typography\".\n- fallbacks: reduced motion should skip the explosion entirely; this is a one-shot showcase moment, never navigation-critical."},{"id":"twinkle-star-field","name":"Twinkle Star Field","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites"],"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/twinkle-star-field.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/twinkle-star-field.md","body":"## Intent\n\nA twinkle star field fills a hero surface with hundreds of points that each pulse on their own phase, and it does so without writing a shader material and without shipping a single image byte. The technique is the point: three.js's stock `PointsMaterial` is intercepted in `onBeforeCompile` and two lines are swapped, one in the vertex shader and one in the fragment shader, which is enough to add a per vertex twinkle and one emphasis uniform. The sprite is generated procedurally as a `DataTexture`, so the transfer cost of the whole effect is zero bytes. Observed on inspiring.nk.studio (`research/m58-round4/p2-nk-studio.json` rows[1], id `r4-02`, read directly out of chunk `0yw007.vb_n7k.js` at offset ≈1018700). Keep it next to `glow-points-scene`, which reaches a similar glowing point result by the opposite route of a fully hand written `ShaderMaterial`.\n\n## Anatomy\n\n- `STAR_COUNT = 420` points in one `BufferGeometry` with two extra vertex attributes: `aSizeMul` and `aTwinklePhase`. Positions are re distributed across the camera frustum on every resize, so the field always covers the surface rather than being scaled with it.\n- Both attributes come from a deterministic hash, `frac(43758.5453 * sin(12.9898 * i + 78.233 * salt))`, so the same index always produces the same star and reloading does not reshuffle the sky.\n- The sprite is a 64×64 `DataTexture` with white RGB and alpha `(1 - r / 31.5)^2.2`, bound as both `map` and `alphaMap`. No image request, no atlas.\n- Material values are the observed ones: `size 3.55`, `opacity 0.56`, `depthWrite false`, `depthTest false`, `alphaTest 0.02`, `NormalBlending`.\n- Vertex injection: `twWobble = 0.5 + 0.5 * sin(uTime * (0.88 + aTwinklePhase * 0.0011) + aTwinklePhase)`, `sizeStar = mix(0.5, 1.95, aSizeMul)`, `chaosBoost = 1.0 + uChaosEmphasis * 0.88`, then `gl_PointSize = size * sizeStar * chaosBoost`. The tiny `0.0011` coefficient is what detunes the frequencies so the field never beats in unison.\n- Fragment injection: `twPulse = 0.66 + 0.44 * vTwinkle`, `glow = 1.0 + uChaosEmphasis * 0.78`, and an alpha term `mix(0.86, min(1.0, 0.7 + 0.42 * vTwinkle), 0.4 + uChaosEmphasis * 0.42)`. Brightness and alpha move together, which is why a star reads as fading rather than shrinking.\n- Emphasis is damped asymmetrically: `λ 4.6` rising, `λ 3.25` falling, with a separate `λ 5.2` for the uniform itself and a `dt` clamp of `0.05`. Rising faster than falling is what makes the emphasis feel like a reaction instead of a crossfade.\n- Color is not baked in. `material.color` is set from the `--foreground` token read at runtime through a 1×1 canvas probe, refreshed on `data-theme` and `class` mutations and on `prefers-color-scheme` changes. The stage itself is `bg-muted`, so the points always sit opposite the surface in both themes.\n\n## States\n\n- Idle: every star cycles through `twWobble` on its own phase, size scaled by `sizeStar`, alpha near the `0.86` floor of the mix.\n- Emphasized: `uChaosEmphasis` damps toward 1, points grow by up to `0.88`, brighten by up to `0.78`, and their alpha response widens.\n- Releasing: the same value damps back at the slower `λ 3.25`, so the field settles rather than snapping.\n- Reduced motion: `uTime` is pinned to 0 and the emphasis target is forced to 0, leaving a still field of stars at fixed phase.\n- No WebGL: a bordered muted panel replaces the canvas and explains that no context could be created.\n\n## Variants\n\n- Density: `STAR_COUNT` trades atmosphere for cost. The whole field is one draw call, so raising it is cheap, but past a few thousand the points read as noise rather than sky.\n- Size spread: `mix(0.5, 1.95, aSizeMul)` is the observed spread. Narrowing it makes the field read as a uniform grid; widening it turns a few stars into blobs.\n- Emphasis binding: the demo drives `uChaosEmphasis` from a button. Scroll progress, audio level, or hover over a nearby element all work, because the uniform is a single scalar.\n- Display scale: the port keeps a separate `DEMO_SIZE_SCALE = 2.6` for card sized surfaces. This is a presentation constant, not an observation, and full bleed heroes should drop it back toward 1.\n\n## Code\n\n```tsx\n// 스톡 PointsMaterial 의 컴파일 직전에 두 줄만 갈아끼운다\nmaterial.onBeforeCompile = (shader) => {\n shader.uniforms.uTime = uTime;\n shader.uniforms.uChaosEmphasis = uChaosEmphasis;\n shader.vertexShader = shader.vertexShader\n .replace(\"#include <common>\", COMMON_WITH_ATTRIBUTES)\n .replace(\n /gl_PointSize = size;/,\n `float twWobble = 0.5 + 0.5 * sin( uTime * ( 0.88 + aTwinklePhase * 0.0011 ) + aTwinklePhase );\n vTwinkle = twWobble;\n float sizeStar = mix( 0.5, 1.95, aSizeMul );\n float chaosBoost = 1.0 + uChaosEmphasis * 0.88;\n gl_PointSize = size * sizeStar * chaosBoost;`,\n );\n shader.fragmentShader = shader.fragmentShader\n .replace(\"#include <common>\", COMMON_WITH_VARYING)\n .replace(\n /outgoingLight = diffuseColor\\.rgb;/,\n `float twPulse = 0.66 + 0.44 * vTwinkle;\n float glow = 1.0 + uChaosEmphasis * 0.78;\n outgoingLight = diffuseColor.rgb * twPulse * glow;\n diffuseColor.a *= mix( 0.86, min( 1.0, 0.7 + 0.42 * vTwinkle ), 0.4 + uChaosEmphasis * 0.42 );`,\n );\n};\n\n// 강조는 비대칭 감쇠 — 올라갈 때가 내려올 때보다 빠르다\nconst dt = Math.min(clock.getDelta(), 0.05);\nbind = THREE.MathUtils.damp(bind, target, target > bind ? 4.6 : 3.25, dt);\nemphasis = THREE.MathUtils.damp(emphasis, target, 5.2, dt);\n```\n\n## Checks\n\n- `onBeforeCompile` replaces by exact string. Verify that `gl_PointSize = size;` and `outgoingLight = diffuseColor.rgb;` still exist in the three.js version you pin, because a silent miss produces a plain point field with no error at all.\n- Confirm which camera you are on before trusting `material.size`. Under an `OrthographicCamera` three skips the `sizeAttenuation` branch and `size` is read in device pixels, not world units. Porting the source value under a perspective assumption is what made the first port render invisible points of 0.03px to 0.10px.\n- Do not stack the source's `lerp(0.7, 1.42, chaosBind)` on top of `chaosBoost` in a pixel sized port. Both multipliers compound and emphasis turns each star into a 24px blob.\n- Clamp `dt`. Without the `0.05` cap, a backgrounded tab returns with a multi second delta and the damp jumps straight to the target, losing the whole asymmetry.\n- Redistribute positions inside the resize handler. Scaling the canvas instead stretches the field and the star sizes drift with it.\n- Multiply by device pixel ratio deliberately and cap it at 2. Uncapped DPR on a dense field is the cheapest way to lose frames here.\n- Dispose the geometry, material, sprite texture, and renderer on unmount, and disconnect both the `ResizeObserver` and the theme `MutationObserver`.\n\n## Anti-patterns\n\n- Rewriting the whole thing as a custom `ShaderMaterial` to get one twinkle term. You lose fog, tone mapping, and every future three.js fix, and you gain nothing the two line injection did not already give you.\n- Shipping a PNG star sprite. The radial falloff is nine lines of code, scales to any resolution, and costs no request.\n- Driving the twinkle from `Math.random()` per frame on the CPU and pushing the buffer each tick. The phase attribute exists so the GPU does this for free and so the field is stable across reloads.\n- Giving every star the same frequency. Without the `aTwinklePhase * 0.0011` detune the field pulses as one object and immediately reads as a loop.\n- Baking a star color into the material. The points are the only lit thing on the stage, so a fixed light color disappears the moment the surface goes light. Resolve it from the foreground token instead.\n- Enabling `depthWrite` because the points look wrong. They are an unordered transparent layer; the observed configuration disables both depth write and depth test on purpose.\n\n## Agent notes\n\n- prompt_phrases: \"starfield background that twinkles\", \"hundreds of glowing points with their own rhythm\", \"night sky hero that reacts on hover\", \"particles without a custom shader\".\n- fallbacks: with reduced motion, freeze `uTime` at 0 and hold emphasis at 0 rather than removing the field. Without a WebGL context, render a bordered muted panel and say so, never an empty box.\n- The port keeps the source's `chaosBind` value damped and exposed on `data-chaos-bind` even though it no longer drives size, so the asymmetric damping stays observable in tests."},{"id":"underline-bridge-sweep","name":"Underline Bridge Sweep","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m59","surface_refs":["websites"],"tokens_used":["color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","color.semantic.emphasis.solid","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/underline-bridge-sweep.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/marketing/underline-bridge-sweep.md","body":"## Intent\n\nAn underline bridge sweep draws a rule under the emphasized words of a headline while the headline's characters are still arriving, and it alternates the direction and the start time of that rule from one emphasis group to the next. The point is rhythm, not decoration. Characters always enter left to right, so an underline that always grows from the left makes every emphasis read identically. Alternating groups makes the rule chase the text on one line and meet it on the next, which keeps a two line hero from settling into a single mechanical beat. Reverse engineered from inspiring.nk.studio, ledger row `research/m58-round4/p2-nk-studio.json rows[8]` (id `r4-09`), read directly out of the `R()` IIFE in module 37553 of chunk `0yw007.vb_n7k.js`. Promoted to a canonical asset in M59 after the user observation gate; the original teal brand accent is cited as provenance only and the asset is painted from project tokens.\n\n## Anatomy\n\n- The headline is split into pieces, and each piece that carries an emphasis group gets one absolutely positioned rule spanning its full width. The source marks these with `.hover-effects-1` through `.hover-effects-4`.\n- Characters are individually wrapped and animated with `opacity` and `blur(10px) -> blur(0px)` over `0.42s`, staggered `0.042s` per character across the whole headline, not per line. The stagger index is global, which is what lets a group on the second line know how late it should start.\n- The rule animates `scaleX` from `0` to `1` over `1.2s` on an `expo.inOut` curve, approximated in CSS as `cubic-bezier(0.87, 0, 0.13, 1)`. In the source this is a tween on the custom property `--hover-fx-before-scale-x`.\n- Odd groups start at `firstCharIndex * 0.042` with `transform-origin: left center`, so the rule launches with the first letter of the word and grows rightward behind the arriving text.\n- Even groups start at `lastCharIndex * 0.042 - 0.1` with `transform-origin: right center`, so the rule leaves `0.1s` before the word finishes and grows leftward into the text.\n- Vertical placement comes from the observed pair `--hero-underline-bridge-from-p` at `0.01em` to `0.02em` and `--hero-underline-bridge-to-p` at `0.17em` to `0.21em`, two responsive values. The demo uses the larger pair, `0.02em` and `0.21em`, composed as `bottom: calc(-1 * 0.21em + 0.02em)`.\n- The rule is `2px` tall and marked `aria-hidden`, since it carries no meaning that is not already in the text.\n\n## States\n\n- Idle before play: characters at `opacity 0` and full blur, rules parked at `scaleX(0)`.\n- Sweeping: characters resolve on the global stagger while each group's rule expands from its own origin on its own delay.\n- Settled: characters fully opaque and unblurred, rules at `scaleX(1)`, nothing animating.\n- Replay: the stage is remounted by key so transitions restart from zero rather than interpolating from the settled state.\n- Reduced motion: `playing` is set immediately, so text and rules land in their final state with no sweep and no blur.\n\n## Variants\n\n- Group count: the source runs four groups, which lets the alternation repeat. Two groups is the minimum that shows the effect at all.\n- Lead value: the `0.1s` head start on even groups is what turns \"chasing\" into \"meeting\". Setting it to zero makes both directions read as the same late arrival.\n- Rule weight and offset: a heavier rule at a larger `to-p` offset reads as a marker highlight rather than a typographic underline, and stops feeling attached to the words.\n- Stagger reuse: the `0.042s` character stagger is deliberately the same value the site uses for its entry reveal, so the underline shares a clock with everything else on the hero.\n\n## Code\n\n```tsx\nconst STAGGER = 0.042\nconst UNDERLINE_DURATION = 1.2\nconst UNDERLINE_EASE = \"cubic-bezier(0.87, 0, 0.13, 1)\" // expo.inOut 근사\nconst EVEN_GROUP_LEAD = 0.1\n\n// 홀수 그룹은 첫 글자 시점, 짝수 그룹은 마지막 글자 시점보다 0.1s 먼저\nconst groupStart = (indices: number[], group: number) =>\n group % 2 === 1\n ? indices[0] * STAGGER\n : Math.max(0, indices[indices.length - 1] * STAGGER - EVEN_GROUP_LEAD)\n\n// 방향은 transform-origin 으로만 바꾼다 — 폭 애니메이션이 아니다\nstyle={{\n transformOrigin: fromLeft ? \"left center\" : \"right center\",\n transform: `scaleX(${playing ? 1 : 0})`,\n transition: `transform ${UNDERLINE_DURATION}s ${UNDERLINE_EASE} ${start}s`,\n}}\n```\n\n## Checks\n\n- Index characters globally across the whole headline, not per line. Restarting the index on each line collapses the second line's group onto the first line's timing and the alternation disappears.\n- Clamp the even group delay at zero. A short emphasized word early in the headline can compute `lastCharIndex * 0.042 - 0.1` as negative, and a negative CSS transition delay starts the sweep already partly drawn.\n- Animate `scaleX` with `transform-origin`, never `width` or `left`. Width animation is not composited and the rule visibly reflows the inline box it sits in.\n- Restart by remounting the stage, not by toggling the flag. Flipping `playing` back to `false` on a live element makes the rule retract with the same `1.2s` expo curve, which reads as an unintended second animation.\n- Verify the rule's vertical offset against the actual font. The `0.21em` value is tuned to a serif with a shallow descender, and on a face with deep descenders the rule cuts through them.\n- Do not let the `blur` filter run on the group wrapper. Filtering an ancestor creates a containing block and the absolutely positioned rule silently repositions.\n\n## Anti-patterns\n\n- Using `text-decoration: underline` with a transition. The property is not animatable in a way that produces a directional sweep, and the result is a rule that appears whole.\n- Growing every group from the left. That is exactly the monotony the split origin exists to break, and it makes the extra machinery pointless.\n- Giving the rule a semantic role or its own text. It is redundant with the emphasized words, so it belongs behind `aria-hidden`.\n- Painting the rule in an imported brand accent. This asset carries the technique, and the color must resolve from the consuming project's emphasis token.\n- Applying it to a whole paragraph rather than a few words. At paragraph length the `1.2s` expo sweep is slow enough to read as a loading bar.\n\n## Agent notes\n\n- prompt_phrases: \"hero headline with underlines that draw themselves under the key words\", \"alternating left and right underline sweep\", \"animated rule that follows the text as it types in\".\n- fallbacks: with reduced motion, set the final state directly so the rules are drawn and the text is unblurred on first paint. With a single emphasis group, keep the odd group timing and drop the alternation rather than inventing a second direction."},{"id":"vertical-horizontal-scroll","name":"Vertical to Horizontal Scroll","pattern_group":"marketing","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["websites"],"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/vertical-horizontal-scroll.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/marketing/vertical-horizontal-scroll.md","body":"## Intent\n\nA vertical scroll segment pins against the page and remaps vertical progress into horizontal drag: the page keeps scrolling normally, but a fixed-height track slides sideways through a row of panels, and each panel's background layer parallaxes at its own speed inside that horizontal scrub. Observed at normalisboring.es (research/m52-round1/p2-normalisboring.json rows 0 and 1: row 0 is the page-scroll-driven horizontal pin, row 1 is the nested containerAnimation parallax). The demo failed its first M52 gate because the hijack was scoped to an inner 520px scroller and read as ordinary scroll to the observer; it was rebuilt to pin against the actual page scroll and re-passed the repaired gate, frozen in research/2026-08-15-m55-gate-verdicts.md section 1 (\"vertical-horizontal-scroll | 통과\").\n\n## Anatomy\n\n- Stage element pins to the page scroll via `ScrollTrigger` with `start: \"top top\"`, `pin: true`, `anticipatePin: 1`, `scrub: 1`.\n- Pin duration is `end: \"+=\" + stageWidth * (panelCount - 1)`: the hijack lasts exactly one stage-width of scroll per extra panel.\n- Track tween: `xPercent: -100 * (panelCount - 1)`, `ease: \"none\"`: horizontal position is a direct linear function of vertical scroll progress, no easing curve of its own.\n- Nested parallax: each panel's `[data-parallax]` background layer runs its own `ScrollTrigger` with `containerAnimation: horizontalTween` (not the page scroll) and `start: \"left right\"` / `end: \"right left\"`, tweening `xPercent` from -20 to 20.\n- A `ResizeObserver` on `document.body` debounces `ScrollTrigger.refresh()` at 200ms: panel toggles elsewhere on the page change document height and shift the pin's start point, so the observer re-measures instead of trusting a one-time `resize` listener.\n- `gsap.context()` scopes every tween and trigger created inside it; `ctx.revert()` on cleanup kills the pin, the horizontal tween, and every nested trigger in one call.\n\n## States\n\n- Idle (pre-pin): panels stacked normally, page scrolls past the stage like any other section.\n- Pinned/scrubbing: stage fixed on screen, vertical scroll delta maps to track `xPercent`; panel backgrounds parallax independently inside the scrub.\n- Released: after the last panel, the pin unpins and vertical scroll resumes normally.\n- Reduced motion: no GSAP import, no pin, no scrub; panels render as a static stacked list with a stated fallback message.\n\n## Variants\n\n- Panel count: `xPercent: -100 * (panelCount - 1)` and the `end` distance both derive from `panels.length`, so panel count is not hardcoded.\n- Parallax intensity: the -20/20 `xPercent` range on `[data-parallax]` layers is the only per-panel tunable; wider range reads as faster drift.\n- Drop the nested parallax entirely and keep only the row-0 pin-and-drag for a plainer horizontal reel.\n\n## Code\n\n```tsx\nconst horizontalTween = gsap.to(track, {\n xPercent: -100 * (panelCount - 1),\n ease: \"none\",\n scrollTrigger: {\n trigger: pinRef.current,\n start: \"top top\",\n end: () => \"+=\" + pinRef.current!.offsetWidth * (panelCount - 1),\n pin: true,\n anticipatePin: 1,\n scrub: 1,\n },\n})\n\npanels.forEach((panel) => {\n const layer = panel.querySelector<HTMLElement>(\"[data-parallax]\")\n if (!layer) return\n gsap.fromTo(\n layer,\n { xPercent: -20 },\n {\n xPercent: 20,\n ease: \"none\",\n scrollTrigger: {\n trigger: panel,\n containerAnimation: horizontalTween,\n start: \"left right\",\n end: \"right left\",\n scrub: true,\n },\n },\n )\n})\n```\n\n## Checks\n\n- The page itself must drive the pin (`trigger: pinRef.current` against document scroll), not an inner scroll container; the M52 failure mode was exactly this substitution reading as normal scroll.\n- `prefers-reduced-motion: reduce` is checked before the GSAP import even runs; matched users get the static stacked layout and no scroll listener at all.\n- A `ResizeObserver` on `document.body` triggers a debounced `ScrollTrigger.refresh()` whenever page height changes, so the pin's start/end stay correct after DOM changes elsewhere on the page.\n- `gsap.context().revert()` on unmount tears down the pin, the horizontal tween, and every nested containerAnimation trigger; the observer is also disconnected and its debounce timer cleared.\n- GSAP and ScrollTrigger load via dynamic `import()` inside the effect, guarded by a `cancelled` flag so a fast unmount before the import resolves does not attach triggers to a gone DOM.\n\n## Anti-patterns\n\n- Pinning an inner scroll container instead of the page: it technically works but observers experience it as ordinary scrolling, not a hijack. This is the exact defect the demo was rebuilt to fix.\n- Hardcoding the pin's `end` distance instead of deriving it from `panelCount` and stage width: panel count changes silently desync the scrub.\n- Skipping the resize/DOM-change refresh: any layout shift elsewhere on the page (an accordion opening, a card expanding) leaves the pin's measured start point stale and the hijack triggers at the wrong scroll position.\n- Nesting the panel parallax under the page's own ScrollTrigger instead of `containerAnimation: horizontalTween`: the parallax progress would then track vertical scroll instead of the panel's horizontal position on screen.\n\n## Agent notes\n\n- prompt_phrases: \"vertical scroll becomes horizontal reel\", \"pin and drag panels sideways on scroll\", \"scroll hijack with nested parallax panels\".\n- fallbacks: reduced-motion users get the panel list stacked and static, no pin, no scrub, fully scrollable. If GSAP/ScrollTrigger fails to load, the effect never fires and the stage stays in its unpinned, unstyled starting layout rather than a partial/broken hijack."},{"id":"adaptive-navigation-container","name":"Adaptive Navigation Container","pattern_group":"navigation","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"mobile":{"platforms":{"ios":"tab bar / sidebarAdaptable","android":"navigation bar / navigation rail"},"gestures":["tap"],"touch_targets":["destination button"],"safe_area":"bottom","reduced_motion":"컨테이너 전환·선택 표시 트윈 없이 같은 종료 상태(채워진 아이콘 + 전면 색)","modal_depth":0,"port_notes":"해소 함수(목적지 수 × 폭 등급 → 컨테이너)는 그대로 옮겨지지만 컨테이너 위젯이 갈린다 — rail 은 iOS 에서 `sidebarAdaptable` 탭바, Android 에서 `NavigationRail`. 화면당 주 내비 1개 규칙이 이식의 판정선이다"},"code_asset":"examples/ui-vocabulary-site/src/components/adaptive-navigation-container.tsx","component_refs":[],"term_refs":["tab-bar","navigation-rail","tabs"],"source_refs":["apple-hig-components"],"last_verified":"2026-08-01","source_path":"recipes/navigation/adaptive-navigation-container.md","body":"## Intent\n\nOne destination set, three possible primary-navigation containers — resolved by rule, not taste. This recipe implements the container-selection decision table from `knowledge/mobile-navigation.md` §1: destination count and width class determine whether the screen gets in-page tabs (fewer than 3 destinations — no primary nav), a bottom tab bar (3-5, compact), or a navigation rail (more than 5, or any expanded layout). Unlike `bottom-tab-bar`, which encodes one container's contract, this recipe encodes the *choice between containers* and keeps exactly one primary navigation component per screen.\n\n## Anatomy\n\n- Resolver: a pure function `(destinationCount, widthClass) → container` that mirrors the knowledge decision table — the rule lives in one place, not scattered through JSX.\n- Bottom tab bar branch: full-width bottom row, icon + 1-2 word label per destination. Selection feedback is the `bottom-tab-bar` recipe's contract, shared as one component rather than reimplemented here, so the two containers cannot drift apart.\n- Navigation rail branch: leading vertical column (icon + label stacked), active destination carries a shape fill and the same filled-icon step as the bar; replaces the bottom bar entirely — never both at once.\n- In-page tabs branch: an underline tab row inside the content area; deliberately *not* a primary navigation container.\n- Rule readout: the demo surfaces which table row fired, so the mapping from state to container is inspectable.\n\n## States\n\n- **Active destination**: `aria-current=\"page\"` (bar/rail) or `aria-selected` (in-page tabs); emphasis carried by fill + color, never color alone.\n- **Container transition**: changing destination count or width class re-resolves the container; active destination is preserved when it still exists in the set.\n- **Compact / expanded**: `expanded` always resolves to the rail — a bottom bar on desktop-width layouts is a rule violation, not a variant.\n\n## Variants\n\n- Destination count 2 / 4 / 6 exercises all three table rows; real products pin one count and get one container — the variant axis exists to make the rule visible.\n- More than 5 destinations on compact may alternatively keep a bottom bar with tab customization (≤5 visible) — this asset promotes to a rail, the other lawful branch.\n- Navigation drawer is intentionally absent: new adoption is discouraged (expanded rail replaces it); existing products keep theirs, new screens don't add one.\n\n## Code\n\n```tsx\nfunction resolveContainer(count: number, width: WidthClass) {\n if (width === \"expanded\") {\n return { container: \"rail\", rule: \"expanded → rail/사이드바 — bottom bar 금지\" }\n }\n if (count < 3) {\n return { container: \"in-page-tabs\", rule: \"목적지 3개 미만 → 페이지 내 tabs (주 내비 아님)\" }\n }\n if (count <= 5) {\n return { container: \"bottom-bar\", rule: \"최상위 목적지 3~5 · compact → bottom tab bar\" }\n }\n return { container: \"rail\", rule: \"5개 초과 → rail 로 승격 (compact 는 탭 커스터마이즈 ≤5 도 가능)\" }\n}\n```\n\nContainer components (bar, rail, in-page tabs), the shared screen body, and the interactive `AdaptiveNavigationContainerDemo` live in `code_asset`.\n\n## Checks\n\n- Exactly one primary navigation container renders per screen state — never bar + rail, never bar + drawer.\n- The resolver is the only place that decides the container; JSX branches only on its output.\n- Every destination shows icon + visible label in bar and rail; active state readable without color (fill change).\n- `aria-current=\"page\"` on the active destination in bar/rail; `role=\"tab\"` + `aria-selected` only in the in-page tabs branch.\n- Switching containers preserves the active destination when it survives the set change.\n\n## Anti-patterns\n\n- **Hamburger-by-default**: reaching for a drawer \"because it's mobile\" — the decision table has no row that lands on a drawer for new adoption.\n- **Bar + drawer double navigation**: shipping a bottom bar and a drawer on the same screen gives two competing primary navs.\n- **Bottom bar on expanded layouts**: keeping the phone container on tablet/desktop widths instead of promoting to a rail or sidebar.\n- **Promoting in-page tabs to primary nav**: styling a 2-item underline tab row as if it switched app-level destinations.\n\n## Agent notes\n\n- `prompt_phrases`: \"목적지 수에 따라 하단 탭바나 레일로 자동 전환되는 내비게이션\", \"태블릿에서는 레일로 바뀌는 모바일 내비게이션 셸\"\n- Before building any mobile screen, run the resolver logic mentally with the real destination count and target breakpoints — the container is an output, not an input.\n- Judgment canon: `knowledge/mobile-navigation.md` §1 (container table) and §2 (behavior contract — tap-only switching, state preservation, label rules). Single-container contracts live in `bottom-tab-bar`.\n- If the request insists on a drawer for a new product, surface the rule and record the deviation in one line — platform-norm departures must be intentional."},{"id":"bottom-tab-bar","name":"Bottom Tab Bar","pattern_group":"navigation","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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"],"mobile":{"platforms":{"ios":"tab bar","android":"navigation bar"},"gestures":["tap"],"touch_targets":["tab button"],"safe_area":"bottom","reduced_motion":"지시자 wash·아이콘 스케일·누름 dip 없이 같은 종료 상태(채워진 아이콘 + 전면 색)로 착지","modal_depth":0,"port_notes":"탭 항목의 구조·모션·접근성은 `nav-tab-item` 이 소유하므로 함께 옮긴다. `motion-safe:` 변형은 RN `useReducedMotion`·Flutter `MediaQuery.disableAnimations` 분기로 바뀐다"},"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: a wash behind the active tab's icon that scales up as it fades in. It is the one element that moves between destinations, so the eye has a single thing to follow; the icons and labels themselves hold still.\n- Redundant selection cue: the active icon also switches from outlined to filled and its color shifts from muted to full-emphasis. Selection must survive the wash being invisible (reduced motion, grayscale, low vision), so it never rests on the indicator alone.\n- Press response: the whole target dips briefly while held. This is feedback for the tap, distinct from the indicator, which is feedback for the resulting state.\n- Optional badge: a small filled circle with a count, reserved for critical/actionable info (unread count), not decorative.\n\n## Motion\n\n- The selection transition is a `replace` over a short distance and a small surface, so it is short (about 200ms) with explicit easing rather than linear (`knowledge/motion-principles.md` §2-3).\n- Every animated property sits behind `motion-safe:`. A reduced-motion viewer lands on the same end state with no transition, never on a different end state.\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":"curtain-page-transition","name":"Curtain Page Transition","pattern_group":"navigation","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-14","surface_refs":["websites"],"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/curtain-page-transition.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/navigation/curtain-page-transition.md","body":"## Intent\n\nA curtain page transition covers the viewport with an opaque layer, swaps the content while fully hidden, then lifts the curtain and slides the new headline in from above. SPA route changes read as one deliberate scene change instead of an instant repaint. Observed at madewithgsap.com (research/m52-round1/p2-madewithgsap.json row 7; params duration .3, delay .2, power2.inOut; headline yPercent -100 to 0, .8s power4.inOut); user gate pass frozen in research/2026-08-14-m52-gate-verdicts.md section 2.\n\n## Anatomy\n\n- A curtain div absolutely covering the content region, `autoAlpha: 0` at rest, `pointer-events: none`.\n- A guard ref (`isAnimatingRef`) so navigation during a transition is ignored.\n- One timeline per navigation: curtain in (0.3s, 0.2s delay), a `tl.call` that swaps the displayed content at full cover, curtain out (0.3s), then the headline slide-in (0.8s power4.inOut) slightly overlapped.\n\n## States\n\n- Idle: curtain invisible, content interactive.\n- Covering: curtain animating to opaque; old content still mounted.\n- Swapped: content replaced at the fully covered frame (the user never sees the swap).\n- Revealing: curtain lifting, new headline sliding in.\n\n## Variants\n\n- Router wiring: run the timeline in a route-change hook; swap at the covered frame.\n- Directional curtains (translateX/Y wipes) instead of a fade; keep the covered-frame swap contract.\n- Per-page accent: tint the curtain with a semantic token per destination.\n- Arrival-side flag (revelatio.studio page-transition.js, research/m55-round2/p2-revelatio.json row 22): cover-in 0.6s power2.inOut on link click, then set a sessionStorage flag; the destination page checks the flag and plays cover-out 0.8s plus a 0.4s content fade-in, so both halves of the transition stay in sync across a real navigation without a SPA router.\n\n## Code\n\n```tsx\nconst tl = gsap.timeline({ onComplete: () => { isAnimatingRef.current = false } })\ntl.to(curtain, { autoAlpha: 1, duration: 0.3, delay: 0.2, ease: \"power2.inOut\" })\ntl.call(() => setDisplayedPage(page)) // swap while fully covered\ntl.to(curtain, { autoAlpha: 0, duration: 0.3, ease: \"power2.inOut\" })\ntl.fromTo(headerRef.current,\n { yPercent: -100, autoAlpha: 0 },\n { yPercent: 0, autoAlpha: 1, duration: 0.8, ease: \"power4.inOut\" }, \"-=0.1\")\n```\n\n## Checks\n\n- Content swap happens only at the fully covered frame (no half-visible swap).\n- Re-entry is guarded: clicking navigation mid-transition does nothing.\n- Timeline killed on unmount; curtain returns to `autoAlpha: 0` rest state.\n- Curtain color is a token (foreground/scrim), not a hardcoded value.\n\n## Anti-patterns\n\n- Swapping content before the curtain is opaque: the flash the pattern exists to prevent.\n- Making the curtain a second scroll container or focus trap: it is decoration, keep `pointer-events: none`.\n- Long curtain holds (over about 0.5s covered) on every navigation: the observed grammar is brisk; slow curtains punish routine navigation.\n\n## Agent notes\n\n- prompt_phrases: \"page transition curtain\", \"cover, swap, reveal\", \"route change wipe\".\n- fallbacks: for users with reduced motion, swap instantly without the curtain; the covered-frame swap contract still prevents flashes."},{"id":"doc-search-cmdk-grouped-results-panel","name":"Doc Search Cmd-K Grouped Results Panel","pattern_group":"navigation","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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- Own measure: the overlay carries one fixed width (40rem, exported as `DOC_SEARCH_PANEL_MAX_WIDTH`) at every viewport above mobile. A palette is a floating object, not a page region, so it must not absorb the width of whatever it opens over — result rows stay one scannable column.\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- **Container-width palette**: letting the overlay stretch to its container or viewport, which turns each result row into a wide band with the label at one edge and nothing holding the eye across the gap.\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":"floating-button-contrast","name":"Floating Button Contrast","pattern_group":"navigation","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/floating-button-contrast.tsx","component_refs":["scroll-theme-inversion"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/navigation/floating-button-contrast.md","body":"## Intent\n\nA floating button that stays fixed over a page of alternating light and dark sections has one hard problem: any fixed tone is invisible over half the page. This pattern solves it by measurement rather than by guessing. The button compares its own rectangle against every element marked as a dark region on each scroll frame, flips its icon tone when they overlap, and additionally lifts itself above the footer as the footer comes into range, so it never covers the page's closing content. Observed on bleach-mh's page-top button, shared across three route chunks (research/m56-round3/bleach-mh-deep.json d3[22]). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- Dark regions declare themselves with `data-tone=\"dark\"`. The button collects them once and re-measures on scroll and resize.\n- Overlap is the simple vertical test `a.top < b.bottom && a.bottom > b.top` between the button's rect and each region's rect. Any hit sets the button's own tone to dark, which selects the light icon.\n- Visibility is a separate channel: the button appears past a small scroll threshold, so it never covers content at the top of the page.\n- Footer lift is computed rather than hard-coded: `max(0, innerHeight - buttonBottom - (footerTop - 16))` pushes the button up exactly as much as the footer intrudes, with a small gap constant.\n- The footer is watched with a `ResizeObserver`, and the source also uses a `MutationObserver` for footers that mount late. This asset keeps the resize watcher and records the mutation case in comments because its footer is always mounted.\n- The demo scales the coordinate system: the stage box substitutes for the viewport, so the same formulas run against `stage.getBoundingClientRect()` instead of `window.innerHeight`.\n\n## States\n\n- Hidden: scroll offset below the threshold.\n- Visible, light tone: the button does not overlap any dark region.\n- Visible, dark tone: overlapping at least one dark region, icon inverted.\n- Lifted: the footer is in range and the button is offset upward by the computed amount.\n\n## Variants\n\n- Tone source: `data-tone` attributes (observed) or a computed background sample. The attribute approach is cheaper and does not fail on images.\n- Channels: icon tone only (observed) or icon plus surface. Flipping both can end up with a dark icon on a dark surface, so tie them to the same value if you flip both.\n- Threshold: any small scroll offset works. The point is that the button is not present at rest.\n\n## Code\n\n```tsx\nconst overlaps = darkRegions.some((region) => {\n const r = region.getBoundingClientRect();\n return btn.top < r.bottom && btn.bottom > r.top;\n});\nsetTone(overlaps ? \"dark\" : \"light\");\n```\n\n## Checks\n\n- Re-measure on resize as well as scroll. Region rects change with layout, and a stale list produces a button that is wrong on exactly one breakpoint.\n- Read the button's own rect rather than assuming its fixed position, since the footer lift moves it.\n- Throttle the measurement to one rAF per scroll burst; reading rects in the raw scroll handler forces layout per event.\n- Confirm contrast in both tones against the real regions, not against a swatch. This pattern exists to satisfy contrast, so failing it defeats the purpose.\n- Give the button an accessible label that describes the action rather than the icon.\n\n## Anti-patterns\n\n- Picking the tone from the section under the page's center: the button is not at the center, and the two disagree exactly when a section boundary crosses it.\n- A semi-transparent scrim behind the icon instead of measuring: it works everywhere and looks considered nowhere.\n- Hard-coding the footer offset: it breaks the moment the footer's height changes, which it does on every breakpoint.\n- Animating the tone flip slowly: the visitor is scrolling, and a slow flip means the icon spends the transition unreadable.\n\n## Agent notes\n\n- prompt_phrases: \"back to top button that changes color over dark sections\", \"floating button that avoids the footer\", \"auto contrast for a fixed action button\".\n- fallbacks: with reduced motion, keep the tone flip (it is a legibility change, not decoration) and drop the appearance animation. Without `ResizeObserver`, fall back to measuring the footer on scroll, which costs a little more but is correct."},{"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"],"mobile":{"platforms":{"ios":"large title navigation bar","android":"collapsing top app bar"},"gestures":["scroll","tap"],"touch_targets":["back button"],"safe_area":"top","reduced_motion":"제목 압축은 스크롤 위치의 함수이므로 유지 — 추가 트윈만 제거","modal_depth":0,"port_notes":"**두 플랫폼 모두 이 동작을 OS 가 소유한다** — iOS `prefersLargeTitles`, Android M3 `LargeTopAppBar`+`scrollBehavior`. 직접 구현하면 플랫폼 동작과 이중이 된다. 넘기는 것은 임계 동작·항상 존재하는 뒤로 타깃·붕괴 후 배경 채움이라는 요구 세 가지다"},"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":"membership-barcode-card","name":"Membership Barcode Card","pattern_group":"navigation","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-20","promoted_round":"m74-mobile","surface_refs":["mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"mobile":{"platforms":{"ios":"wallet pass","android":"membership card"},"gestures":["tap"],"touch_targets":["prev card button","next card button"],"safe_area":"bottom","reduced_motion":"카드 전환은 인디케이터 폭 변화 하나뿐이고, 그 변화가 없어도 현재 카드 번호(1/3)와 브랜드명이 상태를 그대로 전달한다","modal_depth":0,"port_notes":"바코드는 canvas 2D 로 그린다 — RN 은 `react-native-skia` 또는 SVG, Flutter 는 `CustomPainter` 로 옮긴다. 화면 밝기 상승·자동잠금 보류는 웹에서 구현할 수 없고 네이티브 API(iOS `UIScreen.brightness`·`isIdleTimerDisabled`, Android `WindowManager.LayoutParams.screenBrightness`·`FLAG_KEEP_SCREEN_ON`)가 필요하다. 이 자산은 그 요구를 화면 문구로만 표시한다"},"code_asset":"examples/ui-vocabulary-site/src/components/membership-barcode-card.tsx","component_refs":[],"term_refs":["page-control"],"source_refs":["apple-hig-components","material-m3-components"],"last_verified":"2026-08-20","source_path":"recipes/navigation/membership-barcode-card.md","body":"## Intent\n\n매장에서 점원이나 스캐너에게 보여 주는 멤버십 카드. 여러 장을 가진 사용자가 버튼으로 넘겨 지금 필요한 카드를 고른다.\n\n이 화면의 독특한 점은 **사람이 읽으라고 있는 것이 아니라 기계가 읽으라고 있다**는 것이다. 그래서 일반적인 \"정보를 예쁘게 보여 주는\" 카드와 판단 기준이 다르다. 데스크톱에는 대응물이 없다.\n\n## Anatomy\n\n- 카드 표면: 브랜드명 + 등급 배지 + 바코드 + 회원번호. 카드 한 장이 한 브랜드다.\n- **바코드는 `<canvas>` 한 장**으로 그린다. 막대를 DOM 노드 수십 개로 만들면 (1) 스크린리더가 의미 없는 요소를 훑고 (2) 확대할 때 서브픽셀 반올림으로 막대가 뭉쳐 스캔이 실패한다. 캔버스는 `role=\"img\"` + 회원번호를 담은 `aria-label` 로 하나의 그래픽이 된다.\n- 회원번호를 바코드 **바로 아래에 평문**으로 둔다. 스캔이 실패하면 점원이 손으로 입력하는 경로가 이것이다. 바코드만 있고 숫자가 없으면 실패했을 때 막힌다.\n- 페이지 인디케이터: 현재 카드 위치를 점으로 표시(`page-control`). 장식이 아니라 \"몇 장 중 몇 번째\"의 유일한 시각 단서다.\n- 하단 고정 바: 이전·다음 카드 버튼. **이 자산은 스와이프를 구현하지 않는다** — 전환 경로가 버튼 하나뿐이라는 것이 의도다. 실제 지갑 앱은 스와이프를 함께 주지만, 제스처를 못 쓰는 사용자에게 버튼은 유일한 경로이므로 그쪽을 최저선으로 고정했다. 스와이프를 더할 때도 버튼을 없애지 않는다.\n\n## 배치 규칙\n\n- **카드는 세로 중앙이 아니라 위쪽에 붙인다.** 스캐너에 대는 자세에서 손이 화면 아래를 쥐므로, 바코드가 아래에 있으면 손가락에 가린다.\n- **하단 바는 `env(safe-area-inset-bottom)` 을 위치가 아니라 패딩에 더한다.** 위치만 밀면 홈 인디케이터 영역에서 탭 영역이 잘린다. 이 값이 실제로 0이 아니려면 **가져가는 앱의** viewport 에 `viewport-fit=cover` 가 있어야 한다(없으면 항상 0을 반환한다).\n- 이전·다음 버튼은 `min-h-11`(44px) 이상. 서 있는 자세에서 한 손으로 누른다.\n\n## Motion end state\n\n카드를 넘기면 인디케이터의 활성 점이 폭을 넓히며 이동한다. 그것 하나뿐이고, 카드 자체는 즉시 교체된다 — 스캔 대기 중인 사용자에게 전환 연출은 지연으로만 느껴진다. 동작 줄이기 설정에서도 종료 상태는 같다.\n\n## 이 자산이 답하지 않는 것\n\n- **바코드 심볼로지**(Code128·EAN-13 등)를 구현하지 않는다. 막대 폭은 payload 로부터 결정적으로 생성한 시각 예시이며, 실제 서비스는 인코딩 라이브러리와 서버가 발급한 회전 토큰을 쓴다. 이 자산이 가르치는 것은 인코딩이 아니라 **화면에 어떻게 앉히는가**다.\n- **화면 밝기 상승·자동잠금 보류**는 웹에서 할 수 없다. 요구 사항으로 화면에 표시만 하고, 네이티브 이식 시 `port_notes` 의 API 로 채운다.\n\n## States\n\n- 첫 카드: 「이전 카드」 버튼이 `disabled` 로 흐려진다. 끝 카드에서는 「다음 카드」가 같은 상태가 된다. 끝에서 되감지 않는다 — 카드가 3장뿐이라 순환은 지금 몇 번째인지를 흐리게 만든다.\n- 스캔 실패: 이 자산의 범위 밖이지만, 회원번호 평문이 그 상태의 대체 경로다.\n\n## Variants\n\n- **단일 카드**: 카드가 한 장이면 인디케이터와 하단 이동 바를 모두 숨긴다. 넘길 것이 없는데 넘기는 컨트롤을 두지 않는다.\n- **QR 형**: 바코드 대신 정사각 QR 을 쓰는 서비스가 있다. 캔버스 비율만 1:1 로 바뀌고 나머지 배치 규칙(위쪽 배치·평문 병기·safe-area)은 그대로다.\n\n## Code\n\n```tsx\n<canvas\n ref={ref}\n role=\"img\"\n aria-label={`${card.brand} 회원 바코드, 회원번호 ${card.memberNo}`}\n className=\"block h-16 w-full text-foreground\"\n/>\n```\n\n```tsx\n<nav\n aria-label=\"카드 넘기기\"\n className=\"flex items-stretch gap-2 border-t bg-background px-4 pt-2\"\n style={{ paddingBottom: \"calc(env(safe-area-inset-bottom) + 0.5rem)\" }}\n>\n```\n\n캔버스 그리기(dPR 스케일링·전경색 상속·결정적 막대 폭)와 카드 목록은 `code_asset` 에 있다.\n\n## Checks\n\n- 캔버스가 `role=\"img\"` 과 회원번호를 담은 `aria-label` 을 갖는다 — 막대 자체는 스크린리더에게 의미가 없다.\n- 회원번호가 바코드 아래 평문으로 보인다(스캔 실패 시 유일한 대체 경로).\n- 하단 이동 버튼이 44px 이상이고 `env(safe-area-inset-bottom)` 을 패딩에 더한다.\n- **가져가는 앱**의 문서 viewport 에 `viewport-fit=cover` 가 있다 — 없으면 위 inset 이 항상 0이라 검사가 통과해도 실기기에서 무효다. ⚠ 이 참조 사이트는 그 값을 켜지 않는다(모의 폰 프레임 안에 헛된 여백이 생기고 가로 모드에서 콘텐츠가 잘린다). **이 항목은 소비자 쪽 요구이지 우리 표면에서 검증되는 항목이 아니다.**\n- 카드가 화면 위쪽에 있다(손에 가리지 않는 위치).\n- 캔버스가 `currentColor` 를 상속해 다크 모드에서 막대가 배경에 묻히지 않는다.\n\n## Anti-patterns\n\n- **막대를 DOM 노드로 그리기**: `<span>` 수십 개로 막대를 만들면 스크린리더가 빈 요소를 훑고, 확대 시 서브픽셀 반올림으로 막대가 뭉쳐 스캔이 실패한다.\n- **회원번호 생략**: 바코드만 두면 스캔 실패가 곧 막다른 길이 된다.\n- **카드를 세로 중앙에 두기**: 스캐너에 대는 자세에서 손이 화면 아래를 쥐므로 바코드가 가린다.\n- **전환 연출 넣기**: 스캔을 기다리는 사용자에게 카드 슬라이드 애니메이션은 지연으로만 느껴진다.\n- **제스처 전용**: 좌우 스와이프만 두고 버튼을 없애면 제스처를 못 쓰는 사용자의 경로가 사라진다.\n\n## Agent notes\n\n- `prompt_phrases`: \"매장에서 보여 주는 멤버십 바코드 카드를 만들어줘\", \"카드를 여러 장 넘길 수 있게 해줘\", \"바코드 아래에 회원번호를 평문으로 같이 보여줘\"\n- 결제 수단을 고르는 화면이면 이 자산이 아니라 커머스 계열(`mobile-buy-bar-sheet`)을 본다 — 이것은 **보여 주는** 카드이지 **결제하는** 카드가 아니다.\n- 네이티브로 옮길 때 화면 밝기·자동잠금은 `port_notes` 의 API 가 필요하다. 웹 구현은 그 요구를 문구로만 표시한다.\n\n## 출처\n\n`m74-mobile` 회차 채집 행 **B-07**(멤버십 바코드를 canvas 로 렌더) · **B-01/B-03**(safe-area 를 위치가 아니라 높이·패딩에 더하는 선언) · **B-02**(`viewport-fit=cover` 가 전제). 장부: `research/2026-08-20-m74-mobile-harvest.md`.\n\n## Changelog\n\n- 2026-08-20: `m74-mobile` 회차에서 승격. 사용자 게이트 통과."},{"id":"page-transition-modes","name":"Page Transition Modes","pattern_group":"navigation","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m67","surface_refs":["websites"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.raised","color.semantic.surface.muted","color.semantic.action.primary","color.semantic.text.on-accent","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"code_asset":"examples/ui-vocabulary-site/src/components/page-transition-modes.tsx","component_refs":[],"term_refs":["transition","fade-transition","slide-transition"],"source_refs":[],"last_verified":"2026-08-18","source_path":"recipes/navigation/page-transition-modes.md","body":"## Intent\n\n「페이지 전환은 커튼이다」는 전제를 깬다. 같은 목적(화면 교체)에 실제로 쓰이는 답이 최소 셋이고, **총 길이가 3배 이상 벌어진다.** 전환 길이는 관습이 아니라 브랜드가 전환 자체를 발화 자리로 쓸 것인가의 선택이다.\n\n## Anatomy\n\n- **무대** — 헤더 · 메뉴 · 본문 3레이어와 그 위의 전환 레이어(커튼).\n- **눌러 지우기** — 커튼 없음. 이전 화면이 눌리며 사라지고 새 요소가 스태거로 올라온다.\n- **밀어내고 덮기** — 3레이어가 동시에 위로 쓸려 나가고 커튼이 **반대 방향**에서 덮은 뒤, 그 위에서 브랜드가 재생된다.\n- **커튼 + 정리** — 커튼은 0.3초뿐. 덮인 사이에 이전 화면의 애니메이션을 회수한다.\n- **계기** — 살아 있는 애니메이션 수. 정리하지 않는 모드는 카운터가 쌓인다.\n\n## States\n\n- **대기** — 전환 없음. 카운터가 직전 전환의 결과를 보여준다.\n- **나가는 중** — 이전 화면이 사라지는 구간. 이 구간이 끝나기 전에 새 화면을 노출하면 「튀어나온다」로 읽힌다.\n- **덮인 중** — DOM 교체와 정리가 일어나는 유일하게 안전한 구간.\n- **들어오는 중** — 새 요소가 스태거로 자리를 잡는다.\n\n## Variants\n\n| 모드 | 출처 | 레이어 | 총 길이 |\n|---|---|---|---|\n| 눌러 지우기 | Partizan · Taxi.js (`pz2-01`) | 1장 | 약 1.2s |\n| 밀어내고 덮기 | NORMAL IS BORING · Swup 4 (`nib-03`) | 3장 + 반대 방향 커튼 + 브랜드 재생 | 약 4.25s |\n| 커튼 + 정리 | Made With Gsap · 자체 라우터 (`mwg-12`) | 1장 | 약 0.8s |\n\n**총 길이가 3배 이상 벌어진다.** 전환 길이는 관습이 아니라 전환을 브랜드 발화 자리로 쓸 것인가의 선택이다.\n\n## Code\n\n```tsx\n// 눌러 지우기 (pz2-01) — 나가는 구간이 끝난 뒤에 교체·재등장을 물린다.\ntl.to(main, { scale: 0.97, duration: 0.37, ease: \"expo.out\" }, 0)\ntl.to(main, { autoAlpha: 0, ease: beaucoupAlpha, duration: 0.37 }, 0)\ntl.call(swap, undefined, 0.37)\ntl.to(items, { y: 0, scale: 1, autoAlpha: 1, stagger: 0.04, duration: 0.875, ease: \"expo.out\" }, 0.38)\n\n// 밀어내고 덮기 (nib-03) — 3레이어와 커튼이 반대 방향으로 동시에 움직인다.\ntl.fromTo([header, menu, main], { yPercent: 0 }, { yPercent: -100, duration: 1, ease: \"power3.inOut\" }, 0)\ntl.fromTo(curtain, { yPercent: 100 }, { yPercent: 0, duration: 1, ease: \"power3.inOut\" }, 0)\n\n// 커튼 + 정리 (mwg-12) — 덮인 사이에만 회수한다. 전역 kill 이 아니라 범위 kill.\nScrollTrigger.getAll().forEach((t) => t.kill())\ngsap.globalTimeline.getChildren().forEach((t) => {\n t.targets().forEach((o) => main.contains(o) && t.kill(o))\n})\n```\n\n## Checks\n\n- 나가는 애니메이션이 **끝난 뒤에** 교체가 일어나는지 확인 — 겹치면 새 화면이 튀어나온다.\n- 커튼이 덮이는 동안 뒤 화면이 비치지 않는지 확인(반투명 커튼 금지).\n- 브랜드 재생이 커튼이 완전히 덮은 뒤에 시작하는지 확인 — 덮이는 동안 이미 보이면 두 번 재생되는 것처럼 읽힌다.\n- 전환을 여러 번 반복한 뒤 살아 있는 애니메이션 수가 늘지 않는지 확인.\n- 같은 요소에 두 트윈이 같은 속성을 동시에 쓰지 않는지 확인 — scale 트윈과 뒤의 고정값이 다투면 떨림이 된다.\n\n## Anti-patterns\n\n- **전환을 커튼 하나로 전제하는 것.** 세 모드 중 커튼은 하나뿐이고 가장 짧다.\n- **나가는 구간과 들어오는 구간을 겹치는 것.** 라우터가 새 DOM 을 따로 심는 원본과 달리, 한 엘리먼트를 재사용하면 겹침이 곧 결함이다.\n- **커튼을 반투명으로 시작하는 것.** 덮이는 중이 아니라 깜빡임으로 읽힌다.\n- **`gsap.from` 으로 브랜드를 재생하는 것.** 트윈이 붙는 프레임에 완성형이 한 번 보였다 튄다 — `fromTo` 를 쓴다.\n- **전역 kill 로 정리하는 것.** 같은 페이지의 다른 연출까지 죽는다 — 범위를 좁혀 kill 한다.\n\n## Agent notes\n\n- `prompt_phrases`: \"페이지 전환 넣어줘\", \"라우트 바뀔 때 부드럽게\", \"전환이 몇 초가 적당해\", \"전환 후에 애니메이션이 이상하게 남아\".\n- 길이를 먼저 묻는다 — 0.8초와 4.25초는 다른 제품 성격이다. 브랜드 발화를 전환에 실을 것인지가 갈림이다.\n- 잔류 애니메이션 문제로 온 요청이면 모드 선택이 아니라 **정리 규율**이 답이다.\n- 가장 단순한 커튼 하나면 충분한 경우 `curtain-page-transition` 을 쓴다 — 이 recipe 는 선택이 필요할 때다.\n\n## 세 모드 (2026-08-17 관측)\n\n| 모드 | 출처 | 레이어 | 총 길이 |\n|---|---|---|---|\n| 눌러 지우기 | Partizan · Taxi.js (`pz2-01`) | 1장 — 이전 화면을 눌러 지우고 새 요소가 스태거로 올라온다 | ~1.2s |\n| 밀어내고 덮기 | NORMAL IS BORING · Swup 4 (`nib-03`) | 3장 동시 + 반대 방향 커튼 + 그 위 브랜드 재생 | ~4.25s |\n| 커튼 + 정리 | Made With Gsap · 자체 라우터 (`mwg-12`) | 1장 — 커튼은 0.3초뿐, 값어치는 덮인 사이의 정리 | ~0.8s |\n\n- **눌러 지우기**: `to(old,{scale:.97})` + `to(old,{autoAlpha:0, ease:cubic-bezier(.25,.46,.45,.9), duration:.37})`, 새 요소 `{y:0, scale:1, stagger:.04, duration:.875, ease:expo.out}`, 헤더 `{autoAlpha:1, duration:.3}`.\n- **밀어내고 덮기**: 헤더·메뉴·본문 **3레이어가 동시에** `y 0 → -100vh`(`duration 1, power3.inOut`), 같은 순간 전환 레이어가 `y 100vh → 0` 으로 **반대 방향에서** 덮는다. 덮인 뒤 2.65초를 로고 재조립(`x 120% → 0, duration .33, stagger .075`)에 쓴다.\n- **커튼 + 정리**: 커튼 `.3s` 로 덮고, 그 사이에 `ScrollTrigger` 전건 kill → `<main>` 하위 트윈만 선별 kill → DOM 교체 → 커튼 걷기.\n\n## 규칙\n\n1. **목적을 먼저 고르고 효과를 나중에 고른다** — 세 모드 다 목적은 `replace` 지만 답이 다르다([[motion-principles]] §2).\n2. **전환의 절반은 걷어내는 쪽이다.** 라우터 전환에서 이전 화면의 애니메이션을 정리하지 않으면 좀비 타임라인이 쌓인다. 이 데모는 그 값어치가 화면에 안 보이므로 **살아 있는 애니메이션 수를 계기로 붙였다** — 정리하지 않는 두 모드는 카운터가 3→6→9 로 오르고 정리하는 모드만 3 으로 되돌아온다.\n3. **커튼을 반투명으로 시작하지 않는다.** 덮는 물건이 비쳐 보이면 「덮이는 중」이 아니라 「깜빡임」으로 읽힌다(아래 수리 기록).\n4. **브랜드 재생은 커튼이 완전히 덮은 뒤에 시작한다.** 덮이는 동안 이미 보이면 같은 연출이 두 번 재생되는 것처럼 읽힌다.\n\n## 우리가 정한 값\n\n`-100vh` 는 컨테이너 기준 `yPercent -100` 으로 옮겼다(데모가 카드 안에서 돈다). 페이지 문구·메뉴 항목·로고 글자는 우리가 지었고, 「살아 있는 애니메이션」 계기는 관측물이 아니라 우리가 붙인 계측기다. **전환 레이어의 시작 알파는 미관측**이라 처음에는 `.86 → 1` 보간으로 채웠다가 게이트에서 기각돼 걷어냈다(§수리 기록).\n\n## 수리 기록 (m67-repair)\n\n원 판정은 **보류(①결함)** 였고 세 증상이 각각 다른 원인이었다.\n\n1. 페이드 아웃(`.37s`)이 끝나기 전 `0.305` 초에 교체·복원이 겹쳐 새 페이지가 튀어나왔고, 1초짜리 scale 트윈이 뒤의 `set(scale:1)` 과 매 틱 다퉜다. → 둘 다 `.37` 에서 끊고 순차화.\n2. 커튼을 `.86` 으로 깔았다가 2.65초에 걸쳐 1 로 올리던 것을 없앴다. **그 `.86` 이 관측값이 아니라 우리가 넣은 값이었다** — 관측된 2.65초는 원본대로 로고 재조립 시간으로만 쓴다.\n3. 로고를 `gsap.from` 으로 재생해 트윈이 붙는 프레임에 완성형이 한 번 보였다가 시작 상태로 튀었다(from 플래시). → 착지 전까지 감추고 `fromTo` 로 교체, 리셋에도 로고 복원을 넣었다.\n\n## term 대응\n\n- `transition` — 상태 변화를 갑자기 튀지 않게 잇는다는 정의가 이 recipe 의 목적과 같고, 이 문서는 그 정의에 「전환의 길이는 선택이다」를 더한다.\n- `fade-transition`·`slide-transition` — 세 모드가 이 둘의 조합으로 분해된다(눌러 지우기 = fade 주도, 밀어내고 덮기 = slide 주도).\n\n## 관련\n\n[[curtain-page-transition]](같은 축의 가장 단순한 형태 — 이 recipe 가 그 위에 레이어 선택과 정리 규율을 더한다) · [[motion-principles]] · `research/m67-batch2-css/p2-ledger.json` 행 `pz2-01`·`nib-03`·`mwg-12`\n\n## Changelog\n\n- 2026-08-18 — m67-batch2-css 회차 신설. 보류(결함) 1회 수리 후 재판정 통과(동결 `research/2026-08-17-m67-gate-verdicts.md`)."},{"id":"scroll-overrun-advance","name":"Scroll Overrun Advance","pattern_group":"navigation","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/scroll-overrun-advance.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/navigation/scroll-overrun-advance.md","body":"## Intent\n\nScroll overrun advance reads the act of scrolling past the end of a view as a statement of intent: the visitor wants the next thing. Instead of navigating on the first over-scroll, which would be hostile, it accumulates the overrun into a visible gauge that decays when the visitor stops, and advances only when the gauge fills. The gauge is what makes it consensual, because the visitor can see the commitment building and can stop it by doing nothing. Observed on noth.in's project pages (research/m55-round2/p2-noth-in.json rows[9]: bottom test `innerHeight + scrollY >= scrollHeight - 2`, normalization threshold `2600`, touch multiplier `2.5`, gauge lerp `0.12`). This demo was repaired during M56 by widening the idle window, and passed re-judgment in the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 1.\n\n## Anatomy\n\n- Bottom detection uses a two-pixel epsilon, because sub-pixel layout means an exact equality test never fires reliably.\n- Wheel and touch deltas accumulate only while at the bottom. Touch deltas are multiplied by `2.5` so a swipe and a wheel notch cost about the same commitment.\n- The displayed gauge lerps toward the accumulated ratio at `0.12` per frame, so it feels like filling rather than stepping.\n- Idle decay is what makes the gesture cancellable: after an idle window, the accumulator decays per frame.\n- The two idle constants are demo-scaled and the reason is recorded in the source: at the original `140ms` window and `0.92` decay, the gap between wheel notches inside a small demo box was itself read as idle, so the gauge could never fill. The demo uses `450ms` and `0.985`, with the observed values kept in comments.\n- On fill, the next item is committed and the accumulator resets, so the gesture cannot double-fire.\n\n## States\n\n- Not at bottom: accumulator inert, gauge hidden or empty.\n- At bottom, accumulating: gauge fills toward 1 with each delta.\n- At bottom, idle: gauge decays back toward 0.\n- Advanced: next item shown, accumulator and gauge reset.\n\n## Variants\n\n- Threshold: `2600` is a full-page value. Any container smaller than a viewport needs its own scaled threshold, and the scaling must be recorded.\n- Feedback: a linear bar (this demo) or a radial fill. Either way it has to be visible before it completes.\n- Direction: forward only (observed) or bidirectional, where over-scrolling at the top returns to the previous item.\n\n## Code\n\n```tsx\nconst atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - BOTTOM_EPSILON;\nif (atBottom) accum.current += delta * (isTouch ? TOUCH_MULTIPLIER : 1);\ngauge.current += (Math.min(1, accum.current / OVERRUN_THRESHOLD) - gauge.current) * GAUGE_LERP;\n```\n\n## Checks\n\n- Only call `preventDefault` on the event that actually completes the advance. Preventing every over-scroll event steals the platform's bounce feedback for the whole time the visitor is at the bottom.\n- Tune the idle window against real wheel rhythm. Notches arrive `200ms` to `400ms` apart, and an idle window shorter than that decays faster than the visitor can fill.\n- Reset the accumulator on advance and on leaving the bottom, or the next arrival at the bottom starts pre-charged.\n- Give the visitor an ordinary link to the next item as well. This gesture is an accelerator, never the only path.\n- Verify with a trackpad, a mouse wheel, and touch. The three produce very different delta profiles, which is why the touch multiplier exists.\n\n## Anti-patterns\n\n- Advancing on the first over-scroll event: the visitor loses control of navigation, and momentum scrolling triggers it accidentally.\n- An invisible accumulator: the page appears to navigate itself, which is the difference between a gesture and a trap.\n- Never decaying: the commitment then persists across unrelated interactions and fires much later.\n- Using the full-page threshold inside a small container without scaling: the gauge never fills, which is exactly the defect that was repaired here.\n\n## Agent notes\n\n- prompt_phrases: \"keep scrolling at the bottom to go to the next project\", \"over-scroll gauge that advances the page\", \"pull past the end to continue\".\n- fallbacks: with reduced motion, keep the gauge but drop the lerp so it steps. Always keep a conventional next link, and disable the gesture entirely where the container is not the primary scroller."},{"id":"shared-element-flip-transition","name":"Shared Element FLIP Transition","pattern_group":"navigation","kind":"motion-pattern","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m56","surface_refs":["websites"],"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/shared-element-flip-transition.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-14","source_path":"recipes/navigation/shared-element-flip-transition.md","body":"## Intent\n\nA shared-element FLIP transition clones the clicked element, pins it in place with position:fixed at its start rect, then animates it to the destination rect while the real elements swap underneath. The clicked card visibly becomes the next view's hero instead of the page cutting away. Two observed techniques compose: a text-clone FLIP on project navigation (noth.in aurbse, research/m55-round2/p2-noth-in.json row 8, enter duration 1.2s ease power4.inOut) and a card-to-hero shared-element morph with overlapping content fade (Glitch & Grit, research/m55-round2/p2-glitchandgrit.json row 0, duration 1.75s ease expo.inOut, clipPath text-block slide, timeline overlap offset -1.9s). User gate pass frozen in research/2026-08-15-m55-gate-verdicts.md section 2 (round-2 new, pass, listed among the 9 passing demos).\n\n## Anatomy\n\n- On click, measure the source element's rect (`getBoundingClientRect`) before any state change (First).\n- Swap view state, wait one `requestAnimationFrame` so the destination DOM mounts, then measure the destination element's rect (Last).\n- Build a clone `div` styled to match the source rect exactly (left/top/width/height in px), append it to `document.body`, hide the real source and destination elements during the flight.\n- Invert: compute deltaX/deltaY/scaleX/scaleY between start and end rects, then tween the clone from the inverted position back to identity (x, y, width, height) over the flight duration.\n- On timeline complete: remove the clone, restore opacity/visibility on the real elements.\n- Observed durations: 1.2s ease `power4.inOut` (noth.in text clone), 1.75s ease `expo.inOut` (Glitch & Grit card-to-hero, with a -1.9s timeline overlap against the following content reveal).\n- Detail content fades in overlapping the flight (`autoAlpha` 0→1, y 12→0, duration 1s ease `power2.inOut`, delay 0.25s) rather than waiting for it to finish.\n\n## States\n\n- List: source cards visible, no clone in the DOM.\n- Flying: clone in flight (position:fixed, z-50, pointer-events-none), source and destination hidden.\n- Settled: clone removed, destination element opacity restored to 1.\n- Reduced motion: transition skipped entirely, view swaps instantly with no clone created.\n\n## Variants\n\n- Direction: forward (card to hero) and reverse (hero back to card) both flip through the same clone-and-invert mechanism, using each other's cardEl/heroEl for start/end rects.\n- Content type: text clone (title/number) vs. full card clone (border, background, layout): the source picks whichever anatomy the trigger element has.\n- Overlap style: content reveal can start during the flight (Glitch & Grit's -1.9s offset) or wait for `onComplete` (simpler, less overlap risk).\n- Trigger by attribute, not route matching (noth.in, research/m56-round3/noth-in-deep.json): the router's chooseTransition takes `dataset.transition` from the clicked anchor verbatim; only the next-project card builder sets `data-transition='flip'`, everything else falls back to a plain fade. Declaring the transition on the link keeps the FLIP opt-in local and side-steps route tables.\n- Double-wrapper flight targets: the destination header ships as `title-work-flip > h1-flip-init > h1[letter]` (plus a number twin); the reveal attribute sits on the heading itself, wrappers stay attribute-free, and the clone's scale ratio is computed as target fontSize / capture-time fontSize to survive responsive font sizes.\n\n## Code\n\n```tsx\n// Invert: 실제 이동값을 역산해 클론이 원래 자리에 있는 것처럼 보이게 한다\nconst deltaX = endRect.left - startRect.left;\nconst deltaY = endRect.top - startRect.top;\nconst scaleX = endRect.width / startRect.width;\nconst scaleY = endRect.height / startRect.height;\n\nconst tl = gsap.timeline({\n onComplete: () => {\n clone.remove();\n cloneRef.current = null;\n if (hero) hero.style.opacity = \"1\";\n },\n});\ntl.to(clone, {\n x: deltaX,\n y: deltaY,\n width: startRect.width * scaleX,\n height: startRect.height * scaleY,\n duration: FLIP_DURATION, // 1.2\n ease: FLIP_EASE, // power4.inOut\n});\n```\n\n## Checks\n\n- `prefers-reduced-motion: reduce` checked at mount and read before every flight; when true, no clone is created and the view swap is instant.\n- Timeline killed and clone removed on unmount (`tlRef.current?.kill(); cloneRef.current?.remove();`).\n- Content fade tween is cleaned up (`contentTween.kill()`) when the effect reruns or unmounts, not left dangling.\n- Destination rect is measured only after `requestAnimationFrame`, never synchronously after the state update, so Last reflects the mounted layout.\n- Clone uses semantic tokens (`border-border`, `bg-muted`, `text-foreground`) not hardcoded colors, so it matches whichever view it is flying between.\n\n## Anti-patterns\n\n- Measuring the destination rect synchronously right after `setState`: React has not painted the new DOM yet, so the rect is stale or the element does not exist.\n- Animating the real element in place instead of a clone: the real element is still part of layout flow and fights the transition target's own layout.\n- Skipping the reduced-motion check: a full-screen flying clone is one of the more disorienting motion patterns for vestibular-sensitive users.\n- Leaving the clone in the DOM if the component unmounts mid-flight: a stray fixed-position ghost element with no owner.\n\n## Agent notes\n\n- prompt_phrases: \"card flies into the next page as its hero\", \"shared element transition between views\", \"FLIP clone morphs into destination\".\n- fallbacks: with `prefers-reduced-motion: reduce`, skip the clone entirely and swap views directly; do not just shorten the duration. If GSAP is unavailable, the same Invert math works with native Web Animations API or CSS transitions on transform/width/height."},{"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"],"mobile":{"platforms":{"ios":"action sheet","android":"modal bottom sheet"},"gestures":["tap","swipe-vertical"],"touch_targets":["action row","cancel row","scrim"],"safe_area":"bottom","reduced_motion":"시트 상승·scrim 페이드 없이 최종 위치에서 표시","modal_depth":1,"port_notes":"iOS 는 `UIAlertController(.actionSheet)` 가 파괴적 스타일과 Cancel 분리를 이미 갖고 있어 그 API 에 위임하는 것이 옳다. scrim 탭 해제를 직접 구현하면 네이티브 기본 해제와 이중이 된다"},"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":"character-zoom-isolation","name":"Character Zoom Isolation","pattern_group":"overlays","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-16","promoted_round":"m58","surface_refs":["websites"],"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/character-zoom-isolation.tsx","component_refs":["modal-exit-choreography"],"term_refs":[],"source_refs":[],"last_verified":"2026-08-16","source_path":"recipes/overlays/character-zoom-isolation.md","body":"## Intent\n\nA character zoom isolation view answers a zoom button by pushing the whole page back, blurring and dimming it, while the subject scales up and stays sharp in the middle. Three implementation decisions make it more than a modal: the overlay module is not loaded until the first open, the scroll lock compensates for the scrollbar so nothing shifts sideways, and the lock is released on a delay matched to the close transition rather than the instant the state flips. Observed on bleach-mh's character section (research/m56-round3/bleach-mh-deep.json d3[5], d3[9], d3[21]). Passed the user gate frozen in research/2026-08-15-m56-gate-verdicts.md section 2.\n\n## Anatomy\n\n- The trigger is a real button with an explicit zoom label, not an image click target.\n- The overlay content is behind a lazy boundary, so its code is fetched on first open. The observed site does the same with a dynamic import marked client-only.\n- Scroll lock sets `overflow: hidden` on the body and adds `paddingRight` equal to `innerWidth - clientWidth`, publishing that width as a CSS variable so other fixed elements can compensate too.\n- Release is delayed: the source exposes a delay argument defaulting to zero, and this asset passes `220ms` to match its close transition so the restored scrollbar does not pop back while the overlay is still fading.\n- Isolation is built from two channels on the background, blur plus dim, and one on the subject, scale. Only the subject is exempt from the blur, which is what reads as isolation rather than as a dialog.\n- Source values, measured live on 2026-08-16 (M58 step-8, research/m56-round3/bleach-mh-disposition.json `unverifiedResolved`): the scrim animates `background-color` to `rgba(21, 21, 21, 0.8)` and `backdrop-filter` to `blur(16px)` over `0.4s cubic-bezier(0.25, 1, 0.5, 1)`, and the body fades `0 to 1` on the same duration and curve. Every one of those transition declarations lives under a `[data-anim=\"on\"]` ancestor, so a global motion toggle removes the transition and leaves the state change.\n\n## States\n\n- Closed: no overlay in the tree, page scrollable, module possibly not yet fetched.\n- Opening: module resolves (instant after the first time), overlay mounts, background blurs and dims, subject scales.\n- Open: scroll locked, focus inside the overlay, background inert.\n- Closing: reverse transition runs, scroll lock released after the delay.\n\n## Variants\n\n- Loading: lazy on first open (observed) or eager, which is defensible only if the overlay is small and opened by most visitors.\n- Background treatment: blur plus dim (this asset), dim alone, or a scale-down push. Blur is the one that costs the most on low-end devices.\n- Release delay: zero is the source default and is correct when the close is instant. Match it to the transition whenever the close is animated.\n\n## Code\n\n```tsx\nconst lock = () => {\n const width = window.innerWidth - document.documentElement.clientWidth;\n document.documentElement.style.setProperty(SCROLLBAR_LOCK_VAR, `${width}px`);\n document.body.style.overflow = \"hidden\";\n document.body.style.paddingRight = `${width}px`;\n};\n```\n\n## Checks\n\n- Compensate for the scrollbar width. Without it, locking scroll shifts the entire page sideways at the exact moment the visitor's attention moves to the overlay.\n- Release the lock on a delay that matches the close transition, and cancel that timer if the overlay reopens first.\n- Restore the previous body styles rather than clearing them, in case something else set them.\n- Trap focus in the overlay and return it to the trigger on close; the zoom button is the anchor the visitor came from.\n- Provide a close affordance beyond Escape, since the pattern invites touch use where there is no Escape key.\n\n## Anti-patterns\n\n- Locking scroll by setting `position: fixed` on the body: it loses the scroll position and jumps the page to the top on close.\n- Blurring the background including the subject: the isolation reads as a mistake instead of a focus device.\n- Loading the overlay module eagerly on every page that has a zoom button: the cost lands on every visitor for a view most never open.\n- Releasing the lock synchronously with the state flip: the scrollbar reappears mid-transition and the close reads as a jump.\n\n## Agent notes\n\n- prompt_phrases: \"zoom into an image while the page blurs behind\", \"modal that loads its code on first open\", \"scroll lock with scrollbar width compensation\".\n- fallbacks: with reduced motion, open and close without the scale and blur transitions while keeping the lock and the focus behavior. If the lazy module fails to load, keep the trigger enabled and surface the failure rather than leaving a dead button."},{"id":"mobile-buy-bar-sheet","name":"Mobile Buy Bar Sheet","pattern_group":"overlays","kind":"block","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m62","surface_refs":["commerce","mobile-apps"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","color.semantic.border.default"],"mobile":{"platforms":{"ios":"action sheet or modal sheet above a fixed bar","android":"modal bottom sheet above a fixed bar"},"gestures":["tap"],"touch_targets":["option select button","close button"],"safe_area":"bottom","reduced_motion":"시트 상승·scrim 페이드 없이 최종 위치에서 표시","modal_depth":1,"port_notes":"고정 바 높이 55px 는 44px 최소를 넘지만 **내부 버튼이 그 높이를 물려받는지 따로 확인**해야 한다. 시트·모달 두 변형 중 무엇을 쓰는지는 호출자가 고르는 계약이므로 이식에서도 하나로 고정하지 않는다"},"code_asset":"examples/ui-vocabulary-site/src/components/mobile-buy-bar-sheet.tsx","component_refs":[],"term_refs":[],"source_refs":[],"last_verified":"2026-08-17","source_path":"recipes/overlays/mobile-buy-bar-sheet.md","body":"## Intent\n\nA fixed bottom buy bar (EM-01: height 55px, padding 0 5px, font-size 18px, border 1px, observed on Emart Mall's product page CSS) opens an option-selection surface when its buttons are tapped. That surface is not always a bottom sheet. `Shopify/dawn` (GH-10, MIT, `assets/quick-add.css:43-77`) opens a centered modal for its quick-add flow, while `medusajs/nextjs-starter-medusa` (GH-18, MIT, `src/modules/products/components/product-actions/mobile-actions.tsx:58-176`) opens a bottom sheet. This recipe implements both surfaces behind the same fixed bar and lets the caller pick, instead of defaulting to a bottom sheet as the only convention.\n\nThis is a separate asset from the retired `bottom-sheet-detents` recipe (`status: deprecated`, retired 2026-08-17). It carries forward only that recipe's original request — \"the drawer looks too plain, check references and implement the detail\" — as a fresh ground-up build against real sources. It does not revive the retired recipe or its code asset.\n\n## Anatomy\n\n- Fixed bottom bar: `fixed`/`absolute inset-x-0 bottom-0`, `border-t`, `p-4` (GH-18 bar shell) containing a price row and one or two option-selection buttons sized to EM-01 (`h-[55px]`, `px-[5px]`, `text-lg`, `border`).\n- Price row: two text sizes at different weights in the same row — a smaller label and a larger total (EM-02: label 20px/400 next to total 35px/500) — not a single uniform price line.\n- Sheet surface (`sheet` variant, GH-18): scrim at `z-[75]` with `bg-opacity-75 backdrop-blur-sm`, panel pinned `inset-x-0 bottom-0`, `px-6 py-12` interior padding, no drag handle — dismissal is a 48px (`h-12 w-12`) circular close button, not a grabber.\n- Modal surface (`modal` variant, GH-10): centered panel, no drag handle, positioned by `--modal-height-offset` (`3.2rem` mobile, `10rem` at `min-width: 750px`), width `calc(100% - 3rem)` on mobile widening to `80%`/`70%` at wider breakpoints.\n\n## States\n\n- **Closed**: neither surface rendered; only the fixed bottom bar is visible.\n- **Open, sheet variant**: scrim + bottom-pinned panel rendered; dismiss via scrim tap or the circular close button.\n- **Open, modal variant**: scrim + centered panel rendered; dismiss via scrim tap or its close button.\n- Only one surface is open at a time — opening one variant while the other is open is not a state this recipe defines (the code asset's demo toggles between mutually exclusive `variant` values).\n\n## Variants\n\n- `variant=\"sheet\"` — bottom-pinned, GH-18 shell. Use when the product convention already leans on a bottom sheet elsewhere in the flow.\n- `variant=\"modal\"` — centered, GH-10 shell. Use when the option list is short enough that a centered card reads faster than a bottom-anchored one, or when the product's existing quick-add pattern is already modal-based (as in Dawn).\n- The choice between the two is a product decision, not a mobile-viewport rule — do not treat bottom sheet as the mobile default and modal as a desktop fallback; both variants observed here are mobile-scoped.\n\n## Code\n\n```tsx\n{/* fixed bottom bar — EM-01 button sizing, EM-02 price-row size contrast */}\n<div className=\"absolute inset-x-0 bottom-0 flex flex-col gap-2 border-t border-border bg-card p-4\">\n <div className=\"flex items-baseline justify-between\">\n <span className=\"text-base font-normal text-foreground\">총 금액</span>\n <span className=\"text-3xl font-medium text-foreground\">45,900원</span>\n </div>\n <button\n type=\"button\"\n onClick={() => setVariant(\"sheet\")}\n className=\"flex h-[55px] flex-1 items-center justify-center border border-border bg-primary px-[5px] text-lg text-primary-foreground\"\n >\n 바텀시트로 옵션 선택\n </button>\n</div>\n\n{/* sheet variant — GH-18: z-[75] scrim, bg-opacity-75 backdrop-blur-sm, no drag handle */}\n{variant === \"sheet\" && (\n <div className=\"absolute inset-0 z-[75]\">\n <button type=\"button\" aria-label=\"닫기\" onClick={() => setVariant(null)} className=\"absolute inset-0 bg-foreground/75 backdrop-blur-sm\" />\n <div className=\"absolute inset-x-0 bottom-0 flex max-h-[80%] flex-col gap-4 rounded-t-2xl border-t border-border bg-card px-6 py-12\">\n <button type=\"button\" onClick={() => setVariant(null)} aria-label=\"옵션 시트 닫기\" className=\"flex h-12 w-12 items-center justify-center rounded-full border border-border bg-muted text-foreground\">×</button>\n {/* content */}\n </div>\n </div>\n)}\n\n{/* modal variant — GH-10: --modal-height-offset 3.2rem mobile, width calc(100% - 3rem) mobile */}\n{variant === \"modal\" && (\n <div className=\"absolute inset-0 z-[75]\">\n <button type=\"button\" aria-label=\"닫기\" onClick={() => setVariant(null)} className=\"absolute inset-0 bg-foreground/60\" />\n <div className=\"absolute left-1/2 flex w-[calc(100%-3rem)] -translate-x-1/2 flex-col gap-4 rounded-lg border border-border bg-card px-6 py-6\" style={{ top: \"3.2rem\" }}>\n {/* content */}\n </div>\n </div>\n)}\n```\n\nFull component, including both variant shells, the fixed bar, and the demo state toggle, lives in `code_asset`.\n\n## Checks\n\n- The fixed bottom bar's buttons match EM-01 sizing (`h-[55px]`, `px-[5px]`, `text-lg`, `border`) — do not shrink the tap target below this observed baseline.\n- The price row keeps a size/weight contrast between label and total (EM-02) rather than rendering both at the same size.\n- Neither variant renders a drag handle — both observed sources omit one; dismissal is scrim-tap plus an explicit close button only.\n- Only one option-selection surface opens per interaction; do not stack sheet and modal.\n- Two values in the sources are unconfirmed and must stay labeled as such rather than filled with an assumed number: the sheet's top corner radius, and the modal scrim's opacity (GH-10's source has no scrim opacity value).\n\n## Anti-patterns\n\n- **Treating bottom sheet as the only correct surface for \"select options on mobile\"**: half of what this recipe observed is that it is not — Dawn's quick-add is a centered modal, not a sheet. Defaulting to a sheet without checking the product's own convention re-introduces the assumption this recipe was built to break.\n- **Adding a drag handle because sheets \"usually have one\"**: neither source observed here has one; do not add a grabber affordance that isn't backed by an observed source.\n- **Filling in the unconfirmed radius/opacity values with a guessed number and dropping the \"미확인\" label**: an unobserved value presented as if measured is worse than an explicit gap.\n- **Sizing the fixed bar's buttons off a generic button scale instead of EM-01's 55px height**: the taller-than-default height is deliberate for a thumb-reachable, one-line-of-text buy action, not a component-library default.\n\n## Agent notes\n\n- `prompt_phrases`: \"하단 고정 구매 바 눌렀을 때 옵션 고르는 바텀시트 만들어줘\", \"quick-add를 중앙 모달로 열어줘\", \"모바일 구매 바 버튼 사이즈 실측대로 맞춰줘\"\n- If the product's existing option-selection convention elsewhere is already a modal (or already a sheet), match that instead of picking a variant fresh each time — consistency within one product beats picking whichever source looks nicer per screen.\n- Fallback: if neither `backdrop-blur-sm` nor a `--modal-height-offset`-style CSS variable is available in the target stack, keep the two observed shape decisions (sheet = bottom-pinned no-handle panel with a 75%-opacity scrim; modal = centered no-handle panel with a fixed top offset) and drop only the specific blur/variable mechanics."},{"id":"modal-exit-choreography","name":"Modal Exit Choreography","pattern_group":"overlays","kind":"motion-pattern","status":"draft","surface_refs":["websites","saas-dashboards"],"tokens_used":["color.semantic.surface.base","color.semantic.surface.scrim"],"code_asset":"pending","component_refs":[],"term_refs":[],"source_refs":["https://github.com/Yoodeit/MBN_Hackathon"],"last_verified":"2026-08-09","source_path":"recipes/overlays/modal-exit-choreography.md","body":"## Intent\n\nReact unmounts a modal the instant `open` flips false — the exit animation you wrote never plays. This recipe is the minimal state machine that holds the mount through the closing animation, plus the intro side: a WAAPI clip-path wipe for staged entrances (reduced-motion skips straight to content). Harvested from MBN_Hackathon (K-RABBIT) `LoginModal`/`WallIntro`.\n\n## Anatomy\n\n- **Three-phase state**: `closed → open → closing → closed`. The component tracks `{ phase, prop }` and reconciles the incoming `open` prop **during render** (not in an effect): prop turned false while `open` → enter `closing`; render `null` only in `closed`.\n- **Closing timer with a fallback**: a timeout slightly longer than the exit transition flips `closing → closed` — never rely solely on `transitionend`/`onfinish`, which can be skipped (display:none ancestors, tab switch).\n- **Phase-driven styling**: backdrop and card read `data-phase` and animate in on `open`, out on `closing` — one source of truth, no class juggling.\n- **Escape + backdrop close** call the same `onClose` the owner passed; the component never closes itself silently.\n- **Intro wipe (entrance variant)**: `element.animate([{ clipPath: \"inset(0 100% 0 0)\" }, { clipPath: \"inset(0 0% 0 0)\" }], { duration, easing, fill: \"both\" })` — a curtain drawn left-to-right, then hold → fade → reveal. Gate the whole sequence behind `prefers-reduced-motion` (skip to final state, not a faster animation).\n\n## Code\n\n```tsx\nconst [state, setState] = useState<{ phase: \"closed\" | \"open\" | \"closing\"; prop: boolean }>({ phase: \"closed\", prop: false })\nif (state.prop !== open) {\n setState(open\n ? { phase: \"open\", prop: true }\n : { phase: state.phase === \"open\" ? \"closing\" : \"closed\", prop: false })\n}\nuseEffect(() => {\n if (state.phase !== \"closing\") return\n const t = window.setTimeout(() => setState((s) => (s.phase === \"closing\" ? { ...s, phase: \"closed\" } : s)), 260)\n return () => window.clearTimeout(t)\n}, [state.phase])\nif (state.phase === \"closed\") return null\n```\n\n## States\n\n- Closed: nothing mounted.\n- Open: backdrop + card entered; Escape and backdrop click both close.\n- Closing: still mounted, exit animation playing, input ignored beyond the pending close.\n- Reduced motion: open/close are instant mounts/unmounts — the machine still runs, durations collapse.\n\n## Variants\n\n- **역재생 퇴장** — 등장 모션을 되감아 나간다. 같은 자리로 돌아가는 모달(설정·필터).\n- **방향 있는 퇴장** — 온 방향으로 되돌아 나간다. 목록에서 열린 상세처럼 출처가 화면에 남아 있을 때.\n- **즉시 언마운트(폴백)** — `prefers-reduced-motion`. 스크림도 함께 즉시 사라져야 한다(스크림만 남으면 화면이 잠긴 것처럼 보인다).\n\n## Checks\n\n- Rapid toggling (open→close→open before the timer fires) never strands the modal in `closing` — the render-time reconciliation handles re-entry.\n- Focus returns to the trigger on close (`knowledge/focus-keyboard.md` owns the full modal contract — this recipe only owns the exit timing).\n- The scrim uses the semantic scrim token, not a hardcoded black.\n\n## Anti-patterns\n\n- **`open && <Modal/>` with an exit transition** — the unmount wins; the transition is dead code.\n- **Waiting only on `transitionend`** — a skipped event leaves a ghost modal; the timeout is the guarantee, the event is the optimization.\n- **Animating the entrance but cutting the exit** — asymmetric choreography reads as breakage; if you stage the entrance, close with the same care.\n\n## Agent notes\n\n- 퇴장 모션은 **언마운트를 지연시킨다** — 그동안 배경이 입력을 받으면 안 된다. 포커스 반환은 모션이 아니라 상태 계약이다([[focus-keyboard]]).\n- 스크림과 패널의 퇴장 길이를 다르게 주지 않는다. 어긋나면 닫힘이 두 번 일어난 것처럼 읽힌다.\n- `status: draft` 이고 `code_asset` 이 아직 없다."},{"id":"paper-card-reveal","name":"Paper Card Reveal","pattern_group":"overlays","kind":"visual-treatment","status":"stable","verified_by":"사용자","verified_at":"2026-08-18","promoted_round":"m68","surface_refs":["websites"],"tokens_used":["color.semantic.border.default","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.text.default","color.semantic.text.muted","dimension.radius.lg"],"code_asset":"examples/ui-vocabulary-site/src/components/paper-card-reveal.tsx","component_refs":[],"term_refs":["dialog","card"],"source_refs":["research/2026-08-17-m68-ledger.md","research/2026-08-17-m68-gate-verdicts.md"],"last_verified":"2026-08-18","source_path":"recipes/overlays/paper-card-reveal.md","body":"## Intent\n\nDepth comes from a second solid rectangle, offset and slightly rotated, rather than a blurred shadow, and the card grows from scale 0 over it. The close button repeats the tilt and counter-rotates its icon.\n\nObserved at https://summer-afternoon.vlucendo.com/ (SMA-06, accessed 2026-08-17); ledger `research/2026-08-17-m68-ledger.md`. User gate pass frozen in `research/2026-08-17-m68-gate-verdicts.md`.\n\n## Anatomy\n\n- Two observed presets: modal offsets 8px with .5deg, the info panel 10px with 1deg; both share a 5px radius.\n- Back layer and front card both scale from near zero, but only the back layer carries the offset and rotation.\n- The close button is 32px square, tilted 10deg with a hard 2px offset shadow, and its icon counter-rotates so the glyph stays upright.\n- A blur-shadow toggle sits next to it, because the contrast is the argument.\n\n## Pitfalls\n\n- Mount it open. A reveal demo that starts closed shows an empty stage and gets read as broken.\n- Assign the stage, back layer, and card three different tokens or the layering disappears in one of the two themes.\n\n## States\n\n- 열림 — 뒤 레이어와 앞 카드가 함께 자란다(기본 상태).\n- 닫힘 — 둘 다 scale 0 으로 접힌다.\n- 블러 그림자 — 하드 오프셋 대신 일반 그림자로 대조.\n- 프리셋 — 모달(8px/.5°) 과 정보 패널(10px/1°).\n\n## Variants\n\n- 모달·정보 패널 두 프리셋.\n- 하드 오프셋 / 블러 그림자 두 그림자 문법.\n\n## Code\n\n`examples/ui-vocabulary-site/src/components/paper-card-reveal.tsx` — 단일 파일, 기존 의존성만 쓴다. 파일 머리 주석이 인용한 장부 행 ID 와 관측값·우리 값의 경계를 적는다.\n\n## Checks\n\n- 주 상호작용 1회 실구동으로 상태 변화 확인(렌더 확인으로 닫지 않는다).\n- 라이트·다크 양쪽에서 대비 유지.\n- 언마운트 시 rAF·리스너·옵저버 해제. WebGL 은 컨텍스트를 죽이지 않고 리소스만 해제한다.\n- `npm run lint` 의 `lint:colors` 0 위반.\n\n## Anti-patterns\n\n- 닫힌 상태로 마운트해 빈 무대를 보여 주는 것.\n- 무대·뒤 레이어·앞 카드에 같은 표면 토큰을 써서 층이 사라지게 하는 것.\n\n## Agent notes\n\n오프셋·회전·radius 는 관측값이다. 색은 토큰에서만 나오고 원 사이트의 종이색은 가져오지 않았다.\n\n## Tokens\n\nColors are read from CSS custom properties at runtime or applied as semantic utility classes; no palette from the source site is copied.\n\n- `--background`\n- `--border`\n- `--foreground`\n- `--muted`\n- `--muted-foreground`\n- `--radius`","code_source":"// 출처: research/m68/p2-summer-afternoon.json SMA-06 (\"오프셋+회전 이중 배경 레이어 하드 그림자 리빌\")\n// https://summer-afternoon.vlucendo.com/ App3D-69d7dffb.css 전수 직독 (접근일 2026-08-17)\n// .bg-dark: translate(8px,8px) rotate(.5deg) + scale(.001→1) [모달] / translate(10px,10px) rotate(1deg) [정보 패널]\n// .bg-light: scale(.001→1), 오프셋 없음 · 공통 border-radius: 5px\n// 닫기 버튼: 32×32px rotate(10deg) box-shadow 2px 2px 하드섀도, 내부 svg rotate(-10deg) 역회전\n// visual: 열릴 때 뒤에 어긋나고 기울어진 진한 사각형이 그림자처럼 깔리고, 그 위 밝은 카드가 scale 0에서\n// 자라나며 등장한다 — 블러 없는 단단한(하드) 오프셋 레이어가 종이를 겹쳐 붙인 질감의 핵심이다.\n\nimport { useEffect, useState, type CSSProperties } from \"react\"\n\ntype Preset = \"modal\" | \"panel\"\n\nconst PRESETS: Record<Preset, { offset: number; rotate: number }> = {\n modal: { offset: 8, rotate: 0.5 },\n panel: { offset: 10, rotate: 1 },\n}\n\nconst RADIUS_DEFAULT = 5\n\nexport default function PaperCardReveal() {\n // ⚠ 수리(m68-step8): 이전에는 닫힌 상태로 마운트돼 340px 무대가 빈 채로 보였다\n // (\"캔버스가 비어있음\" — 게이트 보류 사유). 열린 상태로 시작하고, 리빌은 버튼으로 다시 본다.\n const [open, setOpen] = useState(true)\n const [preset, setPreset] = useState<Preset>(\"modal\")\n const [offset, setOffset] = useState(PRESETS.modal.offset)\n const [rotate, setRotate] = useState(PRESETS.modal.rotate)\n const [radius, setRadius] = useState(RADIUS_DEFAULT)\n const [hardShadow, setHardShadow] = useState(true)\n const [reducedMotion, setReducedMotion] = useState(false)\n\n useEffect(() => {\n const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n setReducedMotion(media.matches)\n const handleChange = () => setReducedMotion(media.matches)\n media.addEventListener(\"change\", handleChange)\n return () => media.removeEventListener(\"change\", handleChange)\n }, [])\n\n const applyPreset = (next: Preset) => {\n setPreset(next)\n setOffset(PRESETS[next].offset)\n setRotate(PRESETS[next].rotate)\n }\n\n const transition = reducedMotion ? \"none\" : \"transform 260ms ease, opacity 200ms ease\"\n\n const backLayerStyle: CSSProperties = {\n transform: `translate(${offset}px, ${offset}px) rotate(${rotate}deg) scale(${open ? 1 : 0.001})`,\n borderRadius: `${radius}px`,\n transition,\n opacity: hardShadow ? 1 : 0,\n }\n\n const frontCardStyle: CSSProperties = {\n transform: `scale(${open ? 1 : 0.001})`,\n borderRadius: `${radius}px`,\n transition,\n pointerEvents: open ? \"auto\" : \"none\",\n boxShadow:\n !hardShadow && open\n ? \"0 10px 30px color-mix(in srgb, var(--foreground) 25%, transparent)\"\n : \"none\",\n }\n\n return (\n <div className=\"flex w-full flex-col gap-4\">\n <div className=\"flex flex-wrap items-center gap-3 rounded-md border border-border bg-muted p-3 text-sm text-foreground\">\n <button\n type=\"button\"\n onClick={() => setOpen((v) => !v)}\n className=\"rounded-md border border-border bg-background px-3 py-1.5 font-medium text-foreground hover:bg-muted\"\n >\n {open ? \"카드 닫기\" : \"카드 열기\"}\n </button>\n\n <div className=\"flex items-center gap-1.5\" role=\"group\" aria-label=\"레이어 프리셋\">\n {(Object.keys(PRESETS) as Preset[]).map((key) => (\n <button\n key={key}\n type=\"button\"\n onClick={() => applyPreset(key)}\n aria-pressed={preset === key}\n className={`rounded-md border px-2.5 py-1 text-xs ${\n preset === key\n ? \"border-foreground bg-foreground text-background\"\n : \"border-border bg-background text-muted-foreground hover:text-foreground\"\n }`}\n >\n {key === \"modal\" ? \"모달 (8px / .5°)\" : \"정보 패널 (10px / 1°)\"}\n </button>\n ))}\n </div>\n\n <button\n type=\"button\"\n onClick={() => setHardShadow((v) => !v)}\n aria-pressed={hardShadow}\n className={`ml-auto rounded-md border px-2.5 py-1 text-xs ${\n hardShadow\n ? \"border-foreground bg-foreground text-background\"\n : \"border-border bg-background text-muted-foreground hover:text-foreground\"\n }`}\n >\n {hardShadow ? \"하드 오프셋 레이어\" : \"일반 블러 그림자\"}\n </button>\n </div>\n\n <div className=\"flex flex-wrap gap-5 text-xs text-muted-foreground\">\n <label className=\"flex items-center gap-2\">\n 오프셋 {offset}px\n <input\n type=\"range\"\n min={0}\n max={20}\n step={1}\n value={offset}\n disabled={!hardShadow}\n onChange={(e) => setOffset(Number(e.target.value))}\n aria-label={`오프셋 ${offset}픽셀`}\n />\n </label>\n <label className=\"flex items-center gap-2\">\n 회전 {rotate}°\n <input\n type=\"range\"\n min={0}\n max={5}\n step={0.1}\n value={rotate}\n disabled={!hardShadow}\n onChange={(e) => setRotate(Number(e.target.value))}\n aria-label={`회전 ${rotate}도`}\n />\n </label>\n <label className=\"flex items-center gap-2\">\n 모서리 반경 {radius}px\n <input\n type=\"range\"\n min={0}\n max={20}\n step={1}\n value={radius}\n onChange={(e) => setRadius(Number(e.target.value))}\n aria-label={`모서리 반경 ${radius}픽셀`}\n />\n </label>\n </div>\n\n <div className=\"relative flex h-[340px] w-full items-center justify-center overflow-hidden rounded-lg border border-border bg-muted/40 p-8\">\n {/* 뒤 레이어: 오프셋 + 회전된 진한 사각형 — 블러 없는 하드 그림자 */}\n <div aria-hidden=\"true\" className=\"absolute h-48 w-64 bg-foreground\" style={backLayerStyle} />\n\n {/* 앞 카드 */}\n <div\n aria-hidden={!open}\n className=\"relative flex h-48 w-64 flex-col justify-between border border-border bg-background p-4\"\n style={frontCardStyle}\n >\n <div>\n <p className=\"text-sm font-semibold text-foreground\">종이 카드</p>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n 어긋난 사각형이 블러 그림자를 대신하는 손그림 질감입니다.\n </p>\n </div>\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n aria-label=\"카드 닫기\"\n tabIndex={open ? 0 : -1}\n className=\"ml-auto flex h-8 w-8 items-center justify-center border border-border bg-background\"\n style={{\n transform: \"rotate(10deg)\",\n boxShadow: hardShadow ? \"2px 2px 0 var(--foreground)\" : \"none\",\n }}\n >\n <svg\n viewBox=\"0 0 24 24\"\n width={14}\n height={14}\n style={{ transform: \"rotate(-10deg)\" }}\n className=\"text-foreground\"\n >\n <path\n d=\"M6 6l12 12M18 6L6 18\"\n stroke=\"currentColor\"\n strokeWidth={2}\n fill=\"none\"\n strokeLinecap=\"round\"\n />\n </svg>\n </button>\n </div>\n </div>\n </div>\n )\n}\n"},{"id":"popover-anatomy-variants","name":"Popover Anatomy Variants","pattern_group":"overlays","kind":"visual-treatment","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m62","surface_refs":["commerce"],"tokens_used":["color.semantic.surface.overlay","color.semantic.surface.base","color.semantic.surface.muted","color.semantic.border.default","color.semantic.text.default","color.semantic.text.muted"],"code_asset":"examples/ui-vocabulary-site/src/components/popover-anatomy-variants.tsx","component_refs":["popover"],"term_refs":["popover","dropdown-menu","tooltip"],"source_refs":["amazon","coupang","emart-mall","shopify-dawn","vercel-commerce"],"last_verified":"2026-08-17","source_path":"recipes/overlays/popover-anatomy-variants.md","body":"## Intent\n\nThis recipe is not a replacement for `popover` — it does not define the primitive (trigger, portal, dismiss behavior, focus return; see `popover.md` for that). It documents the **visual anatomy** commerce sites converge on when they skin that primitive: how much shadow, what radius, whether an arrow points at the trigger. Compose the primitive from `popover`, then finish it with the anatomy here.\n\nThe request that produced this recipe was \"the popover looks too plain.\" The observed answer across three large commerce sites and two open-source storefronts was not to add ornament — it was that plainness *is* the convention. Commerce popovers carry almost no decoration; the only thing they reliably do is separate themselves from the stage they float over.\n\n## Anatomy\n\n- **Shadow** — shallow or absent, never a deep drop shadow. Amazon's search autocomplete uses `box-shadow: 0 2px 4px rgba(0,0,0,0.13)`; Shopify Dawn's filter/sort popover uses `0 4px 5px rgba(0,0,0,0.05)` (5% opacity preset); Dawn's quantity-limit popover uses `--shadow-opacity: 0` (no shadow at all). Three points on the same low range, not three unrelated choices.\n- **Radius** — `0px` on the panel itself. Amazon's autocomplete panel and Dawn's popup corner-radius preset both land on zero, even though the surrounding trigger controls (search input, buttons) keep normal rounded corners. The floating panel reads as a distinct, harder-edged layer.\n- **Arrow (optional)** — Amazon and Dawn skip it; position (offset from the trigger) alone signals the relationship. Emart Mall is the outlier: it draws a 15×9px triangle pointing at the trigger. Treat the arrow as a variant toggle, not a default.\n- **Internal density** — tighter than body copy. Amazon's autocomplete suggestion rows are a uniform 35px tall; Emart's option dropdown packs a name and an inline price onto the same row height. The panel earns its compactness by doing less per row, not by shrinking type.\n- **Offset from trigger** — small and fixed, not proportional. Amazon's input-to-panel gap is 1px; Dawn's is `top: calc(100% + 0.5rem)`; Emart's option panel opens at `top: 38px`.\n\n## States\n\n- **shadow: none** — Dawn's quantity-limit popover. Separation from the stage comes entirely from the border and the panel's own background token, not elevation.\n- **shadow: low (5%)** — Dawn's filter/sort popover. The default recommendation when a project has no existing convention: visible enough to read as \"floating\" without competing with modal/dialog elevation.\n- **shadow: amazon (13%)** — Amazon's autocomplete. Slightly stronger, still shallow relative to a typical dialog shadow.\n- **arrow: offset-only** — the majority convention (Amazon, Dawn). Position does the pointing.\n- **arrow: arrow** — the Emart Mall variant. A small triangle glyph anchored to the trigger edge.\n\n## Variants\n\n- **Search autocomplete** — input + submit button trigger, suggestion list panel, 35px rows, radius 0, offset 1px (Amazon).\n- **Sort dropdown with explain popover** — a compact option list (radio-like), plus a secondary popover triggered from inside it that explains the ranking criteria as a label + bullet list (Coupang).\n- **Option dropdown with inline price** — each row pairs a variant name with its price instead of showing price only after selection (Emart Mall).\n- **Quantity-limit / info popover** — no list at all, a single short sentence, no shadow, capped `max-width: 20rem` desktop / `36rem` mobile (Shopify Dawn).\n\n## Code\n\n```tsx\nconst SHADOW_STYLE: Record<ShadowLevel, string> = {\n // GH-08 --shadow-opacity: 0\n none: \"shadow-none\",\n // GH-09 box-shadow 0 4px 5px rgba(0,0,0,.05) — Dawn popup_shadow_opacity 5%\n low: \"shadow-[0_4px_5px_rgba(0,0,0,0.05)]\",\n // AZ-17 box-shadow 0 2px 4px rgba(0,0,0,.13)\n amazon: \"shadow-[0_2px_4px_rgba(0,0,0,0.13)]\",\n};\n\nfunction PopoverShell({ shadow, arrowMode, children, className = \"\" }: PopoverShellProps) {\n return (\n <div className=\"relative mt-2 inline-block\">\n {arrowMode === \"arrow\" && <ArrowGlyph />}\n <div\n // radius 0px 관측 일관: AZ-17 border-radius 0, GH-09 preset 0px\n className={`rounded-none border border-border bg-popover ${SHADOW_STYLE[shadow]} ${className}`}\n >\n {children}\n </div>\n </div>\n );\n}\n```\n\nThe panel uses `bg-popover`, distinct from the stage's `bg-background` — this is load-bearing, not cosmetic. In this token set `--background` and `--popover` sit at different lightness in both themes (light: `oklch(0.985 0 0)` vs `oklch(1 0 0)`; dark: `oklch(0.16 ...)` vs `oklch(0.25 ...)`), so the panel reads as a separate surface even at `shadow: none`. If a project's popover and stage tokens ever resolve to the same value, the zero-shadow, zero-radius variant becomes invisible — border alone will not carry the separation.\n\n## Checks\n\n- Panel background token must differ from the stage background token in both light and dark mode; verify by rendering `shadow: none` and confirming the panel edge is still legible against the stage.\n- Radius stays at `0px` on the panel regardless of the radius used by the trigger control next to it.\n- Shadow, if present, stays in the shallow range (5–13% opacity, 2–5px blur) — do not reach for a dialog-weight shadow just because \"it needs to pop.\"\n- Arrow is an explicit choice, not a default; most commerce references ship without one.\n- Viewport-edge collision (flip/shift) is out of scope for this recipe — no observed row covered it, so it is not implemented in the code asset. Compose `popover`'s Radix collision handling for that behavior instead of inventing one here.\n\n## Anti-patterns\n\n- **Adding ornament to fix \"looks plain\"** — the plainness is the convention across every reference row gathered here (AZ-17–19, CP-E1–E2, EM-12, GH-08–09). Escalating shadow depth or adding a border-radius to make a popover \"feel designed\" moves away from the pattern, not toward it.\n- **Giving the panel the same background token as the stage** — with `radius: 0` and `shadow: none`, this is the one combination that makes the panel disappear. Keep `surface.overlay` distinct from `surface.base`.\n- **Using the arrow as a default** — the majority reference (Amazon, Dawn) relies on offset alone; treat the pointer triangle as an opt-in variant, not the base case.\n\n## Agent notes\n\n- `prompt_phrases`: \"이 팝오버 너무 밋밋해 보여\", \"커머스 사이트처럼 검색 자동완성 드롭다운 만들어줘\", \"옵션 선택 드롭다운에 가격도 같이 보여줘\", \"화살표 달린 팝오버 vs 없는 팝오버\".\n- When a request says a popover \"looks too plain,\" check this recipe before adding decoration — the fix in every reference row was restraint, not embellishment.\n- Compose the interactive primitive (trigger, portal, focus/dismiss) from `popover.md`; use this recipe only for the shadow/radius/arrow/density decisions.\n- Sources: `research/m62-round7/p2-ledger.json` rows AZ-17, AZ-18, AZ-19 (Amazon), CP-E1, CP-E2 (Coupang), EM-12 (Emart Mall), GH-08, GH-09 (`Shopify/dawn@258f00f643`, MIT; `vercel/commerce@3761e52e60`, MIT)."},{"id":"popover","name":"Popover","pattern_group":"overlays","kind":"component","status":"stable","verified_by":"사용자","verified_at":"2026-08-17","promoted_round":"m61","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"],"mobile":{"platforms":{"ios":"popover (regular width) or action sheet (compact)","android":"menu"},"gestures":["tap"],"touch_targets":["trigger","close action"],"safe_area":"none","reduced_motion":"fade·zoom 없이 즉시 표시","modal_depth":1,"port_notes":"Radix 가 처리하던 포커스 반환·외부 탭 해제·Escape 를 네이티브 오버레이 API 가 소유한다. **compact 폭에서 iOS 는 popover 가 아니라 action sheet 로 표현되므로 컨테이너 선택을 다시 한다**"},"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."}]