Claude Cursor Skill

design-engineering

Load when reviewing UI code, designing a component or page layout, picking an easing curve or transition pattern, deciding whether something should animate at all, choosing an avatar/typography/color system, building an OKLCH palette or fixing contrast, writing UI copy or error m

LLM Mart · 0 points · 13 views 32 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agentsorg-design-engineering-skills_design-engineering-81805dc.zip · 278 KB

Install

skills CLI npx skills add https://github.com/AgentsORG/design-engineering/tree/main/skills/design-engineering
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agentsorg-design-engineering@llmmart
Git git clone https://github.com/AgentsORG/design-engineering.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agentsorg/design-engineering collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

design-engineering

This skill is a graph, not a file. The body below is the Map of Content. Follow the [[wikilinks]] only as needed — that's the whole point of progressive disclosure.

How to use this skill

  1. /design-engineering is a router before it is a library: [[skill-router]] resolves the design contract, classifies the phase (undecided → build → refine → check → name), and hands the job to one owner — a node here, a subagent, or an installed companion skill (AgentsORG design, impeccable, HyperFrames, ElevenLabs, transitions-dev, the shadcn CLI). One or two owners, never five.
  2. Route first: [[routing-table]] maps intent → entry node for most single questions. If two intents blur, [[disambiguation]] names the tiebreaker. Multi-cluster jobs follow [[stacking-chains]].
  3. Otherwise scan the MOCs below, pick the cluster, and open its MOC-*.md for the atomic nodes under it.
  4. Read only the atomic nodes that match. Each node is standalone — you don't need siblings.
  5. Before reviewing UI code, also load [[gotchas]] and [[pov]].

If the user asks for a UI code review, use the format defined in [[review-format]] and scan against [[review-checklist]].

Philosophy — when, where, and why polish matters

How to think about taste, delight, and the difference between marketing and product UI.

  • [[MOC-philosophy]] → [[taste-is-trained]], [[unseen-details-compound]], [[beauty-is-leverage]], [[delight-impact-curve]], [[feeling-right]], [[marketing-vs-product-ui]], [[states-are-the-work]], [[data-is-content]], [[dependency-discipline]], [[pointing-beats-describing]], [[articulate-precisely]]

Motion — when something should move, and how

The largest cluster. Animation is the most overused tool in modern UI; this cluster tells you when not to animate as much as when to.

  • [[MOC-motion]] → [[animation-decision-framework]], [[easing-curves]], [[duration-table]], [[spring-animations]], [[transform-opacity-only]], [[performance-discipline]], [[transform-mastery]], [[clip-path-tricks]], [[never-scale-from-zero]], [[gesture-momentum]], [[stagger-choreography]], [[prefers-reduced-motion]], [[fly-not-teleport]], [[responsive-feedback]], [[sonner-principles]], [[debugging-animations]], [[lerp-breathing]], [[morphing-icons]], [[shared-letter-morph]], [[hover-default-imperative]], [[tray-rules]], [[css-conventions]], [[launch-video-seams]]

Transition techniques — Jakub Antalik's catalog

A subset of motion focused on canonical transitions for common UI archetypes. Where the Motion MOC teaches principles, these nodes teach implementation. From transitions.dev.

  • [[cross-blur-transitions]] — Pair opacity 0↔1 with filter: blur(2px) ↔ 0 to mask imperfect crossfades.
  • [[compose-subtract-asymmetry]] — Enter with more properties than exit. Disappearance feels soft, not reversed.
  • [[distance-falloff-propagation]] — Per-element lift via lift * pow(falloff, distance) for grouped hover.
  • [[multi-segment-shake]] — Form-error shake at 0%, 28.57%, 57.14%, 78.57%, 100% over 280ms.

Sound — when an interface should be heard, and how

The sense the web forgot. Product UI is silent by default; a launch video is the reverse. This cluster decides which, designs one material family, syncs it to motion, and generates the files — ElevenLabs on demand, or open-weight / procedural / CC0 without a key.

  • [[MOC-sound]] → [[sound-decision-framework]], [[sound-motion-sync]], [[sound-palette]], [[sound-spec]], [[sound-playback-web]], [[sound-generation-elevenlabs]], [[sound-generation-open-source]], [[launch-video-sound]], [[sound-from-motion]]

SVG — creating, animating, morphing, and vectorizing

The only image format that is also an interface. Author it on the token system, move it with the engine its home allows, morph it by the command-count rule, and turn flat clips into editable mascots.

  • [[MOC-svg]] → [[svg-creation]], [[svg-animation]], [[svg-path-morphing]], [[video-to-vector-pipeline]]

Typography — humanizing text

The font defaults of the AI era are tells. Better choices and the rules around them.

  • [[MOC-typography]] → [[typography-humanity]], [[line-length-tracking]], [[type-scale-and-rhythm]], [[line-behavior]]

Surface — color, shadow, radius, dark mode, imperfection

The "background" choices that most decks of guidelines skip. They're load-bearing for feel.

  • [[MOC-surface]] → [[color-monochromatic]], [[color-scales-oklch]], [[dark-mode]], [[shadows-whisper]], [[depth-and-nesting]], [[border-radius]], [[visual-imperfection]], [[contrast-and-color-scheme]]

Layout — page-level grids, viewports, sticky chrome, URL-as-state, marketing surfaces

The container, not the component. Marketing especially benefits from custom-per-viewport thinking; product apps benefit from URL-driven state.

  • [[MOC-layout]] → [[viewport-custom-design]], [[sticky-and-scroll-tells]], [[url-as-state]], [[marketing-surface-rules]]

Components — buttons, hovers, states, cards, forms, avatars, icons, a11y, copy, APIs

The atoms users actually touch. Their behavior is where craft lives.

  • [[MOC-components]] → [[hover-states-subtle]], [[empty-loading-states]], [[icon-systems]], [[cards-design]], [[forms-validation]], [[forms-behavior]], [[touch-and-focus]], [[ui-polish-pass]], [[component-api-design]], [[avatar-systems]], [[interaction-personality]], [[accessibility-baseline]], [[optimistic-updates]], [[copy-voice]]

Anti-patterns — what AI-generated UI gives away

What looks "AI default" and what to do instead. High-value because it's about deletion, not addition.

  • [[MOC-anti-patterns]] → [[ai-default-tells]], [[unslop-pass]], [[content-authenticity]], [[copy-tells]], [[code-tells]]

Meta — review format, design.md, Agentation, evals, per-installer files

Procedural rules and growing files. [[review-format]] is mandatory when doing UI code reviews.

  • [[skill-router]] — What /design-engineering does first: resolve the contract, classify the phase, hand off to one owner — a node, a subagent, or an installed companion (AgentsORG design, impeccable, HyperFrames, ElevenLabs, transitions-dev, shadcn CLI).
  • [[routing-table]] — Intent → entry-node router with the four postures (build / judge / decide / name). Open this before anything else on a single question.
  • [[prototype-picker]] — Undecided? Three to five genuinely different versions behind a live picker, numbers on controls, then write the decision and delete the harness.
  • [[build-a-tool]] — "Closer, but not quite" twice? Stop re-prompting; diagnose the gap and build the small tool that produces the artifact.
  • [[vibe-to-generator]] — Only a mood, no spec? Research the visual language into rules, then a seeded generator with knobs and a save button.
  • [[design-system-docs]] — Writing a DESIGN.md or docs folder an agent can follow: inventory the code, when/alternative, closed variant sets, correct/incorrect examples, verify by prompting.
  • [[skill-writing-rules]] — Encode process, write the why, be strict, earn every line, one aspect per skill, test by running. Governs every node here and [[pov]].
  • [[design-benchmarks]] — DesignBench's generation / edit / repair tasks and Design Arena's pairwise votes, and this skill's own eval suite that borrows both.
  • [[disambiguation]] — The questions that blur together (four motion questions, three dark-mode owners, two layout-shift owners) and which node owns each.
  • [[stacking-chains]] — Ordered node chains for multi-step jobs (new screen, feel-better pass, design system, marketing site, a11y pass).
  • [[review-format]] — Required output format for UI critiques (Before | After | Why markdown table).
  • [[review-checklist]] — Thirteen-row audit to scan against before signing off on a UI review.
  • [[design-vocabulary]] — The precise word for each design phenomenon and where the skill goes deeper. The lexicon behind [[articulate-precisely]]; seeded by Index (Emil Kowalski & Glenn Carstens-Peters).
  • [[using-design-file]] — How to consume a project's .design contract (schema design.v1). Machine-readable tokens, committed intent, executable policy — and it outranks this skill's defaults.
  • [[using-design-md]] — How to consume a project's DESIGN.md (Google Labs spec). Tokens are normative; prose is context.
  • [[agentation-workflow]] — Install and use Agentation for click-to-annotate design review. Two-session critique-then-fix workflow.
  • [[cross-model-testing]] — How to test description / routing changes across GPT, Claude Opus, and Claude Sonnet.
  • [[gotchas]] — lived failures, appended as the agent trips up.
  • [[pov]] — author/installer's opinions and taste calls that override defaults. Edit this when you fork.
  • [[animations-dev-curriculum]] — external pointer to Emil's course; don't duplicate.

Workflow subagents — sibling agents/ directory

Nine narrow-purpose subagents live in agents/ next to this file. Spawn one when its specific workflow matches the user's ask. Each subagent loads its own slice of the graph and returns to the main agent with a structured result.

  • [[ui-reviewer]] — Runs the [[review-format]] table + [[review-checklist]] audit on a UI snippet.
  • [[motion-auditor]] — Animation/transition critique against the motion cluster + transitions.dev nodes.
  • [[anti-pattern-scanner]] — Scans for AI-default tells and content-authenticity misses.
  • [[agentation-fix-loop]] — Session-2 fix side of [[agentation-workflow]]. Reads MCP annotations and applies fixes.
  • [[design-md-consumer]] — Reads a project's DESIGN.md and threads its tokens through generated UI per [[using-design-md]].
  • [[pov-curator]] — Helps the installer fork [[pov]] and append to [[gotchas]] after a real failure.
  • [[sound-designer]] — Runs the sound cluster end to end: decide, palette, generate (scripts/sound-family.mjs for a product family, scripts/sound-sheet.mjs for a video stem derived from the motion), spec, wire up. Returns a sound map.
  • [[svg-creator]] — Authors or refactors an SVG asset: grid, named groups, token colors, SVGO with the right flags, accessible name.
  • [[svg-animator]] — Animates an SVG or builds a vector flipbook from frames (scripts/svg-flipbook.mjs), engine chosen by where the file lives.

See agents/README.md for the full directory and selection guide.

Identity — SOUL.md

The repo also ships a SOUL.md at root — voice, stance, and taste lineage for any agent operating in this skill. AGENTS.md is the "what/how"; SOUL.md is the "who/why". Read it once per session if your harness doesn't auto-inject it.

Tax check

Every sentence above costs tokens in every session. If you find yourself adding a sentence, ask: would the agent get this wrong without it? If no, delete it. If yes, it belongs in an atomic node, not here.

— HKTITAN, 2026.

Files (design-engineering)
  • agents
    • README.md 401 B
      ---
      title: agents
      summary: Subagents moved to the repo root for plugins CLI and Cursor plugin discovery.
      tags: [meta, agents, subagents]
      ---
      
      # Subagents (relocated)
      
      Workflow subagents now live at the repo root so `npx plugins add` and per-host plugin manifests can discover them under `agents/`.
      
      See [agents/README.md](../../../agents/README.md) for the directory index, selection guide, and spec.
      
  • evals
    • design-bench.jsonl 3.7 KB · in bundle
    • loading.jsonl 4.8 KB · in bundle
    • progressive-reads.jsonl 8.1 KB · in bundle
    • README.md 3.2 KB
      # Evals
      
      Per [Perplexity's skill-building guide](https://research.perplexity.ai/articles/designing-refining-and-maintaining-agent-skills-at-perplexity), evals are **Step 0** — write them before the skill, run them when the skill changes, paste them into PRs that touch the description.
      
      This folder ships starter evals. Forks should add their own.
      
      ## What lives here
      
      | File | Format | Tests |
      |---|---|---|
      | `loading.jsonl` | one query per line | Does the agent load this skill on a query that should trigger it? Does it *not* load on a query that shouldn't? |
      | `progressive-reads.jsonl` | one query per line | Once the skill is loaded, does the agent open the right atomic node for the query? |
      | `end-to-end.md` | freeform | A handful of realistic review/design tasks with the expected gotchas the agent should cite. Run manually. |
      
      ## `loading.jsonl` format
      
      ```jsonl
      {"query": "<user query>", "should_load": true, "reason": "<why>"}
      {"query": "<user query>", "should_load": false, "reason": "<adjacent skill that should load instead>"}
      ```
      
      ## `progressive-reads.jsonl` format
      
      ```jsonl
      {"query": "<user query>", "expected_nodes": ["<node-basename>", ...], "forbidden_nodes": ["<node>", ...]}
      ```
      
      `expected_nodes` are nodes the agent must open. `forbidden_nodes` are sibling-like nodes it should *not* open (catches over-eager retrieval).
      
      ## How to run them
      
      There's no automated runner shipped here. Two ways to use the files:
      
      1. **Manual** — open Claude Code / Cursor / your agent, paste a query from `loading.jsonl`, check whether `design-engineering` loads. Paste a query from `progressive-reads.jsonl`, check which files the agent reads.
      2. **Custom harness** — build a wrapper around the Anthropic SDK / OpenAI SDK that submits each query, captures the loaded skills (from system prompt inspection) and read tool calls, and asserts against the JSONL expectations.
      
      A reference harness lives at [anthropics/claude-skill-evals](https://github.com/anthropics/claude-skill-evals) (community project — link may rot; check `awesome-agent-skills` lists for current best).
      
      ## Cross-model
      
      Run every eval against at least **Claude Opus**, **Claude Sonnet**, and **GPT-4-class**. Sonnet and GPT route descriptions differently — what loads on one may not load on the other. See [[meta/cross-model-testing]] for the full guidance.
      
      ## When to update
      
      - **Always**, if you change `SKILL.md`'s `description`. Description changes shift routing; you need evidence the change didn't break recall on existing positive queries or introduce false positives.
      - When you add a new theme folder, add 2+ rows to `loading.jsonl` covering queries that should resolve to that theme.
      - When you add a new atomic node, add 1 row to `progressive-reads.jsonl` covering a query the node should answer.
      
      ## Gotcha
      
      These evals test *routing* and *retrieval* — they don't test whether the node content is correct. Content correctness is human-reviewed via the PR process. Don't try to automate taste.
      
      ## Sources
      
      - Perplexity Agent Skills team — "Step 0: Write the evals" + "Eval suites" sections of [the guide](https://research.perplexity.ai/articles/designing-refining-and-maintaining-agent-skills-at-perplexity).
      - Related: [[meta/cross-model-testing]], `CONTRIBUTING.md` PR checklist.
      
  • references
    • anti-patterns
      • ai-default-tells.md 4.2 KB
        ---
        title: ai-default-tells
        summary: Purple gradients, Inter, pure black dark mode, Lucide icons, lorem ipsum. The full list.
        tags: [anti-patterns, ai-defaults]
        ---
        
        # AI-default tells
        
        The frame: *could this UI have been generated in 30 seconds with a prompt?* If yes, fix it. This is the consolidated list of "tells" that signal AI-default origin.
        
        ## Visual
        
        | Tell | Fix | Linked node |
        |---|---|---|
        | Purple-indigo-fuchsia gradient on hero | Monochrome with one true accent | [[color-monochromatic]] |
        | Inter or SF Pro on a marketing page | Pangram, Geist, Displaay, or similar | [[typography-humanity]] |
        | Pure `#000` dark mode background | `#18181b` with subtle hue | [[dark-mode]] |
        | Pure `#ffffff` text on dark | rgba(255,255,255,0.92) | [[dark-mode]] |
        | Bootstrap-default shadow `0 2px 8px rgba(0,0,0,0.1)` | Layered shadows at 4–6% opacity, `#111` base | [[shadows-whisper]] |
        | Lucide icons (the popular ones — `chevron-down`, `arrow-right`) | Phosphor, Hugeicons, Tabler | [[icon-systems]] |
        | Gradient button backgrounds with no reason | Solid color + subtle shadow | [[hover-states-subtle]] |
        | Same border-radius everywhere | Intentional scale | [[border-radius]] |
        | Asymmetric grid on a *product* page | Reserve asymmetry for marketing | [[marketing-vs-product-ui]] |
        
        ## Layout
        
        | Tell | Fix |
        |---|---|
        | Hero + 3 feature cards + FAQ section | Custom layout that matches your story |
        | Centered single column from top to bottom | Vary section width based on content type |
        | Identical card pattern for unrelated content | Different layouts for different content |
        | "Trusted by" with 6 grayscale logos | Optional, or skip entirely |
        | Every page is the same template scaled | Per-page custom design |
        
        ## Content
        
        | Tell | Fix |
        |---|---|
        | Lorem ipsum or "Coming soon" | Real copy or remove the section |
        | "100+ customers" | "127 customers" (specific) |
        | Generated marketing copy ("Unleash your potential") | Hand-written, specific |
        | Stock photography | Real screenshots or no images |
        | Empty placeholder names ("John Doe", "Acme Inc") | Real-sounding examples or actual data |
        
        ## Animation
        
        | Tell | Fix | Linked node |
        |---|---|---|
        | Crossfading icons | Transform/rotate single icon | [[fly-not-teleport]] |
        | Same-easing animations everywhere | Custom cubic-bezier scale | [[easing-curves]] |
        | Loading spinner with no eta | Skeleton, progress bar, or nothing | [[empty-loading-states]] |
        | Scroll-driven hero parallax on product UI | Reserve for marketing | [[marketing-vs-product-ui]] |
        | Bouncy spring on a daily-use button | Subtle, fast easing | [[delight-impact-curve]] |
        | Animation on `:hover` for touch devices | Gate with `@media (hover: hover)` | [[hover-states-subtle]] |
        
        ## Sound
        
        | Tell | Fix | Linked node |
        |---|---|---|
        | A click on every button, a whoosh on every transition | Sound on daily and rare moments only; silence elsewhere | [[sound-decision-framework]] |
        | Recognizable stock-pack sounds (Kenney raw, the Discord ping) | One material, one generated or re-pitched family | [[sound-palette]] |
        | "Futuristic UI blip" with reverb tail | Dry, short, a physical material | [[sound-spec]] |
        | Sound on by default, no toggle | Off by default, discoverable persisted toggle | [[sound-playback-web]] |
        | Music bed hiding unsynced hits in a launch video | Transients on contact frames, bed last or not at all | [[launch-video-sound]] |
        
        ## Microcopy
        
        | Tell | Fix |
        |---|---|
        | "Get started" as primary CTA | Specific to action: "Create project", "Try it" |
        | "Awesome!" success message | Quieter: "Saved.", "Done." |
        | "Oops!" error message | Plain: "Couldn't save. Try again." |
        | AI-generated tooltip phrasing ("Click here to view...") | Imperative, short: "View details" |
        
        ## When to apply
        
        Use this as a **review checklist** when auditing a UI that "feels generated." Most pages have 3–5 tells from this list. Removing them transforms the feel.
        
        ## Gotcha
        
        This list is not anti-AI — it's anti-default. Many AI-generated UIs are excellent because the prompter knew to specify these things. The tells appear when no one specified anything. The fix is opinion, not avoidance of generation.
        
        ## Sources
        
        - guidelines.sh — the bulk of this list.
        - Benji Taylor — content authenticity, real data.
        - Emil Kowalski — animation tells.
        
      • code-tells.md 4.2 KB
        ---
        title: code-tells
        summary: Generated UI code is rarely buggy and still costs you — narrated comments, catch theater, silent fallbacks, casts that delete compiler errors, V2 names, one-call wrappers, dead scaffolding, celebratory logs, weakened tests, drive-by diffs. The default fix is deletion.
        tags: [anti-patterns, code, review, generated]
        ---
        
        # Code tells
        
        Nobody flags generated code for one bad line; they recognize the accumulation: a comment narrating every statement, a try-catch around code that can't throw, a fallback to an empty array, a helper that exists two files over. Models were trained where code that *runs* wins, so they over-defend, over-comment, and over-scaffold — everything that keeps a snippet alive in isolation makes it slop inside a codebase. It compiles, it passes, and it buries intent, hides real failures, and doubles what the next reader has to hold. This node is the pass over any generated UI change, and the default fix for almost every item is deletion.
        
        ## The pass
        
        Scan in order — comments, defense, types, naming, structure, logging, tests, scope. Delete first; rewrite only where behavior must survive. When removing a cast, catch, or fallback forces a choice, choose the **loud failure** (a throw, a failed build) over the quiet wrong answer. Match the room: mirror the surrounding codebase's comment density, naming, and error idiom.
        
        ## Comments
        
        One test: **does the comment state something the code cannot?** A constraint, a unit, a tradeoff, the bug it works around — keep. Narration (`// increment the counter`), reviewer notes (`// Updated to use the new API`), section dividers, docstrings that restate the signature, commented-out code, file-level summaries — delete. When editing a region, re-read every comment in it; the ones the edit invalidated now lie.
        
        ## Defensive theater
        
        - Null checks against states the types or the lines above already guarantee. Fix the type once instead.
        - Catch-log-rethrow, empty catches, `catch (e) { console.error(e) }` that continues as if it succeeded. Handle errors once at a real boundary with context, or don't catch.
        - **Silent fallbacks** — `catch { return [] }`, `?? default` masking a failed fetch, a simpler backup path. The most dangerous item on the list: tests stay green while the primary path is broken. Fail loudly unless degraded behavior is a product decision, and then make it visible.
        - Validation repeated at every layer; `x !== null && x !== undefined && x !== ''` where one check suffices.
        
        ## Types, names, structure
        
        - `as any`, `as unknown as X`, `@ts-ignore`, a `!` that silences instead of fixes: the compiler error was information. A cast survives only with a comment stating the invariant.
        - `data`, `result`, `item`, `handler`: name what the value *means* (`unpaidInvoices`, `retryDelayMs`). `fetchDataV2`, `EnhancedButton`, `utils-new.ts`: replace the old thing and take its name; a deprecated alias only for published public API.
        - One-call wrappers, options objects with one caller, interfaces with one implementation, duplicate helpers the model didn't search for, unrequested compatibility shims, scaffolding from an abandoned approach, unused imports, `TODO: implement error handling` stubs.
        
        ## Logging, tests, scope
        
        `console.log('here')`, entry/exit logs, and `✅ Successfully initialized!` are debug leftovers and ceremony; log what an operator needs in the repo's style, or nothing. Never delete, skip, or weaken a test to pass it; a test that reproduces the implementation's calculation passes when both are wrong — assert an independently known outcome. Keep the diff to what the task required; propose unrelated cleanup separately.
        
        ## When to apply
        
        Before opening a PR on generated UI code, after any agent-written change, when a diff "reads like Copilot wrote it". Pair with [[review-format]] — a code tell is a row only when it changes what ships.
        
        ## Gotcha
        
        "Matching the room" cuts both ways. A repo with dense comments and defensive checks by convention is not slop; deleting to a standard the codebase doesn't hold is a drive-by change, which is item ten.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on generated code, distilled by HKTITAN.
        - Related: [[review-format]], [[dependency-discipline]], [[unslop-pass]].
        
      • content-authenticity.md 4.8 KB
        ---
        title: content-authenticity
        summary: Real data, specific odd numbers, written copy, real photos. The opposite of lorem-ipsum and generated marketing tropes.
        tags: [anti-patterns, content, copy]
        ---
        
        # Content authenticity
        
        The fastest way to detect AI-generated UI isn't the visual style — it's the content. Lorem ipsum, rounded numbers ("100+ customers"), placeholder names ("John Doe"), and generic marketing copy are dead giveaways.
        
        ## What inauthentic content looks like
        
        ### Lorem ipsum or "Coming soon"
        
        - "Lorem ipsum dolor sit amet…" anywhere in a shipped product.
        - Headings like "Section Title" or "Description goes here."
        - Placeholder buttons labeled "Click here" or "Learn more →" with no clear destination.
        
        The fix: write the real copy. If you don't know what the real copy is, the design isn't ready.
        
        ### Rounded numbers
        
        - "100+ customers"
        - "1000+ projects created"
        - "50,000+ downloads"
        - "Hundreds of integrations"
        
        These read as approximations because they *are* approximations. The fix is specificity:
        
        - "127 customers"
        - "1,243 projects created"
        - "52,841 downloads this month"
        - "26 integrations" (not "Hundreds" — be honest)
        
        Specific numbers feel real. They suggest the team actually looks at the data. They're harder to fake because they imply a verifiable source.
        
        ### Placeholder names
        
        - "John Doe" / "Jane Doe"
        - "Acme Inc." / "Acme Corp"
        - "user@example.com"
        - Stock-photo headshots with generic names
        
        The fix: real names where possible. Anonymized real names where not ("Alex from Stripe"). Generic placeholders erode trust because they say "we couldn't be bothered to find real examples."
        
        ### Generated marketing copy
        
        The AI-default marketing voice:
        
        - "Unleash your potential."
        - "Transform your workflow."
        - "Take your X to the next level."
        - "Built for teams that ship."
        - "The all-in-one solution for…"
        
        All of these are correct English sentences that say nothing. The fix is specificity, again:
        
        - "Send 12 emails in the time it used to take to send one."
        - "Saves our analytics team 4 hours a week on dashboard maintenance."
        - "Built for 3-person startups, not 300-person enterprises."
        
        Concrete claims, real benefits, specific numbers. If the marketing copy could appear on any product's homepage, it's worthless.
        
        ### Stock photography
        
        Stock photos of "diverse business team smiling at laptop" are a tell. Better options:
        
        - **Real screenshots** of your product. Better than any photo at communicating what you do.
        - **Real team photos** for the about page. Imperfect, in-office, not posed.
        - **No photo at all.** A clean layout with strong typography often beats stock imagery.
        - **Custom illustrations** if you have the budget. Memorable; can't be replicated.
        
        ### Generic testimonials
        
        Bad testimonials:
        
        > "This product changed our company." — John Doe, CEO
        
        Good testimonials:
        
        > "We went from 12 deploys per week to 47 in the first month after switching. Our SRE team finally took a Friday off." — Sarah Chen, Engineering Manager, Vercel
        
        The good one is specific, attributable, and has the voice of a real person (verbal tics, partial thoughts, real metrics).
        
        ### Missing context signals
        
        Real products show evidence of ongoing human attention:
        
        - **Timestamps** on changelog entries.
        - **Real version numbers** (`v4.7.2`, not `v1.0` forever).
        - **Recent dates** in "last updated" indicators.
        - **Active social proof** ("Used by 847 customers as of this week").
        
        Their absence signals the page hasn't been touched. Their presence signals someone is home.
        
        ## When this matters most
        
        - **Landing pages and marketing copy.** The first impression depends entirely on the content's authenticity.
        - **Empty states.** "Add your first project" beats "No data."
        - **Error states.** "Couldn't reach our API. Trying again in 4s…" beats "Error occurred."
        - **About pages, team pages, changelogs.** These exist to humanize the product.
        - **Demo / staging environments.** Realistic data here trains the team to think about real-world cases.
        
        ## When NOT to apply
        
        - **Non-customer-facing internal tools.** Lorem ipsum in an internal admin panel is fine if the deadline matters.
        - **Early prototypes** where the visual is the focus and copy will land later. Just don't ship the prototype as the product.
        
        ## Gotcha
        
        "Authentic" doesn't mean "verbose." Real copy is *specific*, not *long*. A 4-word real headline beats a 12-word generic one.
        
        Also: GDPR and similar regs may limit using real customer data. Use opted-in real customer quotes, fictional-but-realistic examples (clearly marked), or your own team as examples. Never fake testimonials.
        
        ## Sources
        
        - guidelines.sh — Content Authenticity category (real data, specific odd numbers, written copy, genuine photography, testimonials, timestamps).
        - Related: [[ai-default-tells]], [[interaction-personality]], [[marketing-vs-product-ui]], [[empty-loading-states]].
        
      • copy-tells.md 3.9 KB
        ---
        title: copy-tells
        summary: The statistical habits that mark prose as generated — model vocabulary, "not just X but Y", the rule of three, false ranges, stacked hedges, em dashes, bold inflation, generic conclusions, chatbot residue — and the two tests every product sentence must pass.
        tags: [anti-patterns, copy, writing, microcopy, marketing]
        ---
        
        # Copy tells
        
        Nobody spots generated prose from one mistake; they spot an accumulation of statistical habits. Each is defensible alone; together they are a watermark. [[copy-voice]] says how UI copy should sound. This node is what to strip from any text a person will read — microcopy, marketing pages, docs, release notes, README — in order: words, sentences, punctuation, claims. Then put a voice back, because clean-but-voiceless is its own tell.
        
        ## Words
        
        | Slop | Write instead |
        |---|---|
        | delve into | look at |
        | crucial, pivotal | important, or name the consequence |
        | leverage, utilize | use |
        | facilitate | help |
        | enhance | improve, or the specific change |
        | showcase, underscore | show |
        | landscape, tapestry | field, mix, or delete the sentence |
        | additionally | also |
        | in the event that | if |
        | serves as, boasts, offers | is, has |
        
        Delete filler on sight ("in order to", "it is important to note that"). Cut adverbs or fix the verb they prop up ("runs quickly" → the number). Promotional adjectives (seamless, robust, stunning, vibrant) get neutral replacements — describe the thing and let the reader supply the adjective. Metaphor jargon (substrate, wedge, north star, flywheel, paradigm) costs credibility when the plain word exists.
        
        ## Sentences
        
        - **"Not just X, but Y"** — state Y. If Y can't stand without the runway, Y is the problem.
        - **Break the rule of three.** Count the real items; write two or five.
        - **Kill false ranges** ("from startups to enterprises") — list the things or name the one that matters.
        - **Repeat the word.** Cycling synonyms for one thing is a school-essay habit; the right word repeated is how a reader tracks the subject.
        - **One idea per sentence; name the actor.** Passive voice is grammatically safe and hides who does what.
        - **Hedge once at most.** "Could potentially possibly" is a model avoiding commitment.
        
        ## Punctuation and formatting
        
        No em dashes (a period or a comma, not a different interrupter that keeps the rhythm). Colons introduce lists, not connect clauses. Bold is for scanning, not emphasis inflation — and the bold-label-plus-colon list that restates its own line is the strongest formatting tell there is. Sentence case headings. No decorative emoji. Straight quotes in source prose; curly quotes belong in rendered UI copy ([[line-behavior]]).
        
        ## Claims
        
        Puffery ("a testament to", "sets the stage for") gets replaced by what happened. Name the source or cut the claim. Delete the trailing "-ing" clause that bolts a conclusion onto a fact ("…, ensuring reliability"). No generic conclusions ("exciting times ahead"). No chatbot residue ("I hope this helps!"). And the two tests for any product sentence: *can it be restated as a concrete instruction, fact, or number?* and *could it appear unchanged in another product's docs?* Fail either, cut it.
        
        ## Putting a voice back
        
        Have a reaction (real reactions are mixed). Vary the rhythm. Use "I" when it fits. Be specific over evocative — the specific image can't be produced by averaging. Let a little mess in: a tangent, an admission of uncertainty.
        
        ## When to apply
        
        Any generated text a person will read; any marketing headline (the competitor paste test); every [[unslop-pass]]; error messages and empty states alongside [[copy-voice]].
        
        ## Gotcha
        
        The pass is not "shorter". A sentence stripped to nothing but nouns reads as generated too. The target is a sentence a specific person would say about this specific product.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on writing, distilled by HKTITAN.
        - Related: [[copy-voice]], [[content-authenticity]], [[unslop-pass]].
        
      • MOC-anti-patterns.md 1.5 KB
        ---
        title: MOC-anti-patterns
        summary: What "AI default" looks like, and what to do instead.
        tags: [moc, anti-patterns]
        ---
        
        # MOC — Anti-Patterns
        
        The frame: *could this have been generated in 30 seconds with a prompt?* If yes, fix it.
        
        This MOC is short on purpose — most anti-patterns are subtle enough to live as one consolidated reference. When in doubt, load this one.
        
        ## Nodes
        
        - [[ai-default-tells]] — Purple gradients, Inter/SF Pro, bootstrap card layouts, lorem ipsum, pure black dark mode, default Lucide icons, gradient buttons without reason, hero + 3-cards + FAQ marketing pages, stock UI sounds. The full list.
        - [[unslop-pass]] — The procedure: squint, subtract first, sweep color → type → layout → decoration → icons → copy → motion, put a point of view back, audit for survivors.
        - [[content-authenticity]] — Real data not lorem. Specific odd numbers ("127 customers", not "100+"). Written copy that sounds like a person. Real photos. Dates with context.
        - [[copy-tells]] — The statistical habits of generated prose (model vocabulary, not-just-X, rule of three, em dashes, puffery, chatbot residue) and the two tests every product sentence must pass.
        - [[code-tells]] — Narrated comments, catch theater, silent fallbacks, casts, V2 names, dead scaffolding; the default fix is deletion.
        
        ## Cross-cluster
        
        - Specific defaults are addressed in their own MOC: [[color-monochromatic]] for color, [[typography-humanity]] for fonts, [[dark-mode]] for `#000`, [[icon-systems]] for Lucide, [[sound-palette]] for stock-pack sounds.
        
      • unslop-pass.md 4.8 KB
        ---
        title: unslop-pass
        summary: The process for a screen that looks generated — squint, subtract first, sweep color → type → layout → decoration → icons → copy → motion, then put a point of view back in, then audit for survivors. Fifty median choices compound into a recognizable one.
        tags: [anti-patterns, ai-defaults, process, review]
        ---
        
        # The unslop pass
        
        Nobody can point at the one element that makes a screen look AI-generated; they recognize the accumulation. The cause is mechanical: a model reproduces the statistically most common pattern for every decision on the page at once, and fifty median choices don't average into a median design — they compound into a recognizable one. [[ai-default-tells]] is the catalog of tells. This node is the *procedure*, in two halves: strip the tells, then put a point of view back in. The second half is not optional; a screen scrubbed of tells with no opinionated choice is still generic, just more quietly.
        
        ## The pass
        
        1. **Squint.** Could this screen belong to any of ten thousand products? Find what ties it to *this* one. If the answer is "the logo", everything else is up for review.
        2. **Subtract before you restyle.** Walk every element — icon, line, badge, caption, container, word — and ask *what breaks if this is gone?* If nothing, delete it. "It adds visual interest" and "it fills the space" are the reasons the model put it there. Run the walk twice: deletions expose each other, because an element that survived round one was often only balancing something you just removed. Deletion is free, cannot introduce new tells, and gives every survivor hierarchy for nothing.
        3. **Sweep in order**: color, typography, layout, decoration, iconography, copy, motion. Color first because it is the loudest tell; a page survives a common layout, not indigo-to-purple.
        4. **Put a point of view back in** (below).
        5. **Final read**: "What here would make a designer suspect a model built this?" Fix. Repeat until nothing.
        
        ## What the sweep catches
        
        - **Color** — unchosen indigo/violet; more than one gradient, or one with no job; dark-and-neon as a "premium" costume. Derive a palette from the domain, the brand, or the content ([[color-scales-oklch]]).
        - **Typography** — gradient text (`bg-clip-text`); the default stack shrugged into place (`Inter font-black tracking-tight`, centered). One distinctive type decision per project does more to de-slop a page than any other single change ([[typography-humanity]]).
        - **Layout** — the hero every model builds (pill badge, centered headline, one-line subhead, two buttons, orb); three icon cards; the bento grid as three-cards-with-better-kerning. The tell is layout chosen before content: let one feature go full-width with a real screenshot, six small ones become a list, unequal features get unequal space.
        - **Decoration** — glass with nothing behind it; blurred orbs and blobs behind the hero; border *and* shadow *and* ring *and* gradient on one card; `rounded-2xl` as the answer to every radius ([[depth-and-nesting]]).
        - **Iconography** — emoji as feature markers; the sparkle badge on every AI feature. One real icon set, or none ([[icon-systems]]).
        - **Fabricated proof** — "Trusted by 10,000+" on an unlaunched product, stock-avatar testimonials, a logo wall nobody agreed to. Real numbers and named quotes, or cut the section; an honest gap beats fake proof ([[content-authenticity]]).
        - **Copy** — a headline a competitor could paste unchanged is a visual element that says nothing ([[copy-tells]]).
        - **Motion** — `fade-in-up` on every block, `hover:scale-105` on cards that aren't clickable, pulsing blobs ([[marketing-surface-rules]]).
        
        ## Looking designed by someone
        
        - Derive the palette from something real.
        - One distinctive type decision. One is enough; three is a costume party.
        - Show the actual product — a real screenshot, real data, a working embed — instead of abstract illustration.
        - Let content break symmetry: an odd number of features, one oversized item, a section that is just a sentence.
        - Repeat a signature: one odd element used consistently beats ten borrowed ones used once.
        - Restraint is a feature. The confident version has fewer effects than the generated one. Adding "personality effects" on top is re-slopping with extra steps.
        
        ## When to apply
        
        "Looks AI-generated", "looks like every other landing page", any generated marketing page, dashboard, or app screen before it ships. Spawn [[anti-pattern-scanner]] for the catalog scan; run this node for the pass itself.
        
        ## Gotcha
        
        The pass ends when you can name the one choice a competitor couldn't copy-paste. If nothing on the screen answers, you stopped after half one.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on generated UI, distilled by HKTITAN.
        - Related: [[ai-default-tells]], [[content-authenticity]], [[feeling-right]], [[copy-tells]].
        
    • components
      • accessibility-baseline.md 3.2 KB
        ---
        title: accessibility-baseline
        summary: Keyboard-everywhere, :focus-visible, hit targets, ARIA names on icon buttons, polite aria-live for async. The a11y floor below taste.
        tags: [components, accessibility, a11y, keyboard]
        ---
        
        # Accessibility baseline
        
        A11y is below taste. Below polish. Below brand. A product that fails the baseline is broken — no amount of motion or typography fixes that.
        
        These are the rules every component should pass before any design judgment is applied.
        
        ## Keyboard
        
        - **Everything interactive is reachable by Tab** in source order. If you can't get to it without a mouse, it doesn't exist for ~10% of users.
        - **`:focus-visible` rings**, not `:focus` rings — the latter triggers on click, the former only on keyboard. Don't show a focus ring after a mouse click.
        - **Custom focus rings beat `outline: 2px solid blue`.** Match your brand — a 2px solid + 1px shadow ring in the brand accent is the modern pattern.
        - **Trap focus in modals and dialogs.** Tab can't escape. `Esc` closes. Focus returns to the trigger element on close.
        - **Skip links** at the top of every page for screen-reader and keyboard users. `<a href="#main">Skip to content</a>` — visually hidden until focused.
        - **Enter submits forms**, `⌘+Enter` (or `Ctrl+Enter`) submits in textareas. Never break these.
        
        ## Hit targets
        
        | Surface | Minimum |
        |---|---|
        | Pointer (desktop) | 24×24px |
        | Touch (mobile / tablet) | 44×44px (per Apple HIG) |
        
        A 16px icon button needs *padding* to hit these — don't make the visible chrome the touch target.
        
        ## ARIA + semantics
        
        - **HTML semantics first**, ARIA second. `<button>` beats `<div role="button" tabindex="0">` every time.
        - **Icon-only buttons need an `aria-label`.** A `<button><Icon /></button>` is a button with no name.
        - **`<label for="…">` on every form input.** A `placeholder` is not a label.
        - **`aria-live="polite"`** for async status updates ("Saved", "Uploading", "Error"). `assertive` only for errors.
        - **`alt=""` on decorative images** — empty alt, not missing alt. Screen readers skip them.
        
        ## Color and contrast
        
        - **Don't rely on color alone.** Errors get an icon AND red. Required fields get an asterisk AND a different background. Color-blind users (~8% of men) read no signal otherwise.
        - **APCA over WCAG 2** for contrast math — see [[contrast-and-color-scheme]] for the threshold table.
        
        ## Motion
        
        - **`prefers-reduced-motion`** is non-negotiable. See [[prefers-reduced-motion]].
        
        ## When to apply
        
        Every component, every PR. Run through the keyboard test at minimum:
        1. Tab through the page. Can you reach everything?
        2. Is the focus ring visible?
        3. Can you Enter / Space to activate?
        4. Can you Esc to dismiss?
        
        ## Gotcha
        
        Don't ship custom `<select>` or `<combobox>` components unless you've truly implemented all of: keyboard nav, type-ahead, screen-reader announcements, mobile keyboard handling. Most "custom select" components fail half of these. Use Radix UI, React Aria, or the native `<select>` instead.
        
        ## Sources
        
        - Vercel Web Interface Guidelines — Interactions and Content sections.
        - bendc/frontend-guidelines — "Don't rely on color alone."
        - WCAG 2.1 + Apple HIG hit-target guidance.
        - Related: [[hover-states-subtle]], [[forms-validation]], [[prefers-reduced-motion]], [[contrast-and-color-scheme]].
        
      • avatar-systems.md 8.8 KB
        ---
        title: avatar-systems
        summary: Use real photos when available; fallback to deterministic generated avatars. DiceBear provides 30+ styles for the fallback.
        tags: [components, avatars, dicebear, identity]
        ---
        
        # Avatar systems
        
        Every product with users needs an avatar strategy. Three tiers:
        
        1. **Real photos** — uploaded by the user. Always preferred.
        2. **Initials** — the most common fallback (Slack, GitHub, Linear all use this).
        3. **Generated avatars** — deterministic, fun, branded. [DiceBear](https://www.dicebear.com/) is the de-facto library.
        
        Pick a strategy *before* you scale — switching avatar systems mid-product is painful because every user has visually adapted to "what they look like" in your UI.
        
        ## DiceBear — what it is
        
        [DiceBear](https://www.dicebear.com/) is an open-source library that generates SVG avatars from a string seed (usually the user's id, email, or name). Same seed always produces the same avatar — so users see the same generated face every visit.
        
        - 30+ style packs (`adventurer`, `bottts`, `lorelei`, `pixel-art`, etc.).
        - HTTP API: `https://api.dicebear.com/9.x/<style>/svg?seed=<seed>`.
        - NPM packages: `@dicebear/core` + `@dicebear/<style>` for self-hosting.
        - Open source: [github.com/dicebear/dicebear](https://github.com/dicebear/dicebear) and [github.com/dicebear/styles](https://github.com/dicebear/styles).
        - License: most styles are CC0 / free; a few designer-contributed styles have specific terms — check per style.
        
        ## Style catalogue
        
        Quick reference. Preview URLs use seed `Felix` so you can see what each style looks like. Click the live link to scrub seeds in the playground.
        
        | Style | Best for | Live preview |
        |---|---|---|
        | `adventurer` | Friendly, playful apps | ![adventurer](https://api.dicebear.com/9.x/adventurer/svg?seed=Felix) |
        | `adventurer-neutral` | Friendly, no background | ![adventurer-neutral](https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Felix) |
        | `avataaars` | Cartoon-style, broad appeal | ![avataaars](https://api.dicebear.com/9.x/avataaars/svg?seed=Felix) |
        | `avataaars-neutral` | Cartoon, no background | ![avataaars-neutral](https://api.dicebear.com/9.x/avataaars-neutral/svg?seed=Felix) |
        | `big-ears` | Animal-style, casual | ![big-ears](https://api.dicebear.com/9.x/big-ears/svg?seed=Felix) |
        | `big-ears-neutral` | Animal, no background | ![big-ears-neutral](https://api.dicebear.com/9.x/big-ears-neutral/svg?seed=Felix) |
        | `big-smile` | Cheerful, simple | ![big-smile](https://api.dicebear.com/9.x/big-smile/svg?seed=Felix) |
        | `bottts` | Robots — great for AI agents | ![bottts](https://api.dicebear.com/9.x/bottts/svg?seed=Felix) |
        | `bottts-neutral` | Robots, no background | ![bottts-neutral](https://api.dicebear.com/9.x/bottts-neutral/svg?seed=Felix) |
        | `croodles` | Hand-drawn, scrappy | ![croodles](https://api.dicebear.com/9.x/croodles/svg?seed=Felix) |
        | `croodles-neutral` | Hand-drawn, no background | ![croodles-neutral](https://api.dicebear.com/9.x/croodles-neutral/svg?seed=Felix) |
        | `dylan` | Stylized portraits (paid) | ![dylan](https://api.dicebear.com/9.x/dylan/svg?seed=Felix) |
        | `fun-emoji` | Emoji-style faces | ![fun-emoji](https://api.dicebear.com/9.x/fun-emoji/svg?seed=Felix) |
        | `glass` | Frosted glass effect (paid) | ![glass](https://api.dicebear.com/9.x/glass/svg?seed=Felix) |
        | `icons` | Iconographic, abstract | ![icons](https://api.dicebear.com/9.x/icons/svg?seed=Felix) |
        | `identicon` | Geometric, GitHub-style | ![identicon](https://api.dicebear.com/9.x/identicon/svg?seed=Felix) |
        | `initials` | Letter-based, most common | ![initials](https://api.dicebear.com/9.x/initials/svg?seed=Felix%20Initial) |
        | `lorelei` | Friendly illustrated portraits | ![lorelei](https://api.dicebear.com/9.x/lorelei/svg?seed=Felix) |
        | `lorelei-neutral` | Friendly, no background | ![lorelei-neutral](https://api.dicebear.com/9.x/lorelei-neutral/svg?seed=Felix) |
        | `micah` | Modern minimal portraits | ![micah](https://api.dicebear.com/9.x/micah/svg?seed=Felix) |
        | `miniavs` | Tiny stylized avatars | ![miniavs](https://api.dicebear.com/9.x/miniavs/svg?seed=Felix) |
        | `notionists` | Notion-style hand-drawn | ![notionists](https://api.dicebear.com/9.x/notionists/svg?seed=Felix) |
        | `notionists-neutral` | Notion-style, no background | ![notionists-neutral](https://api.dicebear.com/9.x/notionists-neutral/svg?seed=Felix) |
        | `open-peeps` | Vector illustration style | ![open-peeps](https://api.dicebear.com/9.x/open-peeps/svg?seed=Felix) |
        | `personas` | Diverse illustrated portraits | ![personas](https://api.dicebear.com/9.x/personas/svg?seed=Felix) |
        | `pixel-art` | 8-bit pixel avatars | ![pixel-art](https://api.dicebear.com/9.x/pixel-art/svg?seed=Felix) |
        | `pixel-art-neutral` | Pixel art, no background | ![pixel-art-neutral](https://api.dicebear.com/9.x/pixel-art-neutral/svg?seed=Felix) |
        | `rings` | Concentric rings, abstract | ![rings](https://api.dicebear.com/9.x/rings/svg?seed=Felix) |
        | `shapes` | Geometric shapes | ![shapes](https://api.dicebear.com/9.x/shapes/svg?seed=Felix) |
        | `thumbs` | Thumbprint-style geometric | ![thumbs](https://api.dicebear.com/9.x/thumbs/svg?seed=Felix) |
        
        > Browse all styles interactively at [dicebear.com/styles](https://www.dicebear.com/styles). Source repo: [github.com/dicebear/styles](https://github.com/dicebear/styles).
        
        ## How to choose a style
        
        | Product type | Suggested style |
        |---|---|
        | Professional B2B (CRM, analytics, fin) | `initials` (safest), or `shapes`/`thumbs` for abstract |
        | AI assistant product (where avatar represents the AI) | `bottts` or `bottts-neutral` |
        | Consumer social / community | `lorelei`, `notionists`, or `open-peeps` |
        | Game / playful product | `pixel-art`, `adventurer`, `big-smile`, or `fun-emoji` |
        | Privacy-focused / anonymous identity | `identicon`, `rings`, or `shapes` |
        | Brand-led with hand-drawn feel | `croodles` or `notionists` |
        | Minimal / Apple-like aesthetic | `micah` or `miniavs` |
        
        ## Implementation
        
        ### HTTP API (simplest)
        
        ```html
        <img
          src="https://api.dicebear.com/9.x/lorelei/svg?seed=user-id-here"
          alt="User avatar"
          width="40"
          height="40"
        />
        ```
        
        Cache-friendly (the URL is deterministic). For production, proxy through your CDN to avoid hitting DiceBear's API on every request.
        
        ### NPM (self-hosted)
        
        ```bash
        npm install @dicebear/core @dicebear/lorelei
        ```
        
        ```ts
        import { createAvatar } from '@dicebear/core';
        import { lorelei } from '@dicebear/lorelei';
        
        const avatar = createAvatar(lorelei, { seed: 'user-id-here' });
        const svg = avatar.toString();
        ```
        
        This avoids the network entirely. Recommended for any serious deployment.
        
        ### React (with fallback to real photo)
        
        ```tsx
        function Avatar({ user, size = 40 }: AvatarProps) {
          if (user.photoUrl) {
            return <img src={user.photoUrl} alt={user.name} width={size} height={size} className="avatar" />;
          }
          
          const seed = user.id ?? user.email;
          return (
            <img
              src={`https://api.dicebear.com/9.x/lorelei/svg?seed=${encodeURIComponent(seed)}`}
              alt={user.name}
              width={size}
              height={size}
              className="avatar"
            />
          );
        }
        ```
        
        ## Style options & customization
        
        Each DiceBear style accepts options (skin colors, hair styles, background colors, etc.) as URL query params:
        
        ```
        https://api.dicebear.com/9.x/lorelei/svg?seed=Felix&backgroundColor=transparent&hair=variant01
        ```
        
        See per-style options at [dicebear.com/styles](https://www.dicebear.com/styles). Match the option set to your brand — e.g., restrict hair colors to your palette for tighter visual consistency.
        
        ## When to apply
        
        - Any product where users need visual identity but real photos aren't reliably available.
        - Auto-generated avatars for AI agents or assistants.
        - Demo / staging environments where you need realistic-looking user data.
        - Public profiles where users haven't uploaded a photo.
        
        ## When NOT to apply
        
        - Healthcare / regulated industries where avatar variation can feel unprofessional. Use initials.
        - High-trust contexts (legal, financial) where the AI-generated look might undermine credibility.
        - Products where avatar fidelity is critical (dating apps, hiring). Require real photos.
        
        ## Gotcha
        
        DiceBear's HTTP API has rate limits. For production, either:
        
        - Self-host via the NPM package (recommended).
        - Proxy + cache through your CDN.
        - Generate avatars server-side once per user and store as a blob.
        
        Hitting their public API from every page load *will* eventually rate-limit your users.
        
        Also: a few styles are paid (designer-contributed). Verify license per style at [dicebear.com/styles](https://www.dicebear.com/styles) before committing.
        
        ## Sources
        
        - DiceBear — [dicebear.com](https://www.dicebear.com/), [github.com/dicebear/dicebear](https://github.com/dicebear/dicebear), [github.com/dicebear/styles](https://github.com/dicebear/styles).
        - DiceBear style index — [dicebear.com/styles](https://www.dicebear.com/styles).
        - Related: [[icon-systems]], [[ai-default-tells]], [[hover-states-subtle]].
        
      • cards-design.md 3.9 KB
        ---
        title: cards-design
        summary: Don't nest cards. Design card interiors intentionally — whitespace, hover-only actions, subtle icons.
        tags: [components, cards]
        ---
        
        # Cards design
        
        Cards are easy to over-use and easier to over-nest. A well-designed card is a self-contained unit of content with intentional interior structure. Nested cards are usually a sign the design has lost its way.
        
        ## The no-nesting rule
        
        ```
        Bad:  card > card > card (three borders, three shadows, three radii)
        Good: card > content with internal hierarchy (one border, one shadow)
        ```
        
        If you find yourself reaching for a card-inside-a-card, the inner card is probably:
        - A list item (use a `<li>` with hover state, not a card).
        - A heading + content block (use typography hierarchy, not a wrapped container).
        - A status indicator (use a badge or chip inline, not a card).
        
        The exception: a *single* call-out card inside a parent card (e.g., a pinned highlight) can work. Beyond that, you're overusing the pattern.
        
        ## Designing card interiors
        
        A card is a small layout problem. Its interior deserves the same care as a screen:
        
        - **Whitespace beats borders for separating sections.** A 24px gap between header and body reads cleaner than a 1px divider.
        - **Hover-only actions** for editing, deleting, more options. Show them on `:hover` (or always on touch).
        - **Subtle icons** to anchor information types — but don't add an icon to every line. One per card maximum, usually.
        - **Variable density inside.** A list of recent activity inside the card can be tighter than the card's outer padding.
        
        Example card structure:
        
        ```tsx
        <Card>
          <CardHeader>
            <CardTitle>Project Atlas</CardTitle>
            <CardActions show-on-hover>{/* Edit, Archive, Share */}</CardActions>
          </CardHeader>
          <CardMeta>
            <Icon name="folder" /> 12 files · Updated 2h ago
          </CardMeta>
          <CardBody>
            <p>Brief description that fits on 2–3 lines max.</p>
          </CardBody>
          <CardFooter>
            <AvatarStack avatars={collaborators} />
            <Badge>Active</Badge>
          </CardFooter>
        </Card>
        ```
        
        ## Card hover states
        
        Most cards in a grid should have a hover state. See [[hover-states-subtle]] — the right answer is usually:
        
        - 1px lift (`transform: translateY(-1px)`).
        - Slight background-color shift on the card body.
        - Hover-only action buttons fade in.
        
        NOT a 4px lift, NOT a large shadow change. Those are the [[ai-default-tells]].
        
        ## Cards as the wrong choice
        
        Cards aren't always the right container. Alternatives:
        
        | Content | Better than card |
        |---|---|
        | A list of items the user reads top-to-bottom | Use a list with hover rows |
        | A single highlighted piece of content | Use typography hierarchy + whitespace |
        | Status that updates frequently | Inline badge or pill |
        | Tabular comparison data | Use a real table ([[data-is-content]]) |
        | Long-form content | Use sections with headers, not cards |
        
        If your screen is *all* cards, it probably has no hierarchy. Some content earns a card; most doesn't.
        
        ## When to apply
        
        - Grid of equally-weighted items (projects, products, users).
        - Bento layouts on landing pages.
        - Dashboards with discrete widgets.
        - Anywhere the content is self-contained and benefits from a clear boundary.
        
        ## When NOT to apply
        
        - Long-form reading.
        - Single-focus screens (each card competes with the focus).
        - Lists where every row is just text + metadata (use rows, not cards).
        - Inside an already-cardified container.
        
        ## Gotcha
        
        The nested-radius rule from [[border-radius]] applies: if a card has `border-radius: 12px` and contains a media (image, video) that fills the top, the media needs `border-radius: 12px 12px 0 0` to match. Most "this card looks off" complaints trace to a mismatched nested radius.
        
        ## Sources
        
        - guidelines.sh — "Don't nest cards" + "Design card interiors intentionally."
        - Linear, Notion, Vercel — bento and grid card patterns in production.
        - Related: [[hover-states-subtle]], [[border-radius]], [[ai-default-tells]], [[shadows-whisper]].
        
      • component-api-design.md 4.2 KB
        ---
        title: component-api-design
        summary: A component API is a contract. Composition over configuration, compound parts sharing context, customization layered variants → size → className → asChild, platform prop names, controlled and uncontrolled both, refs forwarded and props spread, type="button" by default.
        tags: [components, api, react, composition, props]
        ---
        
        # Component API design
        
        Good component APIs make the common case a one-liner and the uncommon case possible without a fork. The two failure modes are always the same: too rigid (consumers fork or hack around it) or too configurable (thirty props nobody can hold in their head). This node is about the *props*, not the pixels; [[component-confusables]] decides which component, and [[dependency-discipline]] decides whether to write one at all.
        
        ## Composition over configuration
        
        Expose structure as JSX children, not configuration objects. `<Card><CardHeader>…</CardHeader><CardContent/></Card>` beats `<Card header={{ title, description }} footer={{ actions: [...] }} />` because the consumer can reorder, omit, and wrap without a new prop for each case.
        
        ## Compound components
        
        When related parts share implicit state — trigger and content, title and close — split into compound parts and share the state through context (the root provides, the parts consume). Use them when the component has slots, when the order or presence of children varies, or when consumers need to compose. Don't when the structure is fixed and there are one to three props.
        
        ## The Goldilocks layer order
        
        1. **Variants** — predefined options (`primary`, `secondary`, `destructive`).
        2. **Size** — predefined (`sm`, `md`, `lg`).
        3. **`className`** — the escape hatch for one-off overrides.
        4. **`asChild`** — render as a different element (Radix `Slot`) while keeping behavior and styles: `<Button asChild><Link href="/">Go</Link></Button>`.
        
        Never raw style props (`backgroundColor`, `paddingX`); that is a second CSS system with a worse syntax. Express variant styles in the project's existing styling stack.
        
        ## Name like the platform
        
        Mirror HTML: `disabled`, not `isDisabled`; `open`, not `isNotClosed` (positive booleans, never double negatives); handlers prefixed `on` (`onChange`, `onOpenChange`). Consistent across every component in the library.
        
        ## Controlled and uncontrolled
        
        Any stateful component works both ways: `defaultValue` for internal state, `value` + `onChange` when the consumer owns it. `isControlled = value !== undefined`; read from the controlled value when present, update internal state only when not, and always call `onChange`.
        
        ## Play nice with the DOM
        
        - **Forward refs** on anything wrapping a DOM element, or focus management, tooltips, and popover positioning break for composers.
        - **Spread remaining props** so `aria-*`, `data-testid`, and other attributes pass through.
        - **Defaults for the 80% case**: `variant="primary"`, `size="md"`, `type="button"` (never `submit` by default — the safer choice).
        - Children for simple content; a render prop (`renderItem`) when the consumer maps data; slot props (`header={<h2/>}`) for optional sections.
        
        ## Common mistakes
        
        - **Prop explosion** — `leftIcon`, `rightIcon`, `iconSpacing`… Use children: `<Button><Icon/> Save</Button>`.
        - **Boolean soup** — `<Button primary large rounded>` invites invalid combinations; `variant`/`size`/`radius` enums don't.
        - **Premature abstraction** — extract after the pattern has repeated two or three times, not before.
        - **Swallowed props / missing ref** — the two silent breakages that surface as "the tooltip doesn't position".
        
        ## When to apply
        
        Creating or refactoring a shared component, reviewing a props API, deciding how much customization to expose. In a [[review-format]] table, an API finding is a row only when the diff introduces a shared component.
        
        ## Gotcha
        
        `asChild` merges props onto the child; if the child already has a handler or class, make sure the merge composes (Slot does) instead of overwriting. Hand-rolled `as` props that clone with spread usually drop one side.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on component design, distilled by HKTITAN.
        - Radix UI `Slot`; React `forwardRef`.
        - Related: [[component-confusables]], [[dependency-discipline]], [[sonner-principles]].
        
      • component-confusables.md 2.9 KB
        ---
        title: component-confusables
        summary: The component pairs people reach for interchangeably but shouldn't — tooltip vs popover, badge vs tag, sheet vs drawer vs dialog. Pick by behaviour, not by looks.
        tags: [components, vocabulary, distinctions]
        ---
        
        # Component confusables
        
        Some components look alike and get used interchangeably. The cost is silent: the wrong one breaks an expectation the user already has. Pick by *behaviour*, not by appearance. This is [[articulate-precisely]] applied to the component layer — naming the difference is choosing the right one.
        
        ## Tooltip vs popover
        
        The line is interactivity:
        
        - **Tooltip** — a hover/focus label that explains an element. It *cannot* hold interactive content. No links, no buttons — the pointer can't move into it without dismissing it.
        - **Popover** — a click-anchored overlay that *can* hold interactive content: links, buttons, a small form. It stays open until dismissed.
        
        If you need a link inside the floating thing, it is a popover, not a tooltip. A button living in a tooltip is the tell that the wrong primitive was chosen. Both still need [[accessibility-baseline]] focus handling.
        
        ## Badge vs tag
        
        The line is ownership and interactivity:
        
        - **Badge** — attached to another element and read-only. Numeric badges imply a count (the "3" on an inbox); word badges ("New", a status) are informational.
        - **Tag** — standalone and interactive: selectable, removable, used to categorise or filter. A chip the user can pick up or discard.
        
        A badge is pinned on; a tag is handled. Styling one as the other collapses a real behavioural difference and trains the user to expect the wrong affordance.
        
        ## Sheet vs drawer vs dialog
        
        All three are overlays; the difference is origin and intent — and all three share the same focus-and-dismissal rules as a modal (see [[tray-rules]]):
        
        - **Dialog / modal** — interrupts the flow centre-screen to demand a decision. Focus trapped, background inert.
        - **Sheet** — slides in from a screen edge; common on mobile for secondary nav or contextual actions.
        - **Drawer** — a bottom sheet pulled up from the base; a frequent mobile replacement for a dialog.
        
        Reach for a dialog when the choice must be made now; reach for a sheet or drawer when the surface is secondary and dismissible.
        
        ## Gotcha
        
        The trap is choosing by how it looks in the design file ("a little floating box") instead of how it behaves. Ask three questions: does the user need to *interact inside* it (popover, not tooltip)? Can they *act on* it (tag, not badge)? Must they decide *now* (dialog, not sheet)? Behaviour picks the component.
        
        ## Sources
        
        - *Index — Say Precisely What You Mean*, Emil Kowalski & Glenn Carstens-Peters — [index.how/to/articulate](https://index.how/to/articulate). The components vocabulary these distinctions sharpen; see also [[design-vocabulary]].
        - [[tray-rules]] — Benji Taylor's overlay/sheet rules. [[accessibility-baseline]] — the focus and dismissal floor.
        
      • copy-voice.md 4.1 KB
        ---
        title: copy-voice
        summary: Active voice, Title Case for headings + buttons, "&" over "and", 2nd person, numerals for counts, error messages guide the exit.
        tags: [components, copy, voice, microcopy]
        ---
        
        # Copy and voice
        
        Microcopy is design. The button label, the empty state, the error message — they ship every day, and the wrong word in any of them quietly ages the product.
        
        This node is the universal rules. For broader content authenticity (real data vs lorem, specific numbers, real photos), see [[content-authenticity]].
        
        ## Universal rules
        
        ### Voice
        
        - **Active voice.** "Saved your changes" beats "Your changes have been saved."
        - **2nd person, addressing the user.** "You can…" beats "Users can…"
        - **Be specific.** "Couldn't connect to the server" beats "Something went wrong."
        - **Be positive.** "Add another" beats "You can't add fewer than one."
        - **Drop the throat-clearing.** "Saved." beats "Awesome, your changes were saved!"
        
        ### Capitalization
        
        - **Title Case for headings and buttons.** "Create Project" not "Create project" or "CREATE PROJECT" (uppercase is harder to read — see [[line-length-tracking]]).
        - **Sentence case for body and tooltips.** "Click here to learn more" not "Click Here to Learn More."
        - **Consistent within a surface.** Don't mix Title Case buttons with sentence-case buttons.
        
        ### Numerals + units
        
        - **Numerals for counts.** "3 projects" not "three projects". Even small numbers.
        - **Non-breaking space between number and unit.** `3 MB` (3 + nbsp + MB) so it doesn't line-break.
        - **Consistent currency decimals.** $5.00 not $5 (in financial / commerce contexts where 2 decimals are the norm).
        - **Singular/plural matters.** "1 item" not "1 items".
        
        ### Punctuation
        
        - **Typographic quotes.** Use `"` and `'`, not `"` and `'`. Most editors auto-correct.
        - **Ellipsis character `…`**, not three dots `...`. Single character; better-rendered.
        - **Em-dash `—` for parentheticals.** En-dash `–` for ranges (5–10 items).
        - **"&" only where it's part of brand or label**, never in body prose. "Q&A" yes; "Save & Continue" — usually no, just "Save and Continue" or split into two buttons.
        
        ## Error messages
        
        A good error message answers three questions:
        1. **What went wrong** (specific, not generic).
        2. **Why** (if helpful).
        3. **What the user can do next** (actionable).
        
        ```text
        Bad:   Something went wrong.
        Bad:   Error: 500 Internal Server Error
        OK:    Couldn't save. Network connection lost.
        Good:  Couldn't save. Network connection lost. Try again, or check your
               internet connection.
        ```
        
        Errors should never just announce. They should guide.
        
        ## Empty states
        
        A good empty state has:
        - **A title** stating what the user is looking at (or *not* looking at).
        - **Why it's empty.** "No projects yet" not "Empty."
        - **The next action.** "Create your first project →" with the actual link.
        
        See [[empty-loading-states]] for full empty-state design.
        
        ## Async status
        
        - **"Saving…"** (with ellipsis) — operation in progress.
        - **"Saved"** — complete.
        - **"Couldn't save"** — failed.
        - **`aria-live="polite"`** on the status element so screen readers announce changes. See [[accessibility-baseline]].
        
        ## When to apply
        
        Every label, every error, every empty state, every tooltip. Microcopy is the highest leverage / lowest cost design intervention available.
        
        ## Gotcha
        
        Don't be cute in error messages. "Oops! Something tripped on the wires!" is funny once and annoying forever. Error messages get re-read every time the user hits the error — write them for the 10th encounter, not the 1st.
        
        Also: don't put microcopy decisions in code reviews. Microcopy lives in design files and content systems. If you're approving "Save" vs "Save Changes" in a PR, the team is missing a design step.
        
        ## Sources
        
        - Vercel Web Interface Guidelines — Copywriting and Content sections.
        - Erika Hall — *Conversational Design* on tone, voice, error messages.
        - Material Design — writing guidelines.
        - Related: [[content-authenticity]] (real data, real numbers), [[empty-loading-states]] (empty-state patterns), [[accessibility-baseline]] (aria-live), [[typography-humanity]] (typographic quotes, em-dashes).
        
      • empty-loading-states.md 2.9 KB
        ---
        title: empty-loading-states
        summary: Empty states direct action. Skeletons hint structure. Spinners under 3s; progress bars over 3s.
        tags: [components, states, loading]
        ---
        
        # Empty and loading states
        
        Most UIs are designed for the "happy" state — data present, user knows what to do. The states that are actually painful to use are empty (no data yet) and loading (data not yet rendered).
        
        ## Empty states
        
        A good empty state has three jobs:
        
        1. **Explain what should be here.** "No projects yet."
        2. **Direct the next action.** "Create your first project →"
        3. **Optionally provide context or example.** Screenshot, demo data, learn-more link.
        
        A bad empty state just says "Nothing here" and ends. The user is left wondering what to do next.
        
        ```tsx
        <EmptyState
          icon={<FolderIcon />}
          title="No projects yet"
          description="Projects organize your work and let you invite collaborators."
          action={<Button>Create project</Button>}
        />
        ```
        
        ## Loading state choice tree
        
        | Expected duration | UI |
        |---|---|
        | 0–800ms | Nothing. Spinner introduced after 800ms (avoids flash). |
        | 800ms–3s | Spinner (animated). |
        | 3s–15s | Progress bar with percentage. |
        | 15s+ | Progress bar + estimated time + cancel button. |
        | Unknown / streaming | Skeleton placeholders matching layout shape. |
        
        ## Skeletons
        
        A skeleton placeholder hints at the **shape** of incoming content (rectangles where text will be, circles where avatars will be). It is *not* a decorative loading element.
        
        ```tsx
        <div className="space-y-2">
          <div className="h-4 w-32 bg-zinc-200 rounded animate-pulse" />
          <div className="h-3 w-48 bg-zinc-200 rounded animate-pulse" />
        </div>
        ```
        
        Rules:
        - **Match the real layout.** A skeleton list with 3 items should reveal to a list with ~3 items, same widths, same gaps. Otherwise the layout jumps when content arrives.
        - **No skeleton for under-300ms loads.** The flash is worse than nothing.
        - **Pulse animation is the only animation.** Don't get clever with shimmer overlays unless your entire product has that energy.
        
        ## Spinners are the wrong default
        
        A perpetually-spinning loader signals "we have no idea how long this will take." That's almost never true. Most loads are <3s. Most slow loads can show real progress.
        
        The most common "loading" mistake is reaching for `<Spinner />` when a skeleton, a progress bar, or just-wait-300ms would be better.
        
        ## When to apply
        
        - Every list, table, or content area: design the empty state.
        - Every async action: pick a loading pattern from the table.
        - Every form submit: provide feedback under 100ms (a disabled button state) and a loading indicator after 300ms.
        
        ## Gotcha
        
        Don't auto-redirect from empty states ("nothing here, going to home..."). That feels like the app broke. Let the user see the empty state and choose the next step.
        
        ## Sources
        
        - guidelines.sh — "Spinners <3s; progress bars >3s; skeletons hint at structure."
        - Emil Kowalski — Sonner skeleton patterns.
        - Brad Frost — "loading state best practices."
        
      • forms-behavior.md 3.9 KB
        ---
        title: forms-behavior
        summary: The semantics the platform gives you, then the details — label wiring, right input type, 16px, focus-visible, autocomplete where it helps, decorations over the input, autofocus only on desktop modals, a real form so Enter submits, buttons that disable while submitting, no dead zones, confirmed destruction, prefilled everything.
        tags: [components, forms, inputs, buttons, keyboard]
        ---
        
        # Forms — behavior
        
        Forms are where users do real work, and they notice every rough edge: the label that doesn't focus the input, the page that zooms on iOS, the button that fires twice, the Enter key that does nothing. None of it is hard; it is easy to forget. [[forms-validation]] owns *when* to validate and how errors look; this node owns everything else a form has to do.
        
        ## Wire the platform first
        
        - **Every input gets an associated label** — `for`/`id` or wrapping — so clicking the label focuses the field.
        - **Right `type`** (`email`, `tel`, `url`, `number`, `search`, `password`): the correct mobile keyboard, native validation, and autofill come free.
        - **Inputs, textareas, and selects at ≥ 16px** on touch viewports (see [[line-behavior]] for why not `maximum-scale`).
        - **`:focus-visible`, never bare `outline: none`.** Keyboard users get the ring; mouse users don't see it on every click. Keep the ring neutral (grey, black, white) unless a focus token exists.
        - **Real `<button>` elements.** A `<div onClick>` is invisible to keyboards and screen readers. Default `type="button"`.
        
        ## Browser behaviors, curated
        
        Keep `autocomplete` **on** for identity, address, and payment fields — there it helps. Turn `spellcheck` and `autocomplete` **off** for usernames, search, codes, and slugs, and suppress password-manager overlays (`data-1p-ignore`, `data-lpignore`) where they don't belong.
        
        ## Decorations, focus, submission
        
        - **Prefix/suffix icons overlay the input** (absolutely positioned, padding reserving room), never siblings. A sibling icon does nothing when clicked and the border lies about the hit area. Clickable decorations (clear, search) refocus the input.
        - **Autofocus the first input when a desktop modal opens; never on touch** — it shoves the keyboard into the user's face.
        - **Wrap inputs in a `<form>`** so Enter submits. Textareas submit on Cmd/Ctrl+Enter.
        - **Disable the submit control while the request is in flight** and change its label to say what's happening; that is the double-submit fix.
        - **Press feedback** `scale(0.97)` on `:active` through a ~150ms transition — see [[responsive-feedback]].
        - **Surface the shortcut** in the button's tooltip ("Save (⌘S)"), bound to Cmd on macOS and Ctrl elsewhere.
        
        ## Rows, danger, prefill
        
        - **No dead zones.** The control, the label, and the gap between them are all clickable — wrap the row in one `<label>` or make the label `flex: 1`.
        - **Destructive actions are confirmed** and spatially separated from the primary action. `confirm()` is the floor; a real dialog for real products, and prefer easy undo everywhere the action is reversible.
        - **Prefill everything you can** from the logged-in user and the link context: a "change username" link lands on a form already carrying the current value.
        - **Build on accessible primitives** (Base UI, Radix, React Aria) rather than hand-rolling focus management. The bar — focus, ARIA, keyboard — is non-negotiable whichever library you pick.
        
        ## When to apply
        
        Any form, input, search field, login/signup, settings page, checkout, or submit handler — building or reviewing.
        
        ## Gotcha
        
        Auto-advancing verification-code inputs feel clever until someone needs to correct a digit. Waiting for Tab is safer; if you auto-advance, Backspace must move back and select.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on forms, distilled by HKTITAN.
        - Related: [[forms-validation]], [[accessibility-baseline]], [[touch-and-focus]], [[responsive-feedback]].
        
      • forms-validation.md 4.4 KB
        ---
        title: forms-validation
        summary: Validate at input, not on submit. Inline errors beat error summaries. Soft validation while typing, hard validation on blur.
        tags: [components, forms, validation]
        ---
        
        # Forms — inline validation
        
        The on-submit error summary is dead. Modern forms validate at the field level, inline, as the user moves through them.
        
        ## The validation cadence
        
        1. **While typing — soft signals only.** Don't fire red borders or error text on keystroke. The user is still composing. The only allowed feedback while typing is *positive* — a green check appearing when the field becomes valid.
        2. **On blur — hard validation.** When the user leaves the field, validate. Fire the error if it fails. This matches user intent: "I'm done with this field; tell me if it's wrong."
        3. **On submit — final pass.** Catch anything blur-validation missed (cross-field rules, server-side checks). If errors exist, scroll to the *first* invalid field and focus it.
        
        The cardinal sin: validating on every keystroke and showing errors mid-typing. This punishes the user for an in-progress action and reads as nagging.
        
        ## Error display
        
        Each errored field has:
        
        - **Border color** — `var(--danger)` outline (1px, slightly stronger than default).
        - **Background tint** (optional) — very subtle, `rgba(danger, 0.04)`.
        - **Inline error message** — below the field, in danger color, 13px–14px. Don't bury it in a sidebar.
        - **Optional: shake animation on the error fire.** See [[multi-segment-shake]]. Use only on submit, not on blur (too aggressive otherwise).
        
        ```tsx
        <FormField>
          <Label>Email</Label>
          <Input
            value={email}
            onBlur={validateEmail}
            aria-invalid={hasError}
            aria-describedby="email-error"
          />
          {hasError && (
            <ErrorMessage id="email-error">
              Email looks invalid. Try the format name@example.com.
            </ErrorMessage>
          )}
        </FormField>
        ```
        
        ## Error copy
        
        Bad error copy:
        > "Invalid input."
        > "Email is invalid."
        > "Please enter a valid email."
        
        Good error copy:
        > "Email looks invalid. Try the format name@example.com."
        
        Rules:
        - Tell the user *what* is wrong.
        - Tell them *how to fix it*.
        - Skip "Please."
        - Don't say "Invalid" — say what was expected.
        - Sound like a person, not a 1990s database.
        
        ## Success states
        
        Valid fields can show a subtle green check inside the input on blur. This is optional but rewards the user for getting a field right. Don't overdo it — checks on every field becomes noise. Use for fields where validity is non-obvious (email format, password strength) and skip for trivially-valid ones (first name).
        
        ## Multi-field validation
        
        For rules that involve multiple fields (password confirmation, date range start ≤ end), validate when the *second* field blurs, not the first. The first field can't be wrong in isolation; only the relationship is wrong.
        
        ```tsx
        function PasswordFields({ password, confirm, setConfirmError }) {
          return (
            <>
              <Input name="password" />
              <Input
                name="confirm"
                onBlur={() => setConfirmError(confirm !== password ? "Doesn't match." : null)}
              />
            </>
          );
        }
        ```
        
        ## Server-side validation
        
        Server errors (email already in use, network error) come back after submit. Handle them by:
        
        - Scrolling to the relevant field.
        - Replacing the inline error message with the server message.
        - NOT clearing what the user typed. Never make the user retype.
        
        ## When to apply
        
        Every form: signup, login, settings, payment, anything.
        
        ## When NOT to apply
        
        - Single-input search boxes — don't validate, just search.
        - Free-form comments / chat messages — no "wrong" answer to validate.
        - Inline edits of single fields (rename, status change) — validate on Enter or commit, not on blur (the user might be navigating elsewhere with the value intact).
        
        ## Gotcha
        
        `required` on every field is lazy. Mark *only* the fields that genuinely require user input as required. Optional fields should clearly indicate "Optional" — don't leave the user guessing whether a missing value will fail submit.
        
        Also: don't disable the submit button until the form is valid. It tells the user *nothing* about why. Better to let the click happen, run validation, and surface the errors clearly.
        
        ## Sources
        
        - guidelines.sh — "Validate forms inline at input."
        - Material Design — form patterns.
        - Stripe — best-in-class form copy.
        - Related: [[multi-segment-shake]], [[states-are-the-work]], [[empty-loading-states]].
        
      • hover-states-subtle.md 2.9 KB
        ---
        title: hover-states-subtle
        summary: Hover is not lift-and-shadow. 1px shifts, color shifts, layered disabled state.
        tags: [components, hover, micro-interaction]
        ---
        
        # Hover states — subtle by default
        
        The default hover effect — "lift the card by 4px and add a stronger shadow" — is dated and overused. Subtler hover states read as more considered.
        
        ## Subtle hover patterns
        
        ### Background-color shift
        
        ```css
        .row {
          transition: background-color 120ms;
        }
        .row:hover {
          background-color: var(--hover-overlay); /* rgba(0,0,0,0.04) */
        }
        ```
        
        For most interactive rows, lists, and table cells, this is enough. The shift signals "interactive" without theatrics.
        
        ### 1px translate
        
        ```css
        .card {
          transition: transform 150ms var(--ease-out-quart);
        }
        .card:hover {
          transform: translateY(-1px);
        }
        ```
        
        A 1px lift is felt more than seen. It's the smallest possible motion that still registers. 4px lifts look bouncy and amateur.
        
        ### Border-color shift on inputs
        
        ```css
        .input {
          border-color: var(--border-default);
          transition: border-color 100ms;
        }
        .input:hover { border-color: var(--border-strong); }
        .input:focus { border-color: var(--accent); }
        ```
        
        ### Icon scale on icon buttons
        
        ```css
        .icon-button { transition: transform 100ms; }
        .icon-button:hover .icon { transform: scale(1.05); }
        ```
        
        A small inner scale of the icon (not the button) feels playful without being theatrical.
        
        ## Pressed states
        
        Use `:active` for press feedback. See [[responsive-feedback]].
        
        - **B2C apps:** more pronounced press (`scale(0.96)`). Adds delight.
        - **B2B / productivity apps:** subtler (`scale(0.98)` or just background). Frequent users tire of springy buttons.
        
        ## Disabled states — layer them right
        
        Disabled elements should be visually distinct but not dead:
        
        ```css
        .button:disabled {
          opacity: 0.5;
          cursor: not-allowed;
          pointer-events: none;
        }
        ```
        
        But for inputs:
        
        ```css
        .input:disabled {
          background-color: var(--bg-disabled); /* not just opacity */
          color: var(--text-muted);
          cursor: not-allowed;
        }
        ```
        
        A flat opacity on a complex element (e.g., a card with multiple children) looks washed out. Set explicit disabled colors for the elements that need them; use opacity for the rest.
        
        ## Touch device gate
        
        ```css
        @media (hover: hover) {
          .card:hover { transform: translateY(-1px); }
        }
        ```
        
        On touch devices, `:hover` triggers on tap and persists until the next tap elsewhere — looks broken. The `(hover: hover)` media query restricts hover effects to devices with actual pointers.
        
        ## When to apply
        
        Every interactive element. The hover state is the first thing a power user notices.
        
        ## Gotcha
        
        Hover effects that change layout (margin, padding, width) cause adjacent elements to shift. Use only `transform`, `opacity`, `background-color`, `border-color`, `box-shadow`. See [[transform-opacity-only]].
        
        ## Sources
        
        - guidelines.sh — "Hover isn't lift-and-shadow; 1px shifts."
        - Emil Kowalski — touch device hover gating, disabled-state layering.
        
      • icon-systems.md 3.1 KB
        ---
        title: icon-systems
        summary: Never mix icon packs. 10-32px range. Match stroke to font weight. Lucide is overused.
        tags: [components, icons]
        ---
        
        # Icon systems
        
        Icons are the most replicated UI element in a product. They are also the easiest to get wrong via mismatch — three icons from three different packs at three different stroke widths in the same UI.
        
        ## Pick one icon pack
        
        The single most important rule: **never mix icon packs**. The eye notices instantly, even if the user can't articulate why the UI feels off.
        
        If you started with Material Icons and now want a feature that needs a custom icon, draw the custom icon to match Material's style (rounded, filled, 24px viewbox). Don't reach for Heroicons "just for this one."
        
        ## Packs worth considering
        
        | Pack | Style | Best for |
        |---|---|---|
        | **Lucide** | Outline, 24×24 | Solid default but overused — pick Phosphor or Hugeicons if you want differentiation |
        | **Phosphor** | Multi-weight outline | More personality than Lucide, same coverage |
        | **Hugeicons** | Wide library | Less common, distinctive |
        | **Tabler** | Outline, 1.5px stroke | Cleaner than Lucide at small sizes |
        | **Iconoir** | Outline, hand-drawn feel | Editorial / blog energy |
        | **Heroicons** | Outline + solid pairs | Tailwind ecosystem |
        | **Material Symbols** | Filled, variable | Apps that already use Material |
        
        ## Sizing
        
        | Use | Size |
        |---|---|
        | Inline with body text | 16px |
        | Toolbar buttons | 20px |
        | Primary navigation | 20–24px |
        | Hero / empty state | 32–48px |
        | Marketing | 24–96px |
        
        Below 14px, most icon packs lose detail. Above 32px, hand-drawn or larger-viewBox icons fare better than 24px-viewBox stretched up.
        
        ## Match stroke weight to font weight
        
        If your font is **500 (Medium)**, your outline icons should have a **1.75–2px stroke** at 24px.
        If your font is **400 (Regular)**, your icons should have a **1.5px stroke**.
        
        Phosphor's "regular" weight is 1.5px; "bold" is 2.5px. Pick the one that matches your type.
        
        ## Icon transformations, not crossfades
        
        When toggling an icon (e.g., chevron expand/collapse, play/pause, sun/moon), **rotate or transform** rather than crossfade between two icons. See [[fly-not-teleport]].
        
        - Chevron up/down: rotate 180°.
        - Play/pause: replace via opacity *but* if you have the bandwidth, draw a single morphing path.
        - Sun/moon: morph via shared geometry (lucide-animated.com has examples).
        
        See [[animations-dev-curriculum]] for the canonical resource on icon animation.
        
        ## When to apply
        
        - Setting up a new design system: pick the pack, document it, lock it.
        - Reviewing a PR that introduces a new icon: check the pack source.
        - Auditing an existing UI: grep for `<svg>` elements from different packs.
        
        ## Gotcha
        
        Icon stroke is `stroke-width`, not `border-width`. Setting `stroke-width: 2` on a 16px icon yields a different visual weight than 2px on a 24px icon (the smaller icon looks heavier). Either scale stroke proportionally or use a variable-weight icon pack like Phosphor.
        
        ## Sources
        
        - guidelines.sh — "Never mix icon packs; match stroke to font weight; Lucide is overused."
        - lucide-animated.com — icon animation patterns.
        - Phosphor Icons — weight system documentation.
        
      • interaction-personality.md 5.7 KB
        ---
        title: interaction-personality
        summary: Unique microinteractions, easter eggs, contextual feedback, sound. Personality lives in how the product responds, not how it looks.
        tags: [components, interaction, personality, easter-eggs, sound]
        ---
        
        # Interaction personality
        
        Two products with identical visual design can feel completely different based on how they *respond* to the user. Personality lives in interactions, not in static screens.
        
        ## What "interaction personality" means
        
        - A hover state that does something specific to this product, not a generic 1px shift.
        - A success state that *celebrates*, even briefly, not a silent "Saved.".
        - An error state that explains in a voice (see [[forms-validation]] copy).
        - A button that has a tiny physical response (slight squish, subtle haptic on touch).
        - A loading state that informs ("Fetching your last 30 days…") instead of spinning anonymously.
        - An empty state that suggests a next step in the product's voice, not "No data."
        - Easter eggs — hidden, never-prompted small delights for users who explore.
        
        The total is what users mean when they say "the product feels alive."
        
        ## Easter eggs — when they're earned
        
        Easter eggs work when they:
        - Reward exploration without depending on it (no user must find them).
        - Match the product's tone (a financial app's easter egg should still be on-brand).
        - Are cheap to maintain (don't ship a complex hidden feature that breaks silently).
        - Appear at moments of natural curiosity (404 page, settings deep-dive, long-press, secret keyboard combo).
        
        Classic spots:
        - **404 page** — the most-explored "broken" page in any product. Most companies put a generic robot there. Put something specific.
        - **Long-press on logo** — credits, a thank-you, a small animation.
        - **Settings → About** — version number that flips on click, a hidden joke, a contributors list.
        - **Konami-code-style sequences** — for the truly curious.
        - **First-of-its-kind moments** — first project created, first 100th visit, etc.
        
        What easter eggs are NOT for:
        - Surprising users in the middle of a critical flow (signup, payment).
        - Adding personality the product otherwise lacks (you can't easter-egg your way out of bad core UX).
        
        ## Imperfect timing — varied micro-delays
        
        Generic animation has uniform timing — every button responds the same way, every hover state has the same duration. Products with personality often vary timing slightly:
        
        - Hover delays of 80ms on one component, 120ms on another, deliberately.
        - A "Save" button that responds at 100ms; a "Delete" button that responds at 200ms (the 100ms extra reads as deliberation, which is right for destructive actions).
        - A "Done" success state that holds for 800ms before fading; a "Continue" state that exits at 200ms.
        
        This is the opposite of design-tokens-make-everything-consistent. Tokens are right for *visual* properties. Timing has more room for narrative pacing.
        
        ## Contextual feedback
        
        Error messages, success states, and confirmations should reflect the user's specific action.
        
        | Generic | Contextual |
        |---|---|
        | "Saved." | "Saved your 3 changes." |
        | "Item deleted." | "Deleted project Atlas." |
        | "Network error." | "Couldn't reach our server. Trying again in 4s…" |
        | "Success!" | "Sent to 47 recipients." |
        
        Specificity costs you almost nothing and signals attention. The agent doing the action knows what was done — surface it.
        
        ## Sound design (use sparingly)
        
        Sound is a personality lever most products skip entirely. When done well:
        
        - Tied to *specific* interactions, not generic events. A send-message ping is fine; a beep on every button click is grating.
        - Designed by someone, not pulled from a stock library. Stock UI sounds are tells.
        - Off by default with a clear toggle. Most users will keep it off, but the ones who keep it on become attached.
        - Synced precisely to the visual animation. The sound and the motion are one event.
        
        Examples done well: Linear's command palette open sound, Apple's "swoosh" on Mail send (in earlier versions), Honk's [bespoke sound design tied to each interaction](https://benji.org/honkish).
        
        Examples done poorly: Microsoft Office's pre-2007 default sounds. Discord's default ping (lovable, but obviously stock).
        
        The sound cluster goes deeper: [[sound-decision-framework]] for whether, [[sound-palette]] for the family, [[sound-motion-sync]] for timing.
        
        ## When to apply
        
        - Consumer products where personality is a competitive advantage.
        - Brand-led products (financial apps with attitude, music apps, social apps).
        - Products with a strong founding voice that the team wants to preserve at scale.
        - Onboarding flows specifically — every first impression is a personality opportunity.
        
        ## When NOT to apply
        
        - B2B power-user productivity tools where consistency and predictability win. Adding personality to an analyst's dashboard slows them down.
        - Healthcare, finance, or other contexts where users want the product to be *calm* and reliable, not playful.
        - Anywhere the personality would conflict with the brand's primary value (a "professional" service shouldn't easter-egg about pizza).
        
        ## Gotcha
        
        Personality lives in restraint. A product with one well-placed easter egg and one specific success message feels alive. A product with ten easter eggs and animated success states everywhere feels exhausting.
        
        The Benji rule: *"A lifeless product feels like a dead product, and a dead product feels uncared for."* Add personality where it matters most, not everywhere.
        
        ## Sources
        
        - Benji Taylor — *Honkish*, [benji.org/honkish](https://benji.org/honkish).
        - guidelines.sh — Interaction Personality category (unique microinteractions, easter eggs, imperfect timing, contextual feedback, sound design).
        - Related: [[feeling-right]], [[delight-impact-curve]], [[forms-validation]], [[states-are-the-work]].
        
      • MOC-components.md 3.1 KB
        ---
        title: MOC-components
        summary: The atoms users actually touch — hover states, empty/loading states, icons.
        tags: [moc, components]
        ---
        
        # MOC — Components
        
        Components are where craft lives because users *touch* them. Their states are where most UIs get lazy.
        
        ## Nodes
        
        - [[hover-states-subtle]] — Hover is not lift-and-shadow. 1px shifts. Pressed states are for B2C. How to layer disabled.
        - [[empty-loading-states]] — Empty states must direct action, not just say "nothing here." Skeletons hint at structure; spinners under 3s; progress bars over 3s.
        - [[icon-systems]] — Never mix icon packs. 10–32px range. Match icon stroke to font weight. Lucide is overused; consider Phosphor, Hugeicons.
        - [[cards-design]] — Don't nest cards. Design card interiors intentionally — whitespace, hover-only actions, subtle icons.
        - [[forms-validation]] — Validate at input, not on submit. Soft validation while typing, hard validation on blur. Inline beats summaries.
        - [[avatar-systems]] — Procedural avatars (DiceBear), uploaded photos, initials. All 31 DiceBear v9.x styles with previews. Seeding strategy.
        - [[interaction-personality]] — Unique microinteractions, contextual feedback, easter eggs, audio when intentional. Personality is *how* the product responds.
        - [[accessibility-baseline]] — Keyboard-everywhere, `:focus-visible`, hit targets, ARIA names on icon buttons, polite aria-live. The floor below taste.
        - [[optimistic-updates]] — Update UI immediately on likely-success actions, reconcile or undo on failure. The largest perceived-latency win.
        - [[copy-voice]] — Active voice, Title Case for headings/buttons, numerals for counts, error messages that guide the exit.
        - [[component-confusables]] — Pick by behaviour, not looks: tooltip vs popover (can it hold a link?), badge vs tag (attached/read-only vs standalone/interactive), sheet vs drawer vs dialog.
        - [[forms-behavior]] — Everything a form does besides validating: label wiring, input types, 16px, focus-visible, decorations over the input, autofocus rules, Enter and Cmd+Enter, buttons that disable while submitting, no dead zones, confirmed destruction, prefill.
        - [[touch-and-focus]] — Hover enhances, never enables; 44px hit areas; `touch-action`; `playsinline`; labelled icon buttons; hidden content out of the tab order; focus moved and restored; the mobile-native fixes table.
        - [[ui-polish-pass]] — The finishing pass after it works: antialiasing, constant weight, tabular numbers, no layout shift, real characters, states that stay put, a z-index scale, optical alignment, designed empty states, no flash on refresh.
        - [[component-api-design]] — Composition over configuration, compound parts with context, variants → size → className → asChild, platform prop names, controlled and uncontrolled, refs forwarded and props spread.
        
        ## Cross-cluster
        
        - For button *motion*, see [[responsive-feedback]] in [[MOC-motion]].
        - For icon *animation*, see [[animations-dev-curriculum]] (Emil's course) and [[transform-opacity-only]].
        - For *tray* / sheet UI, see [[tray-rules]] (Benji's six rules).
        - For *page-level* layout (grids, viewports), see [[MOC-layout]].
        
      • optimistic-updates.md 4 KB
        ---
        title: optimistic-updates
        summary: Update UI immediately on likely-success actions, reconcile or undo on failure. The fastest possible perceived latency.
        tags: [components, ux, latency, mutations]
        ---
        
        # Optimistic updates
        
        When the user takes an action with high success probability — toggling a like, marking complete, renaming — update the UI *immediately*. Don't wait for the server. Reconcile when the server responds; undo if it failed.
        
        This is the single largest perceived-latency improvement available without changing any infrastructure.
        
        ## The pattern
        
        ```ts
        // Pessimistic — felt latency is the full server round-trip (~200–500ms)
        const onToggleLike = async () => {
          await api.toggleLike(post.id);          // wait
          setLiked(!liked);                       // then update
        };
        
        // Optimistic — felt latency is zero
        const onToggleLike = async () => {
          const previous = liked;
          setLiked(!liked);                       // update immediately
          try {
            await api.toggleLike(post.id);        // fire-and-confirm
          } catch (err) {
            setLiked(previous);                   // rollback on failure
            toast.error("Couldn't update. Try again.");
          }
        };
        ```
        
        The user perceives the action as instant. The server confirms (or denies) silently.
        
        ## When to use optimistic updates
        
        - **Toggles** with high success rate: like, bookmark, follow, mute.
        - **Marking complete** in a to-do list.
        - **Rename** with reasonable validation already client-side.
        - **Reorder** in a list.
        - **Delete** when paired with Undo (see below).
        
        ## When NOT to use
        
        - **Multi-step transactions** (payments, bookings) — confirmation is the design.
        - **Destructive actions without Undo** — see below.
        - **Actions where the server's response *changes* the UI in a non-trivial way** (e.g., posting a comment that triggers a complex moderation flow).
        - **First-time actions in a flow** where failure is informative (sign-up, login).
        
        ## Pair destructive actions with Undo
        
        For deletes, archives, and irreversible-feeling actions, the optimistic pattern is paired with an Undo toast:
        
        ```tsx
        const onDelete = async (item) => {
          const previous = items;
          setItems(items.filter(i => i.id !== item.id));   // remove immediately
          const { undo } = toast.success("Deleted", {
            action: { label: "Undo", onClick: () => setItems(previous) },
            duration: 5000,
          });
          try {
            await api.delete(item.id);                     // happens during toast
          } catch {
            setItems(previous);
            toast.error("Couldn't delete. Restored.");
          }
        };
        ```
        
        The user gets instant feedback. The "real" delete happens during the 5-second toast window. Undo cancels the in-flight delete.
        
        Gmail's "Send" with an undo window is the canonical example.
        
        ## Reconciliation strategy
        
        When the server response includes a server-generated value (e.g., a new `id`, a `created_at`), your optimistic value needs to be replaced. Two approaches:
        
        1. **Temporary client ID, then replace.** Generate a `temp_<uuid>` on the client; on server response, swap to the real id.
        2. **Server-side ID generation pre-fetched.** Some APIs let you fetch an ID up front; use it for the optimistic value so reconciliation is a no-op.
        
        ## Gotcha
        
        Don't optimistically update if the action depends on server-side validation that the client can't replicate (e.g., "is this username available"). Showing success then rolling back is worse than showing a 200ms spinner. Use optimistic only when *you already know* the action will succeed.
        
        Also: optimistic updates that are not visually reversible (e.g., scrolling a list to a new item that was deleted on rollback) feel disorienting. Test the rollback path explicitly.
        
        ## Sources
        
        - Vercel Web Interface Guidelines — "Optimistic updates," "No dead ends," "Provide Undo for destructive actions."
        - Sebastian Markbåge — "Optimistic UI" patterns in React.
        - Gmail's Undo Send (the canonical Undo-as-design example).
        - Related: [[empty-loading-states]] (the alternative — when not to optimistically update), [[responsive-feedback]] (other perceived-latency wins), [[forms-validation]].
        
      • touch-and-focus.md 4.2 KB
        ---
        title: touch-and-focus
        summary: Hover enhances, never enables. 44px hit areas by pseudo-element, touch-action set on purpose, muted + playsinline, aria-label on icon buttons, hidden content out of the tab order, focus moved in and restored, timers paused when hidden, tooltip warm state, submenu safe area — plus the mobile-native fixes table.
        tags: [components, touch, accessibility, focus, ios, keyboard]
        ---
        
        # Touch and focus
        
        A large share of users experience the interface through a thumb, a keyboard, or a screen reader — never a mouse. UI that only works with a fine pointer and perfect vision is broken UI however polished it looks. [[accessibility-baseline]] states the floor; this node is the implementation.
        
        ## The rules
        
        1. **Gate hover behind capability**: `@media (hover: hover) and (pointer: fine)`. Touch fires `:hover` on tap and leaves it stuck. Nothing core is reachable only by hover.
        2. **Tap targets ≥ 44px** (40px desktop). Keep the visual small and grow the hit area with a pseudo-element (`inset: -10px` on a 24px icon = 44px). Hit areas never overlap; shrink to the largest non-colliding size.
        3. **`touch-action: manipulation`** on buttons, links, inputs (kills the double-tap-zoom delay); **`touch-action: none`** on surfaces that implement their own pan or drag so native gestures don't fight yours.
        4. **Autoplaying video needs `muted` and `playsinline`**, or iOS refuses or goes fullscreen. Under `prefers-reduced-motion`, swap autoplay for a poster and a play button.
        5. **Every icon-only button has an `aria-label`** that names the action ("Close dialog", never "icon"). Code-built illustrations get `role="img"` + `aria-label`, or `aria-hidden` when decorative.
        6. **Hidden content leaves the tab order**: `visibility: hidden`, `display: none`, or `inert` — never `opacity: 0` or an off-screen transform. Focus must never land off-screen; scroll focused elements into view.
        7. **Overlays move focus in and give it back.** Open: focus the first control or the dialog. Close: return focus to the trigger. Never strand focus on a removed node.
        8. **Timers pause when the tab is hidden** (`visibilitychange`), resuming with the remaining time.
        9. **Shortcuts show the user's OS**: Cmd on macOS, Ctrl elsewhere, bound to match.
        10. **Feedback is on the page.** Errors, confirmations, and status never live only behind hover or inside a modal the user has to find.
        
        ## Tooltips and menus
        
        A tooltip waits ~200ms so it doesn't fire on incidental travel; once one is open, siblings open **instantly** with no delay and no entrance (the warm state), clearing ~300ms after the last closes — see [[responsive-feedback]]. Submenus get a triangular **safe area** (`clip-path` on a pseudo-element) so a diagonal cursor path toward the submenu doesn't close it mid-flight.
        
        ## Mobile-native fixes
        
        | Problem | Fix |
        |---|---|
        | Hover state stuck after tap | `@media (hover: hover) and (pointer: fine)` |
        | Gray or blue flash on tap | `-webkit-tap-highlight-color: transparent` |
        | Layout has the wrong height | `100dvh` (app) or `100svh` (hero), never `100vh` |
        | Page zooms into an input | Input font-size ≥ 16px |
        | Tap feels laggy | Feedback on pointer-down + `touch-action: manipulation` |
        | Pull-to-refresh hijacks scroll | `overscroll-behavior: none` on `html, body` |
        | Content stops at the notch | `viewport-fit=cover` + `env(safe-area-inset-*)` |
        | Long-press selects button text | `user-select: none` on control labels |
        | Carousel scrolls vertically | `touch-action: pan-y` on the gesture surface |
        | Status bar color doesn't match | `theme-color` meta per color scheme |
        | Right in Chrome, wrong on the phone | Test on real hardware |
        
        ## When to apply
        
        Any interactive UI — buttons, modals, tooltips, menus, video, timers — and every mobile bug report. Run it even when the screen already looks finished: a failure here ships broken for someone.
        
        ## Gotcha
        
        Tabbing into a closed drawer is the classic miss. It was hidden with `opacity: 0` or `translateX(100%)`, which keeps every control focusable; the keyboard user lands inside a thing they cannot see.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on touch and accessibility, distilled by HKTITAN.
        - Related: [[accessibility-baseline]], [[hover-states-subtle]], [[prefers-reduced-motion]], [[forms-behavior]].
        
      • ui-polish-pass.md 4 KB
        ---
        title: ui-polish-pass
        summary: The invisible finish, as a pass — antialiasing, constant weight on interaction, tabular numbers, no layout shift, real characters, states that stay put, a z-index scale, hit areas, optical alignment, native page scrollbars, designed empty states, inert decoration, no flash on refresh.
        tags: [components, polish, finish, layout-shift, states]
        ---
        
        # The UI polish pass
        
        A finished interface is mostly invisible work: text that renders crisply, numbers that don't jiggle, hover states that don't shift layout, focus rings that don't clash, hit areas you never miss. No single item is noticeable on its own; their absence is. Run this as a pass over every component *after it works* and *before* [[review-checklist]] gates it. If the pass finds the structure itself is wrong, escalate to [[feeling-right]].
        
        ## The pass
        
        1. **Antialiased smoothing once at the root**; fonts subsetted.
        2. **Never change font weight on hover, active, or selected.** Weight reflows the text. Hold weight; signal with color.
        3. **`tabular-nums` on anything that changes** — counters, prices, timers.
        4. **No layout shift from dynamic content.** Skeletons, placeholders, and images carry the dimensions (or `aspect-ratio`) of the loaded state.
        5. **Real typographic characters** and `text-wrap: balance` on headings — see [[line-behavior]].
        6. **Interaction states that stay put.** Hover transitions only the properties that change, ~150ms ease-out. Press is `scale(0.96)`, never below 0.95. Focus rings stay neutral (grey, black, white) and never disappear. `::selection`, if styled, stays a legible tint. `will-change` only for transform/opacity/filter, only after observing a first-frame hitch.
        7. **A fixed z-index scale** (`--z-dropdown: 100`, `--z-modal: 200`, `--z-tooltip: 300`, `--z-toast: 400`) — or avoid it with `isolation: isolate` so children can't leak above unrelated UI.
        8. **Hit areas ≥ 44×44 touch / 40×40 desktop**, extended with a pseudo-element, never overlapping — see [[touch-and-focus]].
        9. **Align by eye.** Trailing-icon padding = text-side padding − 2px; play triangles nudge 1–2px toward the point; asymmetric SVGs are fixed in the viewBox.
        10. **Native scrollbar on the page.** Customize only inside small scrollers (code blocks): 8px, translucent rounded thumb.
        11. **Empty states teach**: headline, one line, primary action — sized to the filled state so completing the action doesn't shift the layout. See [[empty-loading-states]].
        12. **Decorative elements are inert**: `pointer-events: none`, `user-select: none`.
        13. **No flash on refresh.** Persisted interactive state is set before first paint, never default-then-correct.
        14. **Anchors clear the sticky header** (`scroll-margin-top`); fixed bottom chrome pads `env(safe-area-inset-bottom)`.
        
        ## Frequent offenders
        
        | Problem | Remedy |
        |---|---|
        | Bold-on-hover tabs shift layout | Constant weight, color signals state |
        | Digits jiggle | `font-variant-numeric: tabular-nums` |
        | Content jumps as images load | Explicit dimensions or `aspect-ratio` |
        | `scale(0.9)` on press | `scale(0.96)` |
        | Brand-colored focus ring | Neutral ring |
        | `z-index: 9999` | Scale, or `isolation: isolate` |
        | 20px icon with a 20px hit area | Pseudo-element to 44px |
        | Custom page scrollbar | Native; customize only small scrollers |
        | Blank div for an empty list | Headline + line + action, sized like the filled state |
        | Theme flashes on refresh | Persist + set before render |
        
        ## When to apply
        
        The build works and now needs the last ten percent; "feels unfinished" with no single dimension obviously wrong; before any review. [[disambiguation]] routes *looks right* to [[feeling-right]], *feels finished* here, *should ship* to [[review-checklist]].
        
        ## Gotcha
        
        Polish applied to the wrong structure is thrown away with the structure. If hierarchy or spacing is wrong, this pass is premature; fix the layout, then polish.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on polish, distilled by HKTITAN.
        - Related: [[review-checklist]], [[responsive-feedback]], [[touch-and-focus]], [[line-behavior]], [[depth-and-nesting]].
        
    • layout
      • marketing-surface-rules.md 3.7 KB
        ---
        title: marketing-surface-rules
        summary: Marketing pages earn a bigger motion budget and still overspend it. Motion maps to input, intros play once per session, everything pre-renders, fonts and hero images preload, nav content stays in the DOM, CTAs know who is looking, docs are for copying, blogs and changelogs are feeds.
        tags: [layout, marketing, landing, docs, blog, performance]
        ---
        
        # Marketing surface rules
        
        [[marketing-vs-product-ui]] says *why* marketing tolerates more expression. This node is the build checklist: what a landing page, blog, docs site, or changelog has to do to be fast, readable, and honest. A visitor sees the page once, not a hundred times a day, so the animation budget is larger; it is still small, and restraint is the house style.
        
        ## Motion
        
        - **Motion maps to user input.** No scroll-triggered fade-ups, no scroll hijacking, no parallax that isn't 1:1 with scroll, no auto-advancing carousels. If the user didn't cause it, cut it.
        - **Intro animations play once per session.** Gate hero reveals and logo sequences with `sessionStorage` so internal navigation skips them but a genuinely new visit sees them. `localStorage` would kill them forever.
        - Hover-revealed content still obeys [[touch-and-focus]]: gated, and never the only route.
        
        ## Performance
        
        - **Pre-render everything.** Blog, docs, changelog — build-time generation with revalidation, never request-time fetching.
        - **Kill layout shift at the source.** Preload fonts (`<link rel="preload" as="font" type="font/woff2" crossorigin>`) and above-the-fold images; lazy-load the rest. See [[performance-discipline]].
        
        ## Structure
        
        - **Content lives in the DOM.** Hover-revealed nav submenus are visually hidden, not mounted on hover — crawlers and assistive tech need the real markup.
        - **CTAs know who's looking.** Logged-out: "Get started". Logged-in: "Open app". Never "Sign up" to a signed-in user.
        - **First viewport**: brand, one headline, one supporting line, one CTA group, one dominant visual. No card soup, stat strips, or floating promo chips — see [[unslop-pass]].
        - **Code-built illustrations** get `role="img"` + `aria-label`, `user-select: none`, and `pointer-events: none` unless interactive.
        
        ## Docs, blogs, changelogs
        
        - **Docs are for copying.** A copy button on every snippet; every page exportable as markdown (a "Copy as Markdown" button and `.md` URLs such as `/docs/getting-started.md`) — people and the models reading your docs both want it; a visual example for every concept.
        - **Blogs and changelogs are feeds.** RSS at predictable paths (`/blog/rss.xml`, `/changelog/rss.xml`); `text-wrap: balance` on article headings.
        
        ## Pre-ship checklist
        
        - [ ] No scroll-triggered animation, hijacking, non-1:1 parallax, or auto-carousel
        - [ ] Intro gated behind `sessionStorage`
        - [ ] Fonts and hero image preloaded; no shift on load
        - [ ] Content pages statically generated with revalidation
        - [ ] Nav submenu content present in the DOM when closed
        - [ ] CTAs switch copy and destination on auth state
        - [ ] Snippets have copy buttons; pages export as `.md`; concepts have visual examples
        - [ ] RSS live; headings balanced
        - [ ] Code illustrations labeled, unselectable, inert
        - [ ] The [[ai-default-tells]] deletion pass has run
        
        ## When to apply
        
        Creating or reviewing any public-facing page, and any time parallax, a carousel, or a scroll animation is tempting.
        
        ## Gotcha
        
        "Marketing tolerates more motion" is read by agents as "marketing wants more motion". The permitted extra is one first-run hero moment. A page where every section animates in is the AI-default look, not expression.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on marketing pages, distilled by HKTITAN.
        - Related: [[marketing-vs-product-ui]], [[performance-discipline]], [[unslop-pass]], [[viewport-custom-design]].
        
      • MOC-layout.md 1.2 KB
        ---
        title: MOC-layout
        summary: Page-level layout, grids, viewports, sticky and scroll tells.
        tags: [moc, layout]
        ---
        
        # MOC — Layout
        
        The page-level container, not the component level. When designing a screen — especially marketing screens — start here.
        
        ## Nodes
        
        - [[viewport-custom-design]] — Each viewport gets its own design, not just a scaled version of desktop. The "narrow central column on blog content" pattern.
        - [[sticky-and-scroll-tells]] — Background-blur on sticky sections is an AI giveaway. Scroll hijacking is almost always wrong. What to do instead.
        - [[url-as-state]] — Filters, tabs, pagination, panels — all live in the URL. Back/forward restores scroll. Deep-link everything.
        - [[marketing-surface-rules]] — The landing / docs / blog / changelog checklist: motion maps to input, intros once per session, pre-render, preload, nav content in the DOM, auth-aware CTAs, copy buttons and `.md` URLs, RSS.
        
        ## Cross-cluster
        
        - For *component-level* layout (cards, modals), see [[MOC-components]].
        - For the marketing-vs-product distinction that determines layout discipline, see [[marketing-vs-product-ui]].
        - For when to break the grid, see [[visual-imperfection]].
        
      • sticky-and-scroll-tells.md 4.8 KB
        ---
        title: sticky-and-scroll-tells
        summary: Background-blur on sticky sections is an AI tell. Scroll hijacking confuses users. Native scroll usually wins.
        tags: [layout, sticky, scroll, anti-pattern]
        ---
        
        # Sticky and scroll tells
        
        Two of the most overused (and easiest-to-misuse) page-level patterns: blurred sticky nav, and scroll hijacking. Both signal "AI-generated landing page" when applied generically.
        
        ## Blurred sticky nav — the AI tell
        
        ```css
        /* The pattern that gives it away */
        .nav {
          position: sticky;
          top: 0;
          background: rgba(255, 255, 255, 0.7);
          backdrop-filter: blur(12px);
        }
        ```
        
        This pattern is fine *in moderation* and *with context-specific tuning*. But the default-AI-output version is:
        - 12px blur (too strong).
        - 70% white background (too opaque).
        - Universal application (sticks on every page, even pages where it shouldn't).
        - No border-bottom for separation (so scrolled content bleeds under the nav awkwardly).
        
        The result: every AI-generated landing page in 2025–2026 has the same blurred nav. It's become a *tell*.
        
        ## What to do instead
        
        Pick one of these:
        
        ### A. Solid sticky with subtle separation
        
        ```css
        .nav {
          position: sticky;
          top: 0;
          background: var(--bg-base);
          border-bottom: 1px solid var(--border-default);
        }
        ```
        
        Cleaner, faster (no GPU blur), no AI smell.
        
        ### B. Hide on scroll-down, show on scroll-up
        
        ```ts
        let lastY = 0;
        window.addEventListener('scroll', () => {
          const y = window.scrollY;
          document.querySelector('.nav').style.transform =
            y > lastY && y > 100 ? 'translateY(-100%)' : 'translateY(0)';
          lastY = y;
        });
        ```
        
        The nav appears when the user wants it (scrolling up, presumably to navigate) and stays out of the way otherwise.
        
        ### C. No sticky at all
        
        For long-form content (articles, docs), the nav can simply scroll off-screen. The user scrolls back to top when they want nav. This is the calmest option.
        
        ### D. Blur — but tuned
        
        If you genuinely want the blur effect, tune it:
        - `backdrop-filter: blur(6px)` (not 12px).
        - `background: rgba(var(--bg-base-rgb), 0.85)` (matches your palette, not pure white).
        - Add `border-bottom: 1px solid var(--border-default)` for clean separation.
        - Apply *only* on marketing pages, not in-product.
        
        ## Scroll hijacking — avoid
        
        "Scroll hijacking" = the page intercepts the user's scroll input and does something other than scroll (snap to sections, trigger animations, change layout). Examples:
        
        - `scroll-snap-type` on a long page where the user wants to scroll freely.
        - Parallax that decouples scroll speed from visual movement.
        - Section-by-section snap navigation that prevents the user from skimming.
        - "Scroll-jacking" entrance animations that lock the scroll until they complete.
        
        These confuse users because the OS scroll feels broken — the same gesture produces different results in different contexts.
        
        ## When scroll-snap *is* the right answer
        
        Limited cases:
        
        - **Horizontal carousels** — scroll-snap-x on a single-row carousel is the right answer. The user expects each card to land aligned.
        - **Image galleries with explicit slides** — vertical scroll-snap on a presentation-style page with discrete slides.
        - **Story-driven onboarding** — first-time experiences where you specifically want each "step" to land precisely.
        
        In all these cases, the *vertical page scroll* should still be free. Snap only the contained scroll, not the page scroll.
        
        ## Scroll-driven animations — judiciously
        
        `scroll-driven animations` (the new CSS feature) are powerful and worth knowing. They let you scrub an animation as the user scrolls — e.g., a hero image scales up as the user enters its viewport.
        
        The right uses:
        - **Reveal-on-scroll** — content fades/scales in as it enters the viewport. Subtle.
        - **Progress indicators** — a top-of-page progress bar fills as the user scrolls.
        - **Parallax (gentle)** — backgrounds move at 0.7x scroll speed for depth. NEVER 0.3x or below (causes nausea).
        
        The wrong uses:
        - Anything that *changes the scroll behavior* itself.
        - Anything that *slows* the user's perceived scroll.
        - Anything that animates as the user scrolls *up* differently than down — feels broken.
        
        ## When to apply
        
        - Designing any landing page with a top nav.
        - Reviewing a PR that adds sticky behavior or scroll-driven animation.
        - Auditing for AI-default tells ([[ai-default-tells]]).
        
        ## Gotcha
        
        `backdrop-filter: blur()` is GPU-cheap but interacts weirdly with `position: sticky` and `overflow: hidden` parents in some browsers. Always test on Safari + Firefox before shipping a blurred sticky nav.
        
        Also: `scroll-snap` on iOS Safari can interact poorly with the URL bar collapsing. Test on real iPhone before shipping snap-based layouts.
        
        ## Sources
        
        - guidelines.sh — "Background blur on sticky sections is an AI giveaway" + "Avoid scroll hijacking."
        - web.dev — scroll-driven animations docs.
        - Related: [[viewport-custom-design]], [[ai-default-tells]], [[transform-opacity-only]].
        
      • url-as-state.md 3.3 KB
        ---
        title: url-as-state
        summary: Filters, tabs, pagination, panels — all live in the URL. Back/forward restores everything including scroll.
        tags: [layout, url, state, deep-linking]
        ---
        
        # URL as state
        
        The URL is the single source of truth for *visible* UI state. Filters, sort, current tab, expanded panel, open modal, pagination cursor — all of it lives in query params or the path.
        
        The reason: a URL you can share is a feature. A URL you can bookmark is a feature. A URL the back button can restore to *exactly the same view* is a feature.
        
        ## The rule
        
        Anything a user can see right now should be reconstructable from the URL. If they paste the URL into a new tab, they land on the same view — same tab open, same filters applied, same row expanded.
        
        ## What goes in the URL
        
        | State | Where | Example |
        |---|---|---|
        | Current page | path | `/projects/acme/issues` |
        | Tab selection | query param | `?tab=closed` |
        | Filters | query param (comma-separated) | `?status=open,blocked&assignee=me` |
        | Sort | query param | `?sort=-updated_at` |
        | Pagination cursor | query param | `?cursor=eyJpZCI6...` |
        | Open panel / modal | query param | `?panel=settings` or `?modal=invite` |
        | Search query | query param | `?q=auth+bug` |
        
        ## What does NOT go in the URL
        
        - Form draft state (use local storage).
        - Auth state (cookies / session).
        - Transient UI feedback (toast visibility).
        - Cursor position inside text fields.
        - Hover/focus state.
        
        ## Back/forward must restore scroll
        
        When the user navigates back, the scroll position restores to where they were — automatically for `<a href>` and `history.pushState`, broken if you intercept with `e.preventDefault()` and don't restore.
        
        ```ts
        // Save scroll on navigation away
        useEffect(() => {
          return () => sessionStorage.setItem(`scroll:${pathname}`, String(window.scrollY));
        }, [pathname]);
        
        // Restore on mount
        useEffect(() => {
          const saved = sessionStorage.getItem(`scroll:${pathname}`);
          if (saved) window.scrollTo(0, parseInt(saved));
        }, [pathname]);
        ```
        
        Most SPA routers (Next.js, React Router) do this automatically — verify yours does. If you've broken it with a custom scroll lock, fix the scroll lock.
        
        ## Deep-link every modal
        
        A modal at `?modal=invite-team` should be openable directly via that URL. If a user shares "look at this invite flow," they should be able to paste the URL and land on the open modal.
        
        Don't put modals in component state when they could be in URL state. The cost is one query param; the gain is shareability.
        
        ## When to apply
        
        Every page-level state change. Filters, tabs, sort, search, modals, expanded rows. If you find yourself reaching for `useState` for something a user can *see*, ask if it should be in the URL instead.
        
        ## Gotcha
        
        URL state and component state can drift. Use a single source — read from the URL (via `useSearchParams` or equivalent), write through the router. Don't mirror URL state into local state — that's a bug surface.
        
        Also: long URLs (more than ~2000 chars) break in some places. If your URL state is getting that big, compress it (base64-encoded JSON) or move some of it to a server-side state (with a short URL key).
        
        ## Sources
        
        - Vercel Web Interface Guidelines — "URL as state," "Deep-link everything," "Scroll positions persist."
        - Related: [[states-are-the-work]] (each URL state is a real state to design), [[viewport-custom-design]].
        
      • viewport-custom-design.md 4.2 KB
        ---
        title: viewport-custom-design
        summary: Each viewport gets its own design, not just a scaled version of desktop. Narrow columns are valid. Break the grid for delight.
        tags: [layout, responsive, viewport]
        ---
        
        # Viewport-custom design
        
        "Responsive" doesn't mean scaling desktop. It means designing each viewport with intent.
        
        ## What this means
        
        The default workflow:
        1. Designer builds desktop in Figma.
        2. Engineer "makes it responsive" by setting flex/grid breakpoints.
        3. Mobile looks like a thinner version of desktop.
        
        This produces *technically* responsive UI that feels designed for one viewport and bent to fit others. The fix is designing each major viewport (mobile, tablet, desktop, ultra-wide) with its own layout intent.
        
        For mobile, this often means a *completely different* layout — not a single-column stack of desktop elements. For example:
        
        | Desktop | Mobile |
        |---|---|
        | Sidebar + main content | Bottom tab bar + full-width content |
        | 3-column grid of cards | Horizontal-scroll carousel or vertical stack with different priority |
        | Wide hero with image right | Stacked hero with image full-width below |
        | Dense table | Card-per-row with key fields prioritized |
        
        ## Narrow columns are valid
        
        A common mistake: making content full-width because the viewport allows it. A 2400px-wide article is unreadable; 65 characters per line is the sweet spot for prose ([[line-length-tracking]]).
        
        ```css
        .article {
          max-width: 65ch; /* or 720px */
          margin: 0 auto;
          padding: 0 24px;
        }
        ```
        
        Blog content, documentation, long-form reading — these often benefit from a narrow central column even on a 4K monitor. Don't fill space just because it's there.
        
        ## 12-column grids as foundation
        
        The 12-column grid is the most flexible base because 12 divides into 1, 2, 3, 4, 6, 12. You can have a one-third / two-thirds split, a quarter / three-quarters, a six-column gallery, etc.
        
        ```css
        .grid {
          display: grid;
          grid-template-columns: repeat(12, 1fr);
          gap: 24px;
        }
        .hero { grid-column: span 12; }
        .feature { grid-column: span 4; }
        .sidebar { grid-column: span 3; }
        .main { grid-column: span 9; }
        ```
        
        Use this as the foundation. *Then* break it intentionally for delight.
        
        ## Break the grid (deliberately)
        
        Small graphical elements that escape the grid signal craft — a quote with a hanging punctuation mark in the left margin, an illustration that overlaps a section divider, a callout that bleeds outside the column.
        
        This is a marketing-page move ([[visual-imperfection]]). In-product, hold the grid.
        
        ## Bento layouts (the modern landing-page pattern)
        
        Bento boxes — irregular grid layouts where tiles vary in size and feature their own micro-content — are a 2024–2026 landing-page staple:
        
        ```
        [ tall feature ] [ wide visual    ]
                         [ wide visual    ]
        [ small ] [ small ] [ small      ]
        [ wide quote that bridges 3 cols  ]
        ```
        
        When done well, each tile is self-contained, hierarchy is clear from size alone, and the overall layout reads as composed (not random). When done poorly, it reads as a Bootstrap grid that someone broke.
        
        ## When to apply
        
        - Designing any new screen — start with the viewport-specific layouts, not a single desktop comp.
        - Reviewing a "responsive" PR — ask whether mobile was designed or just scaled.
        - Landing pages and marketing pages — bento and break-the-grid live here.
        
        ## When NOT to apply
        
        - Forms — single-column, top-to-bottom, no exceptions.
        - Dashboards with comparable widgets — symmetric grid wins for scannability.
        - Content the user reads (long articles, docs) — narrow central column.
        
        ## Gotcha
        
        Don't apply marketing-grade asymmetry to product UI. Asymmetric grids in a settings page look broken; in a hero, they look intentional. See [[marketing-vs-product-ui]].
        
        Also: viewport-custom design isn't an excuse to ship inconsistency. The *components* should be consistent across viewports; the *composition* of components varies. A button looks the same on mobile and desktop; the screen they're on differs.
        
        ## Sources
        
        - guidelines.sh — "Design for each viewport" + "Narrow columns are valid" + "12-column grids" + "Break the grid for delight."
        - Bento Grids (bentogrids.com) — modern landing-page bento examples.
        - Related: [[sticky-and-scroll-tells]], [[marketing-vs-product-ui]], [[visual-imperfection]].
        
    • meta
      • agentation-workflow.md 6.4 KB
        ---
        title: agentation-workflow
        summary: Install Agentation in localhost dev. Click any element to annotate. Two-session workflow: critique loop in Session 1, fix loop in Session 2.
        tags: [meta, agentation, workflow, design-review]
        ---
        
        # Agentation workflow
        
        [Agentation](https://www.agentation.com) (by Benji Taylor, [github.com/benjitaylor/agentation](https://github.com/benjitaylor/agentation)) is the productized version of the [[pointing-beats-describing]] principle. It mounts a toolbar in your dev environment that lets you click on any element on the page, write a short critique, and emit structured annotations the agent can read.
        
        This skill **recommends installing Agentation** alongside Obsidian for any team doing AI-assisted UI work. See the README's "Recommended companions" section.
        
        ## Install (Next.js example)
        
        ```bash
        npm install --save-dev agentation
        ```
        
        ```tsx
        // app/layout.tsx (App Router)
        import { Agentation } from "agentation";
        
        export default function RootLayout({ children }) {
          return (
            <html>
              <body>
                {children}
                {process.env.NODE_ENV === "development" && <Agentation />}
              </body>
            </html>
          );
        }
        ```
        
        Mount under `NODE_ENV === "development"` so the toolbar never ships to production.
        
        For non-Next setups, install the [`agentation`](https://www.npmjs.com/package/agentation) skill itself — it has framework detection:
        
        ```bash
        npx skills add benjitaylor/agentation
        ```
        
        This adds an `agentation` skill to your agent's library that knows how to install the React component in your specific framework (Next.js App Router, Pages Router, Vite, Astro, Remix).
        
        ## Add the MCP server (optional but recommended)
        
        The MCP server lets your agent **read annotations directly** instead of having you copy-paste them. Two install paths:
        
        ```bash
        # Universal — works with Claude Code, Cursor, Codex, Gemini CLI, 9+ agents
        npx add-mcp agentation
        
        # Claude Code only (slightly more features)
        npx agentation-mcp init
        ```
        
        With MCP installed, the agent can call `agentation_watch_annotations` and read every annotation in real time as you create them.
        
        ## Manual workflow (no MCP)
        
        1. Run your dev server.
        2. The Agentation toolbar appears bottom-right.
        3. Click "Annotate" → click any element on the page.
        4. Type 2–3 sentences of critique. Be specific and reference principles (visual hierarchy, Gestalt grouping, whitespace). Cite comparable products (Stripe, Linear, Vercel).
        5. Submit. Agentation outputs structured markdown — selectors, file paths, computed styles, your critique.
        6. Copy the output into Claude Code / Cursor / your agent of choice.
        
        ## Two-session workflow (with MCP)
        
        With MCP enabled, you can run two agent sessions in parallel:
        
        ```text
        ┌──────────────────────────┐         ┌──────────────────────────┐
        │   Session 1: Critique    │         │    Session 2: Fix        │
        │                          │         │                          │
        │ - Open localhost in      │         │ - Watches for new        │
        │   headed agent-browser   │         │   annotations via MCP    │
        │ - Scrolls through pages  │  ───►   │ - Reads each annotation  │
        │ - Adds 5–8 annotations   │         │ - Edits code to address  │
        │   per page autonomously  │         │ - Commits + pushes       │
        │ - Names the principle    │         │ - Loops to next          │
        │   in each annotation     │         │                          │
        └──────────────────────────┘         └──────────────────────────┘
        ```
        
        This separates the **critique loop** from the **fix loop**. Each session has a single, focused job. The annotation is the API between them — the same selector + critique that a human would have written.
        
        Use this when you want fully autonomous design review without human-in-the-loop on every annotation. Add human-in-the-loop on PR review of the resulting commits, not on each annotation.
        
        ## Critique style
        
        From Agentation's own skill:
        
        - **2–3 sentences max** per annotation. Longer reads as essay; shorter reads as too vague.
        - **Specific + actionable**. Bad: "this section needs work." Good: "Use a 3-column card grid with icons — similar to Stripe's guidelines pattern."
        - **Name the principle**. Visual hierarchy. Gestalt grouping. Whitespace. F-pattern scanning. These vocabulary words are how the agent looks up the right pattern.
        - **Reference comparable products**. "Like Stripe", "like Linear's command bar", "like Vercel's monochrome dashboard."
        - **Link the principle node** when applicable. `[[hover-states-subtle]]`, `[[shadows-whisper]]`, `[[color-monochromatic]]`.
        
        ## When to apply
        
        - Any team using an AI coding agent on UI work.
        - Solo developers who want structured design-review on their own work.
        - Design engineers who want to give precise feedback to PMs / engineers without writing essays.
        
        ## When NOT to apply
        
        - Production environments — Agentation is dev-only.
        - Pre-launch marketing pages — at low frequency, manual pointing is faster than setting up the toolbar.
        - Components without a localhost preview — Agentation needs a running app to click on.
        
        ## Gotcha
        
        Annotation selectors break when the DOM changes. If you annotate `.product-card > div:nth-child(3)` and someone reorders the divs, the annotation now points at the wrong element. Prefer `data-testid` or stable class names. Audit annotations weekly — old broken ones add noise without signal.
        
        Also: Agentation's license is PolyForm Shield 1.0.0 (non-permissive — restricts use by competing products). Read the license before adopting in a commercial product that might overlap with Agentation's space.
        
        ## Sources
        
        - [agentation.com](https://www.agentation.com) — product site.
        - [github.com/benjitaylor/agentation](https://github.com/benjitaylor/agentation) — source, MCP server, skill.
        - [skills.sh/benjitaylor/agentation/agentation](https://www.skills.sh/benjitaylor/agentation/agentation) — installable skill.
        - Benji Taylor — [benji.org/annotating](https://benji.org/annotating), [benji.org/agentation](https://benji.org/agentation).
        - Related: [[pointing-beats-describing]] (the underlying principle), [[review-format]] (the markdown table format Agentation's output pairs with), [[using-design-md]] (companion workflow for design-token guidance).
        
      • build-a-tool.md 4.6 KB
        ---
        title: build-a-tool
        summary: When prompting for an artifact keeps landing on "closer, but not quite", stop iterating on the output and build the small tool that produces it — diagnose the gap, pitch the tool, put every judgment on a live control, save writes a file, harvest the config.
        tags: [meta, tooling, workflow, process]
        ---
        
        # Build a tool to build the thing
        
        Prompting for an intricate result is a slot machine: every attempt costs a round trip, lands "close", and gives the user no way to steer except rewriting the prompt. A tool converts that loop into knobs — the process is built once and the taste decisions become adjustments the user makes by eye in seconds. Two halves in strict order: **diagnose, then build.** Skipping the diagnosis produces a generic playground.
        
        ## Diagnose the gap
        
        Answer from the conversation; ask only for what you can't infer.
        
        - **What actually ships?** Name the artifact and where it lives. The tool is never it.
        - **Why can't you build it directly?** Classify:
        
        ```text
        Why does prompting keep missing?
        ├── Judgment only the user's eye can make (density, balance, "when it feels right")
        │   → dial-in tool: rebuild the process, bind every taste value to a control
        ├── Must match something that exists (a design, another app's output, a reference)
        │   → matching tool: reproduce the process that made the reference, render beside it at ship size
        ├── Exists in the wrong form (right pixels, wrong format)
        │   → converter: ingest the source, export exactly what the project needs
        ├── Can only be judged in a state that is slow to reach (a screen, a dataset, a moment)
        │   → stage: a route that puts the work in that state instantly
        └── No target yet — the user will know it when they see it
            → exploration, not matching: [[vibe-to-generator]]
        ```
        
        Most real tools combine two branches; name the combination.
        
        - **What knows the process?** An app that produced the reference, a named technique, a spec. Reproduce a process known to work; don't invent one. If an existing app made the reference, its settings panel is your parameter list.
        - **What format does the project need?** Decided by what the code must do with the output, never by what is easiest to export.
        
        **When not to.** One or two direct attempts would land it; the result is fully determined with no judgment left; unclear which side you're on → make one honest direct attempt first. **The stop-the-loop rule:** if a round of "closer, but not quite" has happened, the diagnosis is done — say so and propose the tool instead of retrying the prompt in different words.
        
        ## Pitch it, then build it
        
        Four lines before code: **the gap** (one sentence), **the tool** (what it renders, which branch), **the controls** (which decisions belong to the user's eye), **the export** (the exact format and how saving works). This is the cheapest moment to be corrected.
        
        Then, in one sitting:
        
        1. **Isolated route** (`/lab/<slug>`); nothing in production imports it.
        2. **Rebuild the process, not the output.** A tool that traces the output matches once and can't be adjusted.
        3. **The target stays on screen**, beside the output, at ship size. Matching from memory across a tab switch is how "close enough" ships.
        4. **Every judgment call is a live control**, with tested bounds — a slider that reaches a broken render teaches distrust. Fixed values are only for the rules of the process.
        5. **Instant feedback.** No "apply" step; keep panel rows unconditional.
        6. **Save writes a file.** The full configuration as JSON in the project (plus the artifact data if separate), path shown in the UI. Production imports that file, so saving *is* shipping the decision.
        
        Hand off in two lines — the URL and the verbs — and stop. Hovering with suggestions defeats the point of the knobs.
        
        ## Harvest
        
        When the user picks a keeper: read the saved config (never copy values by eye), wire the export and the cleaned-up render logic into the real artifact, and leave the tool in place unless asked — the next asset in the same language goes through it.
        
        ## When to apply
        
        "Build me a tool for this", a result the agent keeps missing, two rounds burned on one artifact. Sibling workflows: [[prototype-picker]] compares discrete directions; [[vibe-to-generator]] searches a space with no target.
        
        ## Gotcha
        
        A tool whose knob bucket is empty didn't need building — the idea was fully determined. Go build the output.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on tools as a means to an end, distilled by HKTITAN.
        - Related: [[prototype-picker]], [[vibe-to-generator]], [[taste-is-trained]].
        
      • cross-model-testing.md 3.9 KB
        ---
        title: cross-model-testing
        summary: Test skill routing and execution against GPT, Claude Opus, Claude Sonnet. Sonnet and GPT behave quite differently.
        tags: [meta, testing, evals, models]
        ---
        
        # Cross-model testing
        
        Perplexity's Agents team: *"Sonnet and GPT behave quite differently when it comes to Skills."* What loads on one model may not load on another. What works on Opus may fail on Haiku. Routing precision is model-dependent.
        
        This is non-obvious. Most skill authors test on one model (the one they happen to use) and assume the routing is universal. It isn't.
        
        ## What to test cross-model
        
        For each model family you support:
        
        1. **Loading precision** — Does the agent load the skill when it should? Run 10 positive queries; the skill should load on all 10.
        2. **Loading recall** — Does the agent *not* load the skill when it shouldn't? Run 10 adjacent-but-distinct queries; the skill should not load on any.
        3. **Progressive read targeting** — When the skill is loaded, does the agent open the right atomic node? Test 5 queries each mapped to a specific node; verify the agent reads that node.
        4. **End-to-end output quality** — For a given input (e.g., "review this CSS"), does the agent produce the right output format? Test with the [[review-format]] table requirement.
        
        ## Models to cover (as of 2026)
        
        | Family | Models |
        |---|---|
        | Claude | Opus 4.x, Sonnet 4.x, Haiku 4.x |
        | GPT | GPT-5, GPT-5-mini |
        | Gemini | Gemini 2.5 Pro, Gemini 2.5 Flash |
        
        You don't need to cover every model. Cover the orchestration models your downstream users will most likely use. For this skill, the priority is Claude Sonnet + Opus (designed for) and GPT-5 (most common alternative).
        
        ## How to run cross-model evals
        
        The `evals/` folder in this repo has three eval files:
        
        - `loading.jsonl` — query + expected_load (true/false)
        - `progressive-reads.jsonl` — query + expected_node
        - `end-to-end.md` — sample tasks + expected output structure
        
        To run them:
        
        ```bash
        # Pseudo — depends on your harness. Common patterns:
        agent-eval skills/design-engineering/evals/loading.jsonl --model claude-sonnet-4
        agent-eval skills/design-engineering/evals/loading.jsonl --model claude-opus-4
        agent-eval skills/design-engineering/evals/loading.jsonl --model gpt-5
        ```
        
        Compare results across models. Any divergence ≥5% on the same eval set is a routing problem that needs description-tuning.
        
        ## Common cross-model failure modes
        
        1. **GPT loads on broader keywords** — GPT tends to route on noun matches ("CSS", "animation") more aggressively than Claude. If your description uses generic nouns, GPT may load on tangentially-related queries.
        2. **Claude Sonnet skips on verbose descriptions** — Sonnet sometimes skips skills with descriptions over ~80 words. Tighten the description if Sonnet recall is low.
        3. **Haiku doesn't load multi-node skills well** — Haiku can struggle with the MOC → leaf navigation. Often it stops at the SKILL.md and never opens references. If supporting Haiku, consider inlining critical nodes into SKILL.md.
        4. **Opus over-reads** — Opus may open multiple atomic nodes "just in case." Token budget concern; not a routing bug.
        
        ## When to run
        
        - Before changing the SKILL.md description (the routing-critical line).
        - Before any change to atomic node summaries (which the agent uses to decide which to read).
        - Before a new release / version bump.
        - Quarterly, as model behavior drifts with model updates.
        
        ## Gotcha
        
        Don't test on synthetic queries you wrote yourself. Source from real user queries (production logs, user feedback, hero queries). Synthetic queries have a flavor that biases the model toward the answer you expect — they tell you the description matches the queries you wrote, not whether it matches reality.
        
        ## Sources
        
        - Perplexity Agents team — *"Designing, Refining, and Maintaining Agent Skills"*.
        - Anthropic — eval harness patterns.
        - Related: [[gotchas]], [[review-format]], [[../../CONTRIBUTING|CONTRIBUTING.md]].
        
      • design-benchmarks.md 4.9 KB
        ---
        title: design-benchmarks
        summary: How design output gets measured — DesignBench's generation / edit / repair tasks with compile, CLIP, and MLLM-judge metrics; Design Arena's anonymous pairwise votes aggregated by Bradley-Terry — and this skill's own eval suite that borrows both shapes.
        tags: [meta, evals, benchmarks, testing]
        ---
        
        # Design benchmarks and this skill's evals
        
        Taste is trained, but it is also measurable, and two public benchmarks define the shapes worth borrowing. Neither measures *craft* directly — one measures whether generated UI matches and compiles, the other measures whether people prefer it — so this skill runs both shapes side by side with its own checklist-derived assertions. [[cross-model-testing]] covers routing evals; this node covers output quality.
        
        ## DesignBench (WebPAI, arXiv 2506.06251)
        
        900 webpage samples across 11 topics, four stacks (vanilla HTML/CSS, React, Vue, Angular), three tasks that mirror real work: **generation** (UI image → code), **edit** (change an implementation from an instruction, with image, code, or both as context, across 9 edit types: add/change/delete × text, color, position, size, shape, component-level), and **repair** (fix a broken UI across issue categories such as occlusion, crowding, alignment, color and contrast, overflow). Metrics: compile success, visual similarity via CLIP, code-modification similarity, and MLLM-as-judge scoring; difficulty is graded by element count, interdependencies, and cascading scope. Headline findings: models adopt framework components poorly (about 19% Angular component adoption), detect UI issues badly (average accuracy around 0.27), and edit better from code than from images.
        
        What to borrow: the **task triad**. A design skill that only helps generation is untested on the two-thirds of real work that is editing and repairing.
        
        ## Design Arena (by Intelligence)
        
        A crowdsourced arena: a prompt is sent to several models, the first outputs are shown anonymously side by side, a human picks the better one, winners are matched again until a 1–4 ranking exists, and every pairwise choice is a vote. Ratings come from a Bradley-Terry fit (iterated to convergence, normalized, then `400 × log10(strength)`), models below a minimum vote count are dropped, and identities stay hidden to prevent brand bias. Separate leaderboards per category — websites, UI components, mobile apps, slides, games, 3D, SVGs, data visualizations, logos, video, text-to-speech — plus an overall frontend board.
        
        What to borrow: **anonymous pairwise judgment.** "Which of these two is better?" is answerable by a person or a judge model where "is this good?" is not, and it is the only honest way to score an aesthetic change.
        
        ## This skill's suite
        
        | Layer | File | Shape | Asserts |
        |---|---|---|---|
        | Routing | `evals/loading.jsonl`, `evals/progressive-reads.jsonl` | fixtures | The skill loads when it should; the right node opens |
        | Contract | `evals/review-format.eval.ts`, `motion-values.eval.ts`, `sound-values.eval.ts` | eve scored | Review tables, concrete values |
        | Craft floor | `evals/design-bench.eval.ts` + `skills/design-engineering/evals/design-bench.jsonl` | generation / edit / repair fixtures | Deterministic checks lifted from [[review-checklist]]: no `transition: all`, no `scale(0)`, reduced-motion present, inputs ≥ 16px, labelled icon buttons, no `z-index: 9999`, no hover-only actions, no sound before a gesture |
        | Preference | `design-bench.jsonl` rows tagged `arena` | pairwise | Two candidates per prompt; a human or judge model picks; log the win rate per node change |
        
        The craft floor is DesignBench's *repair* task pointed at our own tells: each fixture is a broken snippet and the row the checklist should catch. The preference layer is Design Arena's protocol at PR scale: when a node changes what the agent produces, generate before-and-after candidates for three fixtures, judge them blind, and record the result in the PR. A node that loses the pairwise more than it wins is reverted, however good it reads.
        
        ## When to apply
        
        Before changing the SKILL.md description or any node that alters output (motion values, palette rules, sound defaults); when comparing models for a design task; when someone asks "is this skill actually helping?"
        
        ## Gotcha
        
        Compile rate and CLIP similarity reward *matching the reference*, including its flaws. A repair that fixes an overflow the reference also had can score lower than a faithful reproduction of the bug. Read the pairwise layer before trusting the similarity layer.
        
        ## Sources
        
        - Xiao et al., *DesignBench: A Comprehensive Benchmark for MLLM-based Front-end Code Generation* (arXiv 2506.06251), and the WebPAI/DesignBench repository and annotation guideline.
        - Design Arena methodology notes (notes.designarena.ai/methodology) — Bradley-Terry aggregation, anonymity, vote thresholds.
        - Perplexity Agent Skills team — evals as Step 0.
        - Related: [[cross-model-testing]], [[review-checklist]], [[taste-is-trained]].
        
      • design-system-docs.md 4.7 KB
        ---
        title: design-system-docs
        summary: Docs define when to use what; write them for an engineer who joined yesterday and can never ask. Inventory the code first, size the structure, give every component when/alternative + closed variant set with a default + one correct and one incorrect example + a flowchart, ban hedge words, then verify by prompting and fix the docs, not the output.
        tags: [meta, design-system, documentation, agents, design-md]
        ---
        
        # Design system docs agents can follow
        
        Tokens and components define what exists; docs define when to use what. Every decision the docs leave out gets replaced by a guess, and a guess looks exactly like a decision. This node is how to write a DESIGN.md or docs folder that stops an agent hardcoding colors, inventing variants, and picking the wrong component. [[using-design-md]] and [[using-design-file]] cover *consuming* such files; this is *authoring* them.
        
        ## 1. Inventory the code first
        
        Never write from memory — that is how agents learn variants that don't exist.
        
        - **Tokens**: the token files (globals.css, theme, Tailwind config). Every semantic token and the primitive behind it. Raw values with no token need a rule, not a mention.
        - **Components**: every shared component, its variant and size unions, and the props that change appearance. The union type is the truth.
        - **Usage**: two or three real call sites per component for the de facto default and the patterns to bless or ban.
        
        Every name in the docs traces to a file and a line. If there is nothing to document — no tokens, ad-hoc styles — say so and offer to set up semantic tokens and base components first.
        
        ## 2. Size the structure
        
        One token file and ≤ 5 components → a single root file. Bigger → a folder: a root file with the system's character in two or three sentences plus an index, and one file per topic (colors, typography, buttons, forms). Root under 100 lines, topic files under 150; split by topic rather than trim rules. Small files mean an agent loads only what the task needs. If the project uses a `.design` contract, the machine-readable half lives there and these docs become its `rationale`.
        
        ## 3. The component template
        
        Four parts, always this order:
        
        1. **When to use it, and when to use the alternative.** "Use for actions. Navigation styled as a button is a Link; actions in tight spaces use IconButton." Without the alternative the agent has a description; with it, a choice.
        2. **Which variant applies where, and which is the default.** Close the set: "Variants: `primary`, `secondary`, `danger`. Nothing else exists — an unlisted variant is a bug." Most drift is an agent promoting everything to primary.
        3. **One correct and one incorrect example**, real code from the codebase. Models follow examples better than prose, and the incorrect one inoculates against the exact mistake.
        4. **A flowchart when a choice has three or more options.** A tree gets walked the same way every run; a paragraph gets interpreted.
        
        For tokens, the load-bearing rule is the layer: components use semantic tokens, never primitives, never raw values — then a flowchart per role ("what background do I use").
        
        ## 4. Language rules
        
        Numbers and absolutes, no hedges ("body 16px, captions 14px, never below 12px", not "use small text sparingly"). Every non-obvious rule carries its why so it generalizes ("one primary per view — two means the screen has no hierarchy"). Every line changes what the agent does; don't explain what a button is. See [[skill-writing-rules]].
        
        ## 5. Verify by prompting
        
        Docs are a hypothesis until an agent runs against them. Ask for a real screen in one sentence, then read the drift:
        
        | Drift | Hole | Fix |
        |---|---|---|
        | Hardcoded a color | Layer rule not findable or not strict | Move it into the root file |
        | Invented a variant | Set not closed | Add "nothing else exists" (and tighten the union type) |
        | Wrong component | No when-vs-alternative line | Add the comparison for that pair |
        
        Fix the docs, never just the output — a corrected screen fixes one screen; a patched doc fixes every screen after it. Repeat until a fresh prompt drifts zero.
        
        ## When to apply
        
        Creating or updating design docs; an agent keeps hardcoding colors, inventing variants, or picking the wrong component; a project adopting a contract for the first time.
        
        ## Gotcha
        
        A documented variant that doesn't exist is worse than an undocumented one that does. The inventory step is the whole difference.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on documenting systems for agents, distilled by HKTITAN.
        - Google Labs DESIGN.md spec; AgentsORG `.design` (`rationale`, `components.when` / `when_not`).
        - Related: [[using-design-md]], [[using-design-file]], [[skill-writing-rules]], [[component-api-design]].
        
      • design-vocabulary.md 28.7 KB
        ---
        title: design-vocabulary
        summary: The precise word for each design phenomenon, and where this skill goes deeper. The lexicon behind articulate-precisely. Seeded by Index's articulate module.
        tags: [meta, vocabulary, reference, articulation]
        ---
        
        # Design vocabulary
        
        The skill's working lexicon: the precise word for a design phenomenon, plus where the skill treats it in depth. Use it to name things exactly per [[articulate-precisely]] — in a critique, name the phenomenon; in a request, name the change.
        
        Seeded by *Index*'s articulate module (Emil Kowalski & Glenn Carstens-Peters — [index.how/to/articulate](https://index.how/to/articulate)). The glosses here are this skill's own; `→ [[node]]` points to the node that goes deeper. Terms in **bold** with no arrow are vocabulary the skill adopts but does not yet cover in a dedicated node — name them precisely even where there is no node to open.
        
        ## How to use this file
        
        - **Don't read it top to bottom.** Jump to the category, grab the word, follow the arrow if you need depth.
        - **One job: stop the agent saying "weird."** If a critique reaches for a vague word ("off," "clean," "modern"), the right word is almost always here.
        - **Pair it with the principle.** [[articulate-precisely]] is *why* precision matters; [[review-checklist]] is *what* to scan when reviewing. This file is a naming index, not a rulebook — the rules live in the linked nodes.
        
        ## 01 — Typography
        
        How text is set, spaced, and read.
        
        - **Kerning** — space between two specific letters, tuned by hand. Distinct from tracking.
        - **Tracking** — uniform letter-spacing across a run; uppercase and small text usually need more. → [[line-length-tracking]]
        - **Leading** — line-height; the vertical space between lines. Too tight suffocates, too loose stops reading as a paragraph.
        - **Optical kerning** — spacing judged by eye instead of the font's metric tables; closes the gaps the defaults leave.
        - **Tabular nums** — fixed-width digits so changing numbers don't jitter in a column. Essential for tables, prices, timers. → [[data-is-content]]
        - **Type scale** — a fixed set of sizes (often a ratio) you pick from rather than inventing per use. → [[typography-humanity]]
        - **Weight** — stroke thickness. Bold is for hierarchy; italic is for linguistic stress or citation, not hierarchy. → [[typography-humanity]]
        - **x-height** — height of the lowercase; why two fonts at the same px read as different sizes. → [[typography-humanity]]
        - **Cap height** — height of the capitals; with x-height it governs perceived size. Matters when matching a fallback.
        - **Ligature** — two glyphs merged to dodge a collision (fi, fl). Decorative ligatures are display-only.
        - **Font smoothing** — antialiasing of glyph edges; `antialiased` renders lighter on Mac, worse on non-retina.
        - **Text overflow / truncation** — cut at a word boundary with a real ellipsis character, never mid-word. → [[copy-voice]]
        - **Hyphenation** — breaking a word across lines; best left to the browser to place.
        - **Clamp** — fluid sizing with a min, preferred, and max in one declaration (`clamp()`); scales between breakpoints.
        - **Widow** — classically, a paragraph's lone last line carried to the top of the next column or page; colloquially also the lone word left on a last line. `text-wrap: pretty` targets the trailing-word case. → [[typography-humanity]]
        - **Orphan** — a paragraph's lone first line stranded at the bottom of a column or page — the mirror of a widow. CSS `orphans`/`widows` set the minimums, but support is uneven, so it is often fixed by hand.
        - **Font stack** — the ordered fallback list; a size-matched fallback prevents reflow when the web font loads.
        - **Line length** — the measure; ~65 characters reads comfortably, wider loses the next line. → [[line-length-tracking]]
        - **Variable font** — one file spanning weight/width axes; lets you animate weight without layout shift.
        - **Superscript / subscript** — `sup`/`sub` are oversized by default and break line rhythm; size and shift them by hand.
        
        ## 02 — Color
        
        How color is defined, mixed, and applied.
        
        - **sRGB** — the legacy default space; every hex value lives here. Narrower gamut than modern screens can show.
        - **P3** — a wider gamut most current displays support; reaches greens and reds sRGB cannot describe.
        - **OKLCH** — a perceptually uniform space; equal lightness actually looks equally bright. Best for generating palettes.
        - **Alpha** — the transparency channel. An alpha border recedes into the surface; a solid hex sits on top of it.
        - **Semantic token** — a color named for its job, not its value (`--border-subtle`, not `#e0e0e0`). → [[using-design-md]]
        - **Contrast ratio** — luminance difference of foreground to background; WCAG wants 4.5:1 body, 3:1 large/UI. → [[contrast-and-color-scheme]]
        - **Tinted neutral** — a grey with a slight hue bias; pure `#808080` reads as a placeholder, a tint reads as chosen. → [[color-monochromatic]]
        - **Saturation** — a color's intensity; a fully-saturated brand hue that sings on a light page can buzz on a dark one — drop it roughly a quarter to settle it.
        - **Chroma** — OKLCH's perceptually-accurate saturation. Reduce chroma for a light tint; reducing opacity instead just greys it. → [[color-monochromatic]]
        - **Gradient** — a transition between colors; built in OKLCH it stays vivid where sRGB/HSL go grey at the midpoint.
        - **Opacity vs visibility** — `opacity:0` still takes space and catches clicks; `visibility:hidden` kills interaction but keeps space.
        - **Dark mode** — surfaces built dark, brightest at the top of the layer stack; light tokens rarely translate directly. → [[dark-mode]]
        - **Blending** — how a layer mixes with what is beneath. Multiply darkens, Screen lightens, Overlay does both.
        
        ## 03 — Iconography
        
        How icons are drawn and balanced.
        
        - **Stroke weight** — icon line thickness; it has to scale with size or it disappears small and looks frail large. → [[icon-systems]]
        - **Optical centre** — where a shape *looks* centred vs where it mathematically is; a play triangle must nudge right. → [[icon-systems]]
        - **Filled vs outlined** — two styles signalling state; filled often means active/selected, outlined means default. → [[icon-systems]]
        - **Cap style** — how a stroke ends, square or round; rounded ends usually read intentional in UI, blunt square ends can look like an untuned default.
        - **Pixel hinting** — nudging paths onto the pixel grid at small sizes so a 16px glyph doesn't turn to mush.
        - **Icon library** — a single family tuned to one size, weight, and corner radius; pulling glyphs from a second set — however close — leaves small inconsistencies that pile up. → [[icon-systems]]
        - **Icon size system** — the defined sizes (12/16/20/24/48) each ideally drawn for its context, not scaled from one master.
        - **Meaning collision** — the same icon doing two jobs (a star for favourite *and* rating); users learn neither.
        - **Contextual swap** — switching outlined↔filled to mark a state change; only works if the convention is consistent.
        - **Breathing room** — the deliberate spacing between a glyph and its text (start ~6–8px); design it as a property of the component, not whatever the flex gap happens to leave.
        - **Unified weight** — icon stroke matched to the adjacent text weight, so they read as one family. → [[icon-systems]]
        - **Metaphor accuracy** — whether the glyph still means what it depicts (a floppy disk for save) as the audience ages.
        
        ## 04 — Layout
        
        How space and structure organise a page.
        
        - **Border radius** — corner rounding; an inner radius = outer radius − padding, or the gap shows. → [[border-radius]]
        - **Gap** — space between flex/grid children, set on the parent; unlike margin it leaves no trailing space.
        - **Negative space** — the empty area that gives a layout its edges and steers the eye; packing things tighter erases the structure the eye was reading, it doesn't add content.
        - **Flexbox** — the row/column layout model controlling direction, alignment, and grow/shrink.
        - **Auto layout** — Figma's flexbox: elements that space themselves and resize with content.
        - **Layout shift** — content jumping as the page loads (images, fonts, reorders); reserve space to prevent it. → [[sticky-and-scroll-tells]]
        - **Overflow** — what happens past a container's bounds; `hidden` clips silently and breaks child sticky positioning.
        - **Sticky positioning** — scrolls then pins at a threshold; a parent with `overflow:hidden` silently breaks it. → [[sticky-and-scroll-tells]]
        - **Aspect ratio** — fixed width:height; setting it reserves space before media loads and prevents layout shift.
        - **Viewport units** — units relative to the window; `dvh` accounts for mobile browser chrome that `vh` ignores. → [[viewport-custom-design]]
        - **Safe area** — the screen region clear of notches and home indicators; fixed bottom chrome must inset for it. → [[viewport-custom-design]]
        - **Max-width** — a cap on container width so lines don't stretch unreadable on wide screens. → [[line-length-tracking]]
        - **Breakpoint** — where layout changes; set it where the content breaks, not at assumed device widths. → [[viewport-custom-design]]
        - **Responsive** — a layout that adapts via fluid sizing, flexible media, and breakpoints. The web baseline.
        - **Grid** — a column system organising layout; 12 is conventional, 8 often suits simpler layouts.
        - **Asymmetry** — columns or elements weighted on purpose rather than mirrored; the imbalance is what carries energy. A perfectly balanced grid is safe and usually inert. → [[visual-imperfection]]
        - **Baseline grid** — horizontal rhythm from body line-height; great for editorial, usually overkill in product UI.
        - **Z-index** — stacking order on a plane; modals, tooltips, dropdowns need explicit values or they clip behind. → [[tray-rules]]
        
        ## 05 — Interaction
        
        How elements respond to input.
        
        - **Affordance** — the cue a control gives about how to operate it; a raised edge says press, an underline says click. Strip the cue and the user is left to experiment.
        - **Hover state** — the change on cursor-over; confirm interactivity with cursor + color, not color alone. → [[hover-states-subtle]]
        - **Focus state** — the keyboard-focus indicator; never delete it, replace it. → [[accessibility-baseline]]
        - **Active state** — the pressed change; a small scale or color shift, or the button feels dead. → [[responsive-feedback]]
        - **Disabled state** — non-interactive; a muted token is more reliable than opacity, which can pass or fail contrast. → [[accessibility-baseline]]
        - **Cursor** — the pointer contract: pointer = clickable, text = selectable, default = static. The wrong one lies.
        - **Pointer events** — whether an element catches input; `none` makes a decorative layer present but click-through.
        - **Optimistic update** — update the UI before the server confirms; feels instant, needs a rollback path. → [[optimistic-updates]]
        - **Debounce** — delay a function and reset on each trigger; stops a request per keystroke (~300ms is common).
        - **Touch target** — the tappable area; 44×44px minimum, even when the visible element is smaller. → [[accessibility-baseline]]
        - **Copy to clipboard** — needs visible confirmation (a checkmark for a beat) or users click it again thinking it failed. → [[responsive-feedback]]
        - **Skip link** — a visually-hidden link, revealed on focus, that jumps past nav to main content. → [[accessibility-baseline]]
        
        ## 06 — Motion
        
        How things move and transition. The largest cluster in this skill — see [[animation-decision-framework]] first.
        
        - **Easing** — the speed curve of an animation; a custom cubic-bezier almost always beats the CSS built-ins. → [[easing-curves]]
        - **Ease-out** — fast then slow; the default for things entering the screen. → [[easing-curves]]
        - **Ease-in** — slow then fast; for things leaving. On an entrance it feels reluctant. → [[easing-curves]]
        - **Ease-in-out** — slow-fast-slow; for moves across the screen (a toggle, a sliding card). Overkill for simple enter/exit.
        - **Stagger** — list items animating in sequence (~40ms apart); all-at-once reads as a flash. → [[stagger-choreography]]
        - **Duration** — UI is sub-300ms; hovers ~150ms, buttons 100–160ms, >400ms with no feedback looks broken. → [[duration-table]]
        - **Transition property** — animate only `transform` and `opacity`; `all` catches layout props and janks. → [[transform-opacity-only]]
        - **Reduced motion** — the `prefers-reduced-motion` preference; any significant movement must honour it. → [[prefers-reduced-motion]]
        - **Skeleton shimmer** — a gradient sweep on a skeleton to suggest activity; must respect reduced motion. → [[empty-loading-states]]
        - **Spring** — physics-based easing that overshoots and settles; reads more alive than a fixed curve. → [[spring-animations]]
        - **Choreography** — timing several animations as one phrase so attention lands in one place; uncoordinated motion just scatters the gaze. → [[stagger-choreography]]
        - **Enter vs exit asymmetry** — enter decelerates in, exit accelerates away; a reversed enter feels like arriving backwards. → [[compose-subtract-asymmetry]]
        - **Shared axis transition** — motion direction reflects spatial relationship: forward animates right, back animates left. → [[fly-not-teleport]]
        - **Motion as feedback** — press-compress, error-shake, success-draw: the motion *is* the response, not decoration. → [[responsive-feedback]]
        - **GPU compositing** — transform/opacity run on the GPU and skip layout recalc; animating width/top forces reflow per frame. → [[transform-mastery]]
        
        ## 07 — Accessibility
        
        How interfaces work for everyone. The baseline is non-negotiable — see [[accessibility-baseline]].
        
        - **WCAG** — the contrast standard most teams use; AA is 4.5:1 body, 3:1 large/UI. → [[contrast-and-color-scheme]]
        - **APCA** — a newer perceptual contrast model accounting for size and weight; sometimes disagrees with WCAG.
        - **Screen reader** — reads the UI aloud; navigates by DOM order, headings, and ARIA roles, not visual layout. → [[accessibility-baseline]]
        - **Tab order** — the keyboard focus sequence; should match reading order, broken when DOM and visual order diverge.
        - **Prefers color scheme** — the media query for system theme; the right way to honour dark mode without a custom toggle. → [[dark-mode]]
        - **aria-label** — an accessible name for a control with no visible text; describe the action ("Search"), not the icon.
        - **Focus trap** — keeping keyboard focus inside an open modal so it can't escape to the page behind. → [[tray-rules]]
        - **DOM order** — the HTML sequence screen readers follow; reordering visually with CSS desyncs seen from read.
        - **Semantic HTML** — the right element for the job; a `button` gets keyboard, focus, and role for free, a `div` does not.
        - **Label association** — tying a `label` to its input via `for`/`id` so clicking the label focuses the field. → [[forms-validation]]
        - **Color-only state** — color as the sole signal (a red border) is invisible to colorblind users; always pair with icon or text.
        
        ## 08 — Information architecture
        
        How content is structured and found.
        
        - **Progressive disclosure** — reveal complexity as the user goes deeper instead of dumping it all on one screen.
        - **Navigation** — the system for moving between areas; labels should match how users describe things, not internal naming.
        - **Mental model** — the assumptions a user already holds about how a thing behaves; architecture that fits them feels obvious, architecture that fights them makes users feel stupid.
        - **Hierarchy** — the deliberate ordering of importance; with none, every element shouts equally and the user hears nothing.
        - **Empty state** — the no-content-yet view; explain why and offer the first action. "No data" ends the journey. → [[empty-loading-states]]
        - **Error state** — what shows on failure; say what broke and a specific way to recover. → [[states-are-the-work]]
        - **Onboarding** — the best kind delivers value immediately; one completed action beats a watched tour. → [[delight-impact-curve]]
        - **Confirmation dialog** — a gate before a destructive action; describe what is lost, keep safe and destructive far apart.
        - **Wayfinding** — the signals (breadcrumbs, active nav, titles, URL) that say where you are and where you can go. → [[url-as-state]]
        - **Signpost** — an element whose sole job is to orient (a section header, a step counter); you only notice it once it's gone.
        - **Labelling** — the wording on nav and categories; when users search for something they should have browsed to, the query is a receipt for a mislabel.
        - **Depth** — levels of nav to reach content; past three, users lose track — add wayfinding or flatten.
        - **Search as escape hatch** — high search volume for browsable things is a sign the IA failed, not that search needs work.
        - **Content inventory** — a complete list of everything before restructuring; skipping it just moves broken content.
        - **Card sorting** — a research method: real users cluster items the way they expect to find them, exposing where the team's labels and the user's expectations split.
        
        ## 09 — Copywriting
        
        How words guide and reassure. The skill's voice work lives in [[copy-voice]].
        
        - **Microcopy** — the small text (labels, errors, placeholders) that outsizes its length in how trustworthy a product feels. → [[copy-voice]]
        - **CTA** — the primary action label; "Save changes" outperforms "Submit." Own the action, don't apologise for it. → [[copy-voice]]
        - **Error message** — name what went wrong and how to fix it; "Invalid input" names nothing. → [[forms-validation]]
        - **Placeholder** — hint text that vanishes on typing; it cannot replace a label — the reminder is gone when needed.
        - **Sentence case** — cap only the first word and proper nouns — the UI default; capitalising Every Word gives microcopy a stiff, contractual tone.
        - **Front-loading** — lead with the word that carries the meaning, because eyes grab the first token and move on; "3 errors" lands before "There were some problems with your submission" finishes. → [[copy-voice]]
        - **Inline error** — the message next to the offending field; a top-of-form summary makes users hunt. → [[forms-validation]]
        - **Voice** — a product's fixed character in language — warm or clinical, plain or technical. Voice is the constant; tone is what flexes by moment.
        - **Tone** — how the voice adapts to the moment (encouraging in onboarding, careful around destructive actions).
        - **Success message** — specific and brief ("Saved", not "Done"); often neglected, never a press release.
        - **Destructive language** — say "Delete"/"Remove" plainly; softening a permanent action ("clear", "reset") misleads.
        - **Scannability** — copy built for the glance: lead with the outcome, keep clauses short, stay active. Most UI text is skimmed, not read — write for that.
        - **Truncation strategy** — where and how overflowing text gets cut; a heading, a nav label, and a body line each want a different cut rule, never one global ellipsis. → [[copy-voice]]
        - **Contextual help** — one sentence placed at the moment of need; cheaper than a tooltip (hover) or docs (navigate).
        - **Numeric formatting** — `1,000` vs `1000`, `3.5k` vs `3,500`; consistency matters more than the convention, match the locale. → [[data-is-content]]
        
        ## 10 — Tools
        
        How design is made and shared.
        
        - **Design system** — shared components, tokens, patterns, and guidelines; a living product, not a static doc. → [[using-design-md]]
        - **Source of truth** — the one place a team defers to; increasingly the codebase, not the Figma file. → [[using-design-md]]
        - **Variables** — named, reusable values updated from one place; change once, everything referencing it updates.
        - **Tokens** — design decisions as named variables both tools and code reference; what makes a system portable. → [[using-design-md]]
        - **Visual language** — the cohesive color/type/shape/motion/tone that makes a product recognisable without its name. → [[marketing-vs-product-ui]]
        - **Artboard / Frame** — a named canvas for a screen or component; Figma frames also define clipping and auto layout.
        - **Prototype** — an interactive mockup to test a flow; fidelity should match the question being asked.
        - **Handoff** — design moving to engineering; done well it is shared tokens and an available designer, not guesswork. → [[using-design-md]]
        - **Redline / annotation** — measurements and notes on a design to communicate intent; less needed with a shared system. → [[agentation-workflow]]
        - **Moodboard** — a reference collection to align on direction; the best ones make an argument, the worst are Pinterest. → [[taste-is-trained]]
        - **HiDPI / Retina** — 2×+ pixel-density screens; assets, icons, and borders must account for 2× and sometimes 3×.
        - **Open Graph** — the share-preview metadata (image, title, description); the first impression for most who don't visit.
        
        ## 11 — Analysis
        
        How design decisions are measured.
        
        - **A/B test** — two variants run live to measure which wins; only as good as the metric chosen.
        - **Heatmap** — a visualisation of clicks/taps/scroll; spots affordance problems and content nobody reaches.
        - **Session recording** — replayed footage of one real user's path; where an aggregate heatmap blurs, a recording puts a face on the moment someone gave up — much harder to dismiss.
        - **Funnel** — the staged path to a goal (sign-up, checkout, activation); every step that sheds users is a problem to diagnose, not a law of nature.
        - **Conversion** — the moment a visitor takes the desired action; design moves it via clarity, trust, and friction.
        - **Bounce rate** — share who leave without acting; high on a key page signals an expectation mismatch.
        - **Retention** — how many return after the first visit; harder and more important than acquisition.
        - **Churn** — the rate at which existing users leave — retention read backwards; a rising number is the product admitting it no longer earns the return visit.
        - **NPS** — likelihood-to-recommend, 0–10; a blunt instrument, widely used as a rough satisfaction signal.
        - **Scroll depth** — how far down users actually read; content below the fold reaches fewer than assumed. → [[sticky-and-scroll-tells]]
        
        ## 12 — Components
        
        The building blocks. Their state behaviour is where craft lives — see [[states-are-the-work]]. For the pairs people confuse, see [[component-confusables]].
        
        - **Button** — the primary trigger; needs distinct default/hover/active/focus/disabled/loading, one primary per view. → [[hover-default-imperative]]
        - **Input** — a text field; needs a persistent label above it, not just a placeholder, and a distinct state each. → [[forms-validation]]
        - **Textarea** — a multi-line input; same state treatment as a single-line field. → [[forms-validation]]
        - **Select** — a one-of-many dropdown; the native element is accessible but hard to style, customs need keyboard care.
        - **Checkbox** — a binary toggle; clicking the label should toggle it, not just the box. → [[forms-validation]]
        - **Radio group** — exactly one of a set; distinct from checkboxes, which allow several.
        - **Switch** — an on/off with immediate effect; for settings that apply without a save.
        - **Slider** — a value within a range; update live on drag, not only on release.
        - **Modal / Dialog** — an overlay that interrupts and demands attention; trap focus, make the background inert. → [[component-confusables]]
        - **Sheet** — a panel sliding from a screen edge; same focus and dismissal rules as a modal. → [[component-confusables]]
        - **Drawer** — a bottom sheet pulled up from the base; a common mobile dialog replacement. → [[component-confusables]]
        - **Popover** — a click-anchored overlay that *can* hold interactive content (links, buttons). Not a tooltip. → [[component-confusables]]
        - **Tooltip** — a hover label that *cannot* hold interactive content; if you need a link inside, use a popover. → [[component-confusables]]
        - **Toast** — a temporary auto-dismissing notification; duration should track reading time, not a flat 2s. → [[sonner-principles]]
        - **Badge** — a small label attached to another element and read-only; numeric implies a count. → [[component-confusables]]
        - **Tag** — a label the user can pick up, toggle, or remove — it categorises and it's interactive. A badge is pinned on; collapsing the two hides a real difference. → [[component-confusables]]
        - **Accordion** — stacked expand/collapse sections; good for FAQs, not a substitute for tabs when comparing.
        - **Tabs** — switches between related views in one space; for filtering the same content, not navigating unrelated sections.
        - **Stepper** — a multi-step flow showing progress; show the steps upfront to set expectations.
        - **Carousel** — a horizontal scroll row; on desktop often a sign the layout wasn't solved. The first item gets the attention.
        - **Navigation menu** — top-level or flyout structure; labels reflect how users think, not how it was built.
        - **Sidebar** — persistent edge navigation; works when users move between sections often.
        - **Breadcrumb** — a location trail in a hierarchy; navigate back at depth without the browser button. → [[url-as-state]]
        - **Pagination** — page controls for lists too long to load at once; infinite scroll is the tradeoff alternative.
        - **Skeleton** — a placeholder holding content's shape while it loads; better than a spinner for list/page loads. → [[empty-loading-states]]
        - **Spinner** — an indeterminate loader; fine for short action waits, a skeleton wins for page/list loads. → [[empty-loading-states]]
        - **Avatar** — a small user image; needs a fallback (initials or icon) when the image fails. → [[avatar-systems]]
        - **Card** — a contained surface grouping content; inner radius < outer, and a fully-linked surface kills text selection. → [[cards-design]]
        - **Data table** — a row/column grid; right-align numbers with tabular nums — alignment does zebra-striping's job. → [[data-is-content]]
        - **Combobox** — a typeable input whose list narrows with each character; feels like free text but only lets the user land on a valid option.
        - **Command menu** — a keyboard-triggered search for navigating or acting; for power users avoiding the mouse.
        - **Progress** — a bar for a determinate process; for unknown durations use a spinner instead.
        - **Separator** — a visual divider; often overused where spacing would do the same job.
        
        ## 13 — Sound
        
        The words for what an interface sounds like. This category is the skill's own extension of the Index module; see [[MOC-sound]].
        
        - **Transient** — the sharp onset of a sound; the part you sync to a frame. → [[sound-motion-sync]]
        - **Earcon** — a short abstract tone whose meaning is learned (rising = success); distinct from an *auditory icon*, which imitates a real sound. → [[sound-palette]]
        - **Contour** — the pitch shape of a sound over time: rising, falling, flat. Carries meaning before timbre does. → [[sound-palette]]
        - **Timbre / material** — what the sound is *made of* — wood, glass, breath. A product has one. → [[sound-palette]]
        - **Envelope (ADSR)** — attack, decay, sustain, release; UI sounds are nearly all attack and decay. → [[sound-spec]]
        - **Tail** — the decay after the event; the first thing to cut. → [[sound-spec]]
        - **Dry / wet** — without / with reverb; UI sounds are dry. → [[sound-generation-elevenlabs]]
        - **One-shot / loop** — a sound that plays once vs one that sustains a state (processing, recording). → [[sound-spec]]
        - **Sprite** — several sounds in one file addressed by offset; one decode for the whole family. → [[sound-spec]]
        - **LUFS / dBFS / true peak** — perceived loudness, sample level, and the reconstructed peak; the three numbers a spec names. → [[sound-spec]]
        - **Ducking** — lowering one track (a bed) while another (a hit, a voice) plays. → [[launch-video-sound]]
        - **Pre-roll** — silence before a file's transient; a sync bug disguised as an asset. → [[sound-motion-sync]]
        - **Whoosh / riser / stinger / braam** — the motion-graphics vocabulary: movement, tension build, a brand hit, a cinematic drone. Product UI uses none of them. → [[launch-video-sound]]
        - **Audio-haptic harmony** — Apple's term for sound, haptic, and visual describing the same physical event at the same instant. → [[sound-motion-sync]]
        
        ## Sources
        
        - *Index — Say Precisely What You Mean*, Emil Kowalski & Glenn Carstens-Peters — [index.how/to/articulate](https://index.how/to/articulate). The 188-term articulate module this lexicon is seeded from; the canonical reference, arriving fall 2026. Glosses here are this skill's own.
        - Cross-linked throughout to this skill's own clusters: [[MOC-philosophy]], [[MOC-motion]], [[MOC-surface]], [[MOC-typography]], [[MOC-components]], [[MOC-layout]], [[MOC-anti-patterns]].
        
      • disambiguation.md 4.3 KB
        ---
        title: disambiguation
        summary: The intents that blur together — four motion questions, three dark-mode owners, two layout-shift owners — and which node owns each.
        tags: [meta, routing, disambiguation]
        ---
        
        # Disambiguation — the questions that blur together
        
        Some questions sound identical but route to different nodes. Misrouting here is the most common navigation failure in this graph: the answer you get is *plausible* but from the wrong altitude. This node lists the known confusable pairs and the tiebreaker for each.
        
        ## The four motion questions
        
        "Something about animation" is four different jobs:
        
        | Question | Owner |
        |---|---|
        | How should *this one thing* animate? | [[animation-decision-framework]] → [[easing-curves]], [[duration-table]] |
        | Is *this diff's* animation good? | [[motion-auditor]] (motion-only, deeper) or motion rows in a [[review-format]] table |
        | What's wrong with motion across the *whole app*? | [[motion-auditor]] in audit mode — output is a prioritized plan, not a table |
        | Where is motion *missing*? | [[delight-impact-curve]] + [[responsive-feedback]] — propose few, reject most |
        
        Tiebreaker: scope. One element → decide/build nodes. One diff → judge posture. Whole codebase → audit plan. Absence of motion → the delight nodes, and the burden of proof is on *adding*.
        
        ## Looks right vs. feels finished vs. should ship
        
        - **Looks right** — hierarchy, spacing, restraint, while designing → [[feeling-right]], [[unseen-details-compound]].
        - **Feels finished** — press states, tabular numbers, no layout shift, after it works → [[ui-polish-pass]] as the finishing pass, [[review-checklist]] as the audit.
        - **Should ship** — a verdict on a diff, at the gate → [[review-format]] table, checklist-scoped.
        
        Two more that blur: **feels wrong vs. drops frames** — judged motion goes to [[easing-curves]] and [[duration-table]]; measured slowness goes to [[performance-discipline]]. **Broken for someone vs. merely unpolished** — a failure in [[touch-and-focus]] is blocking; polish is not.
        
        Same screen, three altitudes. Don't give a gate verdict when the user is still designing.
        
        ## Layout shift has two owners
        
        - Shift because things are **slow to arrive** — skeletons that don't match loaded dimensions, unreserved space → [[empty-loading-states]].
        - Shift because things are **animated wrong** — layout-property transitions, `width`/`top` animation → [[transform-opacity-only]].
        
        Tiebreaker: does it happen once (loading) or on every interaction (animation)?
        
        ## Dark mode has three owners
        
        - The **palette** — how colors derive, desaturation, preserved layering → [[dark-mode]].
        - The **treatment** — shadows lose their job in dark; borders take over → [[shadows-whisper]].
        - The **principle** — dark mode is a redesign, not an inversion → [[color-monochromatic]].
        
        A dark-mode question that names a hex value routes to palette; one that says "looks flat in dark" routes to treatment.
        
        ## Hit areas: craft vs. floor
        
        [[hover-states-subtle]] treats target sizing and feedback as craft. [[accessibility-baseline]] treats the same numbers as a floor you cannot trade away. An a11y audit routes to the floor; a polish pass routes to the craft node — but the floor still applies.
        
        ## Sound has three owners
        
        - **Should it make a sound at all** — frequency and purpose, before any file exists → [[sound-decision-framework]]. The burden of proof is on adding.
        - **It has sounds and something is off** — stock, late, too long, too loud, no toggle → [[sound-palette]] for character, [[sound-motion-sync]] for timing, [[sound-spec]] for numbers, [[sound-playback-web]] for wiring.
        - **It is a video, not a product** → [[launch-video-sound]]. Opposite defaults: silence is placed, sound is the medium.
        
        Tiebreaker: is there a user who can mute it? If yes, product rules. If no (video), the launch register.
        
        ## Component look-alikes
        
        Tooltip vs. popover, badge vs. tag, sheet vs. drawer vs. dialog → [[component-confusables]] owns these. Don't adjudicate them from memory.
        
        ## Gotcha
        
        When two owners both seem right, answer from the *narrower* one and link the other — a specific node's values with a pointer beats a blended answer from both.
        
        ## Sources
        
        - HKTITAN — confusable pairs collected from real misroutes; see [[routing-table]] for the forward map.
        
      • gotchas.md 5.5 KB
        ---
        title: gotchas
        summary: Lived failures. Append a one-liner every time the agent gets a UI detail wrong.
        tags: [gotchas, append-only]
        ---
        
        # Gotchas
        
        This file is **append-only**. Each time the agent (or you) gets a UI detail wrong in practice, add a one-line gotcha here. Do not edit existing entries unless they're outright wrong.
        
        The Perplexity Agent Skills team calls this the "gotchas flywheel." Negative examples are the highest-signal content in a skill over time. The skill's description and instructions should change rarely; this file should grow steadily.
        
        ## Format
        
        ```text
        - [YYYY-MM-DD] One-line description of the gotcha. → fix in [[node-name]]
        ```
        
        ## Starter gotchas
        
        - [2026-05-21] Agent set `transition: all` on a card with `width` defined. Caused layout thrash on hover. → Animate only `transform` and `opacity`. See [[transform-opacity-only]].
        - [2026-05-21] Agent generated a hover state with `transform: translateY(-4px)` on a list row. Felt bouncy and amateur. → 1px shifts. See [[hover-states-subtle]].
        - [2026-05-21] Agent reached for `<Spinner />` on a 200ms API call. Created a flash. → No spinner under 800ms. See [[empty-loading-states]].
        - [2026-05-21] Agent crossfaded two icons for play/pause toggle. Looked like two separate elements. → Transform a single icon. See [[fly-not-teleport]] and [[icon-systems]].
        - [2026-05-21] Agent set `prefers-reduced-motion` to disable *all* animations including 120ms opacity fades. Made the UI feel broken. → Disable translations/scales, keep opacity. See [[prefers-reduced-motion]].
        - [2026-05-21] Agent used a single 16px shadow for elevation. Looked flat. → Layered shadows at 4–6% opacity. See [[shadows-whisper]].
        - [2026-05-21] Agent picked Inter for a marketing page. Indistinguishable from every other AI page. → Pangram, Geist, or Displaay. See [[typography-humanity]].
        - [2026-05-21] Agent applied `prefers-reduced-motion` to disable a loading spinner without providing a static replacement. Critical accessibility miss. See [[prefers-reduced-motion]].
        - [2026-09-05] Agent added a click sound to every button and a "whoosh" to every route change. Users muted the whole site within a day. → Sound only on daily and rare moments, off by default. See [[sound-decision-framework]].
        - [2026-09-05] Agent created `new AudioContext()` at module load; sounds silently dropped until the second click. → Create and resume inside the first user gesture. See [[sound-playback-web]].
        - [2026-09-05] Agent shipped a generated "success" sound with 40ms of leading silence; it read as lagging behind the check animation. → Trim to the first sample above −60 dBFS; transient on the contact frame. See [[sound-spec]] and [[sound-motion-sync]].
        - [2026-09-05] Agent prompted ElevenLabs with "futuristic UI success sound" and got a stock jingle with reverb. → Describe a material and a stick, add "dry, no tail", request 0.5s and cut. See [[sound-generation-elevenlabs]].
        - [2026-09-05] Agent pulled five sounds from three CC0 packs; the product sounded like a browser toolbar. → One material per product; re-pitch a single family. See [[sound-palette]].
        - [2026-09-05] Agent laid a music bed under a logo reveal to hide the sync work; the piece felt cheap. → Cut with no bed first; place hits on frames; add a bed at −24 LUFS or not at all. See [[launch-video-sound]].
        
        ## Why this file matters
        
        A skill's description and main nodes encode the **happy path** — what to do. Gotchas encode the **failure mode** — what *not* to do, with examples. The model uses both to triangulate.
        
        The Perplexity team finds gotchas often help more than positive guidance. If you're unsure whether a piece of advice belongs in a main node or here, **prefer here**.
        
        ## When to add
        
        - Every time the agent gets a detail wrong.
        - Every time a real user reports a UI issue caused by AI-generated code.
        - Every time a code review flags a regression on a polished interaction.
        
        ## Don't put
        
        - Things the model already knows from training data (write commands, syntax).
        - Personal taste calls — those go in [[pov]].
        - Long explanations — keep gotchas to one line. If it needs explanation, link to a node.
        
        — append below this line —
        - [2026-09-05] Agent copied the parent's 16px radius onto a nested card; corners pinched. → inner = outer − padding. See [[depth-and-nesting]].
        - [2026-09-05] Agent "fixed" grey-on-grey text by raising saturation; contrast unchanged. → move L only. See [[color-scales-oklch]].
        - [2026-09-05] Agent put `text-wrap: balance` on body paragraphs; every column narrowed. → headings balance, paragraphs don't. See [[line-behavior]].
        - [2026-09-05] Agent hid a closed drawer with `opacity: 0`; keyboard users tabbed into it. → `inert` or `visibility: hidden`. See [[touch-and-focus]].
        - [2026-09-05] Agent sprinkled `will-change: transform` on every card "for performance". → only after an observed first-frame hitch. See [[performance-discipline]].
        - [2026-09-05] Agent restyled a generated hero without deleting anything; the tells survived under new colors. → subtract first. See [[unslop-pass]].
        - [2026-09-05] Agent rotated an SVG arm around (0,0) — it orbited the corner. → `transform-box: fill-box` + explicit origin. See [[svg-animation]].
        - [2026-09-05] Agent morphed a four-line rect into a four-arc circle with CSS `d`; Safari jumped. → same command count and type. See [[svg-path-morphing]].
        - [2026-09-05] Agent loaded four craft skills at once for one review and applied none properly. → one or two owners. See [[skill-router]].
        
      • pov.md 13.8 KB
        ---
        title: pov
        summary: Installer's opinions and taste calls. This install is forked to Duolingo's design language — physical buttons, meaning-bearing color, rounded lowercase type, celebration on the reward beat.
        tags: [pov, opinion, customizable, duolingo]
        ---
        
        # Point of View
        
        This file is **meant to be edited by you**. The rest of the skill is mostly canonical — Emil, Benji, Jakub, guidelines.sh — and this file is the override layer.
        
        **This install is forked to Duolingo's design language.** Sources are [design.duolingo.com](https://design.duolingo.com) (identity, writing, illustration) plus token values read directly off the shipped product. Where a section below contradicts a canonical node, **this file wins**. Where it is silent, canon holds.
        
        ## How to use this file
        
        Two kinds of entry are useful:
        
        1. **Hard overrides** — "I never use X." or "I always prefer Y over Z."
        2. **Taste calls** — "When in doubt, lean toward A because B."
        
        Be specific. "I like clean UI" changes nothing. "1px borders in all cases; if a border needs more weight, raise contrast not width" changes output.
        
        A taste call should *orient* a decision, not *amputate* a possibility.
        
        ## The stance, in one line
        
        Duolingo's UI is **physical, loud, and kind**. Surfaces look pressable, color carries meaning instead of decoration, type is rounded and lowercase, and the product celebrates the user out loud. Restraint is not the goal here — *legibility of intent* is.
        
        ## 1. Buttons are physical objects, not rectangles
        
        Every pressable surface has a **4px solid lip** in a darker shade of its own fill. The host element reserves that lip with a transparent bottom border, so pressing moves the fill into space that already exists — no reflow, no layout shift.
        
        ```css
        .btn {
          position: relative;
          height: 50px;
          padding: 0 16px;
          border-radius: 12px;
          border-bottom: 4px solid transparent; /* reserves the lip */
          font: 700 15px/1 "DIN Next Rounded", Nunito, system-ui, sans-serif;
          text-transform: uppercase;
          letter-spacing: 0.04em;
          color: #fff;
        }
        .btn::before {
          content: "";
          position: absolute;
          inset: 0;
          z-index: -1;
          border-radius: 12px;
          background: #58CC02;            /* fill */
          box-shadow: 0 4px 0 0 #58A700;  /* lip — same hue, ~15% darker */
        }
        .btn:active::before {
          transform: translateY(4px);
          box-shadow: 0 0 0 0 #58A700;    /* the button compresses into its own lip */
        }
        ```
        
        Rules that come with it:
        
        - **Every fill has a paired lip color.** Feather Green `#58CC02` → Tree Frog `#58A700`. Macaw `#1CB0F6` → `#0096DD`. Fox `#FF9600` → Guinea Pig `#CD7900`. Cardinal `#FF4B4B` → Fire Ant `#EA2B2B`. Bee `#FFC800` → Lion `#FFB100`.
        - **Secondary buttons keep the geometry, drop the fill:** 2px border plus 2px lip in Swan `#E5E5E5`, label in Wolf `#777777` or Macaw.
        - **Labels are ALL CAPS, 700, 15px, `letter-spacing: 0.04em`, no punctuation.** `NO THANKS`, never `NO, THANKS`.
        - **One button shape for the whole product.** One radius, one lip depth. If you are adding a fourth variant, you are solving a hierarchy problem with a shape.
        - Cards, list rows, and pickable answer tiles use the same construction at 2px — pickable things look pickable.
        
        Overrides [[shadows-whisper]]: here the shadow is **structural and opaque**, not atmospheric. Whisper shadows still own *floating* layers (menus, sheets, modals) — anything that leaves the page plane. Anything you press gets a lip.
        
        ## 2. Color carries meaning. When in doubt, go green
        
        | Role | Token | Hex |
        |---|---|---|
        | Primary / correct / progress | Feather Green | `#58CC02` |
        | Primary lip, green text on white | Tree Frog | `#58A700` |
        | Body text, headings | Eel | `#4B4B4B` |
        | Page background | Snow | `#FFFFFF` |
        | Secondary text | Wolf | `#777777` |
        | Disabled text, placeholders | Hare | `#AFAFAF` |
        | Borders, dividers, lips | Swan | `#E5E5E5` |
        | Inset surfaces, tracks | Polar | `#F7F7F7` |
        
        Secondary colors are a **vocabulary, not a palette**. Each owns exactly one meaning and is never used decoratively:
        
        - Macaw `#1CB0F6` — information, secondary action, links
        - Cardinal `#FF4B4B` — wrong, destructive, hearts lost
        - Fox `#FF9600` — streaks, urgency, time
        - Bee `#FFC800` — earned currency, XP, rewards
        - Beetle `#CE82FF` — premium / upgrade
        - Humpback `#2B70C9` — chrome, headers, calm depth
        
        Overrides [[color-monochromatic]]. Mono-plus-one-accent is the wrong default for this language — but its *discipline* survives intact: a color that means nothing gets deleted. Six meaningful hues is a system; six pretty hues is noise.
        
        **Never set text in Feather Green on Snow** — it computes to ≈2.1:1. Green is a *fill*. If you need green type, use Tree Frog (≈4.2:1 on Snow).
        
        **Dark mode is a remap, not a second palette.** Token names stay, values flip: Polar is `#F7F7F7` in light and `#202F36` in dark; the dark ground is `#131F24` and dark text `#F1F7FB`, never `#000`/`#FFF`. A token that must *not* flip carries an explicit `-always-light` / `-always-dark` suffix. Adopt that suffix convention — it kills the whole class of "this looked fine until dark mode" bugs. Agrees with [[dark-mode]] and [[contrast-and-color-scheme]].
        
        ## 3. Type: rounded, lowercase, two faces with a hard role split
        
        **Display face** (Feather Bold's role — substitute any bold rounded display sans):
        
        - Short headlines only, **10 words maximum**. Longer than that is a body-face job.
        - **Always lowercase.** Capitals only for proper nouns. Never Title Case, never all caps.
        - Leading 100–110%. Tracking `-0.02em`. Never below 30px.
        - Left-aligned. Never justified, never hyphenated.
        - Never set it in a neutral like Eel, and never in two secondary colors at once.
        
        **Body face** (DIN Next Rounded's role; **Nunito** is the sanctioned free substitute):
        
        - Everything else — long headlines, subheads, body, UI. Leading 140%, tracking 0, never below 14px.
        - Roughly 1.5× smaller than the display face when the two share a block, with equal leading.
        - **Never mix the two faces inside one sentence.**
        
        Overrides [[typography-humanity]] on two points: rounded-and-friendly is the *brief*, not a default to escape, so Nunito is a correct answer here rather than a tell; and the single-weight rule does not apply — this language needs two faces precisely because the role split does the hierarchy work.
        
        ## 4. Nothing is pointy
        
        Duolingo's illustration guidelines put it flatly: "Pointy shapes are off-brand."
        
        - Everything is built from rounded rectangles, circles, and rounded triangles.
        - Radius scale: `12px` buttons and tiles, `16px` cards and modals, `full` for pills, progress bars, and avatars. Keep the nested-radius math from [[border-radius]].
        - Progress bars are full-radius with a full-radius fill and an inset highlight. They are the most-looked-at element in the product; give them the care.
        - Character eyes are pills, never ovals. Ground shadows are pills, never ovals — an oval implies a perspective the flat style doesn't have.
        
        ## 5. Motion: feedback always, celebration on the reward beat
        
        Canon's default is *don't animate*. This POV **raises the floor by one notch** and leaves the ceiling where Benji put it.
        
        | Frequency | Canonical allowance | This POV |
        |---|---|---|
        | Every keystroke / scroll | None | **None.** Unchanged. |
        | Every tap | Subtle, ~100–160ms | **Always.** The lip compress, ~100ms, plus sound where the platform has it. A tap with no physical response is a bug. |
        | Daily reward beat (lesson done, streak day, XP earned) | Small — a tick | **Medium.** Scale-in, stagger, a number that counts up. The reward loop *is* the product. |
        | Rare milestone | Big | **Big.** Unchanged — confetti belongs here and only here. |
        
        Character motion is small and expressive: a wave, a wing flap, a fist pump. Never sudden, never fast. A mascot that lurches reads as broken rather than alive.
        
        Extends [[delight-impact-curve]] and [[animation-decision-framework]]: the decision tree is unchanged, but "feedback" and "reward" are load-bearing purposes in a habit product, not decoration.
        
        ## 6. Copy: expressive, playful, embracing, worldly
        
        - **Brief, active, direct, excited.** "You did it!" beats "Congratulations." "Check your answer" beats "You can see how you did by checking your answer."
        - **Errors support, never blame.** "Not quite correct. Try again!" — never "Incorrect."
        - **Contractions always.** Gender-neutral always (they/them).
        - **Numerals for every number**, even under 10 — unless one starts a sentence. Comma above 999, but never in an XP or currency total.
        - **Capitalization is minimized:** sentence case for headings, subheads, and page titles; ALL CAPS for buttons; capitalize product-specific proper nouns (feature names, purchasable items) and nothing else.
        - **Punctuation:** none in buttons; none in headlines except `!`. Serial comma. Avoid em dashes and semicolons — write two sentences instead. Write "and", not "&".
        - **Emoji are allowed** in notifications, subject lines, and social — one, roughly half the time, so it keeps its impact. Never as list bullets and never as UI iconography; that stays an [[ai-default-tells]] offense.
        - **Tone reads the room.** Wins get exclamation points. Serious human subject matter gets them stripped out entirely, along with the exuberance. Voice is fixed; tone is not.
        
        Overrides [[copy-voice]] on three specifics: Title Case buttons (→ ALL CAPS), Title Case headings (→ sentence case), and "&" over "and" (→ reversed). Its other rules — active voice, second person, specificity, errors that name the exit — hold.
        
        ## 7. Illustration over stock, characters over icons
        
        - Reach for an illustrated character before a photograph and before a generic glyph. Characters are posed and expressive; a static, expressionless character reads as dead.
        - Keep each illustration to roughly 15 shapes. Six is too abstract; thirty muddies at small sizes.
        - Flat perspective, one line of sight, minimal color count per object.
        - **No gray inside illustrations** — it reads lifeless against the rest of the palette. Because backgrounds are white, use light pastels where you would reach for white.
        - Mascot characters communicate in text, never narrate the product, and never carry anything aggressive.
        
        Extends [[content-authenticity]]: an original character beats a stock photo beats a Lucide glyph, in that order.
        
        ## 8. What the brand does *not* override
        
        Two things outrank every taste call above.
        
        1. **[[accessibility-baseline]].** White on Feather Green computes to ≈2.1:1, white on Tree Frog to ≈3.0:1 — neither clears WCAG AA for normal text. Duolingo ships it; you should not, unless you have checked it. Either darken the fill for text-bearing surfaces (≈`#367F00` clears 4.5:1 against white) or accept it only for large bold labels *with* a non-color affordance carrying the same meaning. Never let green-vs-red be the only signal for right-vs-wrong.
        2. **[[prefers-reduced-motion]].** Celebration is the first thing to drop, not the last. Kill the confetti, the count-up, and the mascot loop; keep opacity fades and keep the button's state change legible.
        
        ## Where this overrides the canonical graph
        
        | Canonical node | Its default | This POV |
        |---|---|---|
        | [[shadows-whisper]] | Layered shadows at 4–6%, `#111` base | Opaque 4px lip on anything pressable; whisper shadows only for floating layers |
        | [[color-monochromatic]] | One hue plus one accent | Six named hues, one meaning each; green leads |
        | [[typography-humanity]] | Avoid Google-default faces; one weight | Rounded is the brief; Nunito sanctioned; two faces, hard role split |
        | [[copy-voice]] | Title Case buttons/headings, "&" over "and" | ALL CAPS buttons, sentence case headings, "and" over "&" |
        | [[delight-impact-curve]] | Daily moments get a tick | Daily reward beat gets medium-tier celebration |
        | [[hover-states-subtle]] | 1px shifts, no lift | Press compresses 4px; hover lightens the fill |
        | [[visual-imperfection]] | Imperfection lives on marketing surfaces | Warmth comes from rounded geometry and characters, not from texture or wobble |
        
        Everything not listed is unchanged. [[states-are-the-work]], [[data-is-content]], [[dependency-discipline]], [[transform-opacity-only]], [[never-scale-from-zero]], and [[feeling-right]] apply exactly as written.
        
        ## Reviewing under this POV
        
        When reviewing UI in a product built on this language, the [[review-format]] table judges against the values above — a flat button with no lip, a Title Case CTA, or a silent correct-answer state are **findings**, not preferences. When reviewing a product that is *not* on this language, fall back to canon and say so.
        
        ## Your POV (add yours below)
        
        > The narrow calls the sections above deliberately leave open. Examples:
        >
        > - I always prefer ____ over ____.
        > - In my products, ____ is non-negotiable.
        > - Skip ____, even if the rest of the skill recommends it. The reason is ____.
        
        ## Gotcha
        
        Do not confuse POV with [[gotchas]]:
        
        - **gotchas** = "the agent did this wrong, here's the right answer." Negative examples, append-only.
        - **pov** = "I prefer this default over the canonical default." Taste overrides.
        
        Both grow over time. Both outrank canonical content. The framing is different.
        
        ## When the agent should consult this file
        
        Always. Before producing UI code or a review, load this file alongside [[gotchas]].
        
        If the user explicitly says "ignore my pov" or "use canonical defaults," skip this file for that response only.
        
        ## Sources
        
        - [design.duolingo.com](https://design.duolingo.com) — identity (logos, color, typography, imagery, brand family), writing (brand narrative, voice, tone, Duo, style), illustration (shape language, characters, Duo), marketing.
        - Token values, the button construction, and the light/dark remap read off the shipped Duolingo product (`--color-*` and `--text-*` custom properties).
        - Contrast ratios computed from the published hex values, not quoted from the guidelines.
        - Perplexity Agent Skills team — the "inject your opinion" principle. Henry Modisett (Perplexity head of design) — design Skills written for personal taste.
        
      • prototype-picker.md 4.6 KB
        ---
        title: prototype-picker
        summary: When the decision hasn't been made, build three to five genuinely different versions behind a live picker — real materials, worst content, a named axis each, every uncertain number on a control — then write the decision down, promote the winner, and delete the harness.
        tags: [meta, prototyping, exploration, workflow]
        ---
        
        # Prototype behind a picker
        
        A divergence workflow for the exploration phase: a rough description and a decision nobody has made yet. If the user has already decided, build the one thing — a picker holding three answers to a settled question wastes the run. This is the "don't know what it should look like yet" entry in [[stacking-chains]], expanded.
        
        ## Hard rules
        
        1. **Never touch production code during exploration.** Prototypes live in a throwaway route (`/proto/<slug>`) imported by nothing. Integration happens only for the winner.
        2. **Real materials, worst content.** Real fonts, tokens, and components — a prototype built from other materials answers a question about an interface you aren't building. Then fill it with the content that breaks it: the longest real name, the two-line title, forty rows, the empty state. Pretty placeholder content makes every variant look fine and hides the decision.
        3. **Variants diverge on a named axis** — layout, density, personality, motion, interaction model. If you cannot state each variant's axis in a phrase, you have three tints of one idea. Names describe direction ("Quiet", "Editorial", "Dense"), never "Option A/B/C".
        4. **Every variant fully works.** Real interactions, motion, states. No dead buttons, no "imagine this part".
        5. **The picker is chrome, not a contestant.** A fixed dark pill, bottom-center, unbranded, never restyled with the project's tokens. Number keys and arrows switch, `R` replays, `?v=2` persists the selection. Switching is instant — it happens a hundred times a session.
        6. **The output is a decision, not code.**
        
        ## Workflow
        
        - **Scope** one thing per run; if the brief spans a dashboard, pick the highest-leverage piece and offer the rest as follow-ups.
        - **Recon** the stack, materials, personality, and context. If the piece exists, the current implementation is variant 1, unchanged — a baseline beats a memory.
        - **Loadout**: before variant code, load the nodes the brief needs — [[feeling-right]] always; then [[type-scale-and-rhythm]], [[color-scales-oklch]], [[depth-and-nesting]], [[animation-decision-framework]], [[forms-behavior]], [[marketing-surface-rules]] as relevant; [[ui-polish-pass]] and [[touch-and-focus]] before the user sees anything. The loadout applies to every variant equally, or the comparison is rigged.
        - **Three variants** by default, up to five. Render one at a time, full size, in realistic surrounding context; thumbnails distort spacing.
        - **Controls** whenever the brief has a number in it — duration, easing, spring, blur, radius, offset. Any number you would hardcode is a decision not yet made; put it on a slider with a range wide enough to be wrong, a speed multiplier on anything that moves, the value visible and copyable. Controls write to CSS variables, not React state, so the slider itself doesn't stutter.
        - **Verify** every variant yourself: renders, responds, clean console, no [[review-checklist]] escalations.
        - **Present** a table — variant, axis, when it wins, its cost — with the URL and keys. Then **stop**; the choice is the user's. Never pre-pick a favorite.
        
        ## Promote and write it down
        
        When the user picks, write the decision first — it is the only artifact that survives:
        
        ```md
        Dropdown entrance — decided from /proto/dropdown
        - scale from 0.96, opacity 0 → 1; 180ms enter, 140ms exit, cubic-bezier(0.32, 0.72, 0, 1)
        - Rejected: slide-down felt heavy at 200ms and jittery below it
        ```
        
        The rejected options are the part everyone skips and the part that saves the next discussion. Then integrate the winner per project conventions ([[component-api-design]] if it becomes shared) and delete the route. A picker that survives the run is exactly the dead code rule 1 exists to prevent.
        
        ## When to apply
        
        "Show me a few options", "which feels better", "not sure if". Not for reviewing existing UI ([[review-format]]) or polishing a finished component ([[ui-polish-pass]]).
        
        ## Gotcha
        
        Divergence is not an excuse to drop the craft bar. A sloppy variant doesn't widen the exploration; the user rejects a good direction because the type was ugly, and the run taught them nothing.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on prototyping, distilled by HKTITAN; Josh Puckett's DialKit for the control panel.
        - Related: [[taste-is-trained]], [[stacking-chains]], [[build-a-tool]], [[vibe-to-generator]].
        
      • review-checklist.md 3.4 KB
        ---
        title: review-checklist
        summary: Audit table — thirteen common UI issues with the fix for each. Scan against this before signing off.
        tags: [meta, review, checklist]
        ---
        
        # Review checklist
        
        A pre-flight checklist for UI code review. Scan the user's code against each row. If any row applies, write it up using [[review-format]].
        
        ## The checklist
        
        | # | Issue | Quick check | Fix | Node |
        |---|---|---|---|---|
        | 1 | `transition: all` | Grep for `transition: all` | Specify `transform, opacity` (or other GPU-safe properties) | [[transform-opacity-only]] |
        | 2 | `scale(0)` entrance | Grep for `scale(0)`, `scale: 0` | Use `scale(0.95)` + opacity | [[never-scale-from-zero]] |
        | 3 | `ease-in` on UI | Grep for `ease-in` (not `ease-in-out`) | Use `ease-out` or custom cubic-bezier | [[easing-curves]] |
        | 4 | Animation duration > 400ms | Search durations | Most UI is <300ms; reserve longer for rare moments | [[duration-table]] |
        | 5 | Crossfade between two icons | Two icons in same slot with opacity transitions | Transform a single icon (rotate, morph) | [[fly-not-teleport]] |
        | 6 | No `prefers-reduced-motion` guard | Grep for `@media (prefers-reduced-motion` | Add for any translate/scale animation | [[prefers-reduced-motion]] |
        | 7 | `:hover` on touch | Grep for `:hover` without media query gate | Wrap in `@media (hover: hover)` | [[hover-states-subtle]] |
        | 8 | Default Bootstrap shadow | Grep for `box-shadow: 0 2px` (or similar) | Layered shadows at 4–6% opacity | [[shadows-whisper]] |
        | 9 | Pure `#000` dark mode | Grep for `background: #000` (or `black`) | Use `#18181b` or `#1a1a1a` | [[dark-mode]] |
        | 10 | Spinner under 800ms expected wait | Look for `<Spinner />` on quick async calls | Skip the spinner; or use skeleton | [[empty-loading-states]] |
        | 11 | Inter / SF Pro on marketing page | Check font-family on landing/marketing routes | Use a less-default sans (Geist, Pangram, Displaay) | [[typography-humanity]] |
        | 12 | Sound with no mute, or on by default | Grep for `AudioContext`, `new Audio(`, `useSound`; look for a persisted toggle | Off by default, discoverable toggle, persisted; never sound-only information | [[sound-playback-web]] |
        | 13 | Sound on hover / focus / keystroke, or on page load | Grep for `play(` inside `mouseenter`, `focus`, `keydown`, or module scope | Delete it; sound belongs to daily and rare moments only | [[sound-decision-framework]] |
        
        ## Workflow
        
        1. Read the code top to bottom once for context.
        2. Run through the checklist row by row, noting which apply.
        3. Open [[ai-default-tells]] for any visible defaults beyond this checklist.
        4. Write the review using [[review-format]] (Before / After / Why table), one row per applicable issue.
        5. If nothing applies — say so. "Looks good, nothing to change" is a valid review.
        
        ## When to use
        
        Every UI code review. This is the systematic pass *before* the taste-driven pass.
        
        ## Gotcha
        
        Don't run the checklist mechanically without context. Item 4 ("duration > 400ms") is fine for rare-moment animations (see [[delight-impact-curve]]) — it's a flag, not a rule. Always check the context before writing the row.
        
        Also: the checklist is not exhaustive. The most valuable reviews catch what's *not* on this list. Use it as a floor, not a ceiling.
        
        ## Sources
        
        - Emil Kowalski — Review Checklist section of [emilkowalski/skill](https://github.com/emilkowalski/skill).
        - Related: [[review-format]] for the output format, [[ai-default-tells]] for broader visual audit.
        
      • review-format.md 3.9 KB
        ---
        title: review-format
        summary: Mandatory format for UI code reviews — Before | After | Why markdown table.
        tags: [meta, review, format]
        ---
        
        # Review format (required)
        
        When the user asks for a UI code review, output a markdown table with three columns: **Before**, **After**, **Why**. This is non-negotiable — it forces concrete diffs instead of vague advice.
        
        ## The required format
        
        ```markdown
        | Before | After | Why |
        |---|---|---|
        | `transition: all 200ms` | `transition: transform 200ms, opacity 200ms` | Animating `all` triggers layout/paint on properties the browser doesn't know are safe. Specify the GPU-cheap ones explicitly. See [[transform-opacity-only]]. |
        | `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Scale-from-zero looks like an inflating balloon and renders blurry sub-pixel during the first frames. See [[never-scale-from-zero]]. |
        | `transition: all 0.3s ease-in` | `transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1)` | `ease-in` makes UI feel hesitant. Use `ease-out-quart` or similar for entrances. See [[easing-curves]]. |
        ```
        
        ## What each column does
        
        - **Before** — the actual line(s) from the user's code. Quote in inline code or fenced if multi-line. Don't paraphrase.
        - **After** — the suggested replacement. Equivalent function, better feel.
        - **Why** — one short sentence on the rationale, plus a wikilink to the relevant skill node for depth.
        
        ## Rules
        
        1. **One issue per row.** Don't combine multiple changes into a single Before/After. If a snippet has three issues, write three rows quoting overlapping sections.
        2. **No row without a Why.** "Just better" is not a Why. If you can't articulate why in one sentence, you're probably reaching.
        3. **Link to nodes.** The Why should end with a `[[node-name]]` wikilink. This is how the agent invites the user to learn more without bloating the response.
        4. **Don't fix in prose around the table.** All actionable items go in the table. Prose around it is for high-level context only (e.g., "Three issues in the modal entrance:").
        5. **Order by impact.** Most-impactful issue first. Don't bury the lede.
        
        ## When this format applies
        
        - "Review this component."
        - "Critique this animation."
        - "What's wrong with this hover state?"
        - "Is this UI well-built?"
        - Any code review where the user shares CSS, motion, or component code.
        
        ## When this format does NOT apply
        
        - High-level design questions ("should this animate at all?") — prose answer pointing at [[animation-decision-framework]].
        - API/architecture questions — prose answer.
        - Bug diagnostics ("why isn't this working?") — investigation in prose, table only if there are multiple actionable fixes.
        - "Build me X" requests — produce the code, then optionally a table of *choices made* (Before: alternative approach, After: chosen approach, Why: rationale).
        
        ## Counter-example
        
        **Wrong — vague prose review:**
        
        > Your modal animation could be smoother. The transition is a bit too long and the scale starts too small. Consider using a different easing curve.
        
        **Right — Before/After/Why table:**
        
        ```markdown
        | Before | After | Why |
        |---|---|---|
        | `transition: transform 0.6s ease` | `transition: transform 0.25s cubic-bezier(0.25, 1, 0.5, 1)` | UI animations under 300ms feel responsive; 600ms feels theatrical. See [[duration-table]]. |
        | `transform: scale(0)` | `transform: scale(0.95)` | Scale-from-zero is inflating-balloon energy and renders blurry. See [[never-scale-from-zero]]. |
        ```
        
        The wrong version is unactionable. The right version tells the user exactly what to type.
        
        ## Gotcha
        
        Don't include the table if there are no issues. "This looks great — nothing to change" is a valid review and better than inventing nitpicks to fill rows.
        
        ## Sources
        
        - Emil Kowalski — Review Format (Required) section of [emilkowalski/skill](https://github.com/emilkowalski/skill).
        - Related: [[review-checklist]] for the audit categories to scan against.
        
      • routing-table.md 5.8 KB
        ---
        title: routing-table
        summary: Intent → entry-node router. Name the job first, then open exactly one node — most questions have one canonical entry point into the graph.
        tags: [meta, routing, navigation]
        ---
        
        # Routing table — intent → entry node
        
        Most wasted context comes from opening the wrong cluster. Before reading anything, classify the user's intent into one of four postures, then jump straight to the entry node for that job. One good entry beats scanning three MOCs.
        
        ## The four postures
        
        | Posture | The user wants… | Output shape |
        |---|---|---|
        | **Build** | UI produced or changed | Code, using the cluster's values |
        | **Judge** | Existing work assessed | A table or list — never unsolicited edits |
        | **Decide** | A call made *before* building | A yes/no or a pick, with the trade named |
        | **Name** | The precise word for a thing | The term, one line, stop |
        
        Judge-posture outputs are read-only: return the [[review-format]] table or a deletion list, don't rewrite the code unless asked. Decide-posture nodes (like [[animation-decision-framework]]) must be consulted *before* writing code, not cited after. Name-posture lookups ([[design-vocabulary]]) answer and stop — no implementation.
        
        ## The router
        
        | You want to… | Open first |
        |---|---|
        | Understand why a screen feels flat or assembled | [[feeling-right]] |
        | Decide whether something should animate at all | [[animation-decision-framework]] |
        | Pick easing / duration for one animation | [[easing-curves]], then [[duration-table]] |
        | Build drag / swipe / momentum interactions | [[gesture-momentum]], [[spring-animations]] |
        | Fix jank, stutter, or layout thrash | [[transform-opacity-only]], then [[debugging-animations]] |
        | Pick or fix a typeface, wrapping, tracking | [[typography-humanity]], [[line-length-tracking]] |
        | Build a palette, fix contrast, derive dark mode | [[color-monochromatic]], [[contrast-and-color-scheme]], [[dark-mode]] |
        | Add depth — shadows, borders, radii | [[shadows-whisper]], [[border-radius]] |
        | Build a form that behaves | [[forms-validation]] |
        | Write empty / loading / error states | [[empty-loading-states]], [[states-are-the-work]] |
        | Write UI copy or error messages | [[copy-voice]] |
        | Review a UI diff before shipping | [[review-format]] + [[review-checklist]] (or spawn [[ui-reviewer]]) |
        | Review a diff that is *specifically* motion | [[motion-auditor]] |
        | Audit "does this look AI-generated?" | [[ai-default-tells]] (or spawn [[anti-pattern-scanner]]) |
        | Build a marketing / landing surface | [[marketing-vs-product-ui]] |
        | Consume a project's DESIGN.md tokens | [[using-design-md]] (or spawn [[design-md-consumer]]) |
        | Put the right word to a design idea | [[design-vocabulary]] |
        | Decide whether an interaction should make a sound | [[sound-decision-framework]] |
        | Design or generate a set of UI sounds | [[sound-palette]], then [[sound-generation-elevenlabs]] or [[sound-generation-open-source]] (or spawn [[sound-designer]]) |
        | Sync a sound to an animation, or fix one that feels late | [[sound-motion-sync]] |
        | Score a launch video or logo reveal | [[launch-video-sound]]; derive the stem from the timeline → [[sound-from-motion]] |
        | Cut a multi-scene video so it reads as one move | [[launch-video-seams]] |
        | Decide which skill or companion should own a job | [[skill-router]] |
        | Build an OKLCH palette or ramp, repair contrast by lightness | [[color-scales-oklch]] |
        | Fix pinched corners, harsh borders, dark-mode cards vanishing | [[depth-and-nesting]] |
        | Set up a type scale, leading, tracking, font loading | [[type-scale-and-rhythm]] |
        | Fix wrapping, truncation, underlines, casing, RTL | [[line-behavior]] |
        | Make a form behave (labels, Enter, loading, autofocus) | [[forms-behavior]] |
        | Make it work on touch, keyboard, screen readers, iOS | [[touch-and-focus]] |
        | "Feels unfinished" with nothing obviously wrong | [[ui-polish-pass]] |
        | Design a component's props API | [[component-api-design]] |
        | Build a landing page, docs site, blog, changelog | [[marketing-surface-rules]] |
        | Fix jank, layout shift, slow lists (measured slowness) | [[performance-discipline]] |
        | "Looks AI-generated" — run the pass | [[unslop-pass]]; copy → [[copy-tells]]; code → [[code-tells]] |
        | Create or clean up an SVG | [[svg-creation]] (or spawn [[svg-creator]]) |
        | Animate an SVG, morph a path, vectorize a clip | [[svg-animation]], [[svg-path-morphing]], [[video-to-vector-pipeline]] (or spawn [[svg-animator]]) |
        | Explore several directions before deciding | [[prototype-picker]] |
        | Stop re-prompting; build the tool that makes the artifact | [[build-a-tool]]; no target yet → [[vibe-to-generator]] |
        | Write design docs an agent can follow | [[design-system-docs]] |
        | Write or fix a skill file, or [[pov]] | [[skill-writing-rules]] |
        | Measure whether a change made output better | [[design-benchmarks]] |
        
        ## Rules
        
        1. **Route before reading.** Classify posture, pick the entry node, open it. Only widen to the MOC when the entry node's outbound links don't cover the question.
        2. **One entry node per question.** If you find yourself opening four nodes up front, you skipped classification.
        3. **Judge posture never writes.** A review request gets a table; a fix request gets code. Don't convert one into the other silently.
        4. **Always end with the overrides.** Whatever the route, load [[gotchas]] and [[pov]] before producing output — installer taste wins over canonical defaults.
        
        ## Gotcha
        
        The router is for *entry*, not *coverage*. Landing on [[easing-curves]] doesn't exempt you from [[prefers-reduced-motion]] — follow the entry node's own outbound links for the mandatory companions.
        
        ## Sources
        
        - HKTITAN — routing patterns distilled from operating this graph across real sessions.
        - Related: [[disambiguation]] for the intents that blur together, [[stacking-chains]] for multi-step jobs.
        
      • skill-router.md 5.5 KB
        ---
        title: skill-router
        summary: What `/design-engineering` does first — classify the phase and the material, resolve the design contract, then hand the job to the owning node, subagent, or installed companion skill (AgentsORG design, impeccable, HyperFrames, ElevenLabs, the transitions catalog, the shadcn CLI). One or two owners, never five.
        tags: [meta, routing, ecosystem, skills]
        ---
        
        # The skill router
        
        `/design-engineering` is a router before it is a library. Loading four craft skills at once spreads attention across four bars and applies none of them, so the first job on any request is to name **one or two owners** and hand off. [[routing-table]] maps intent to a node *inside* this graph; this node adds the phase question and the ecosystem *outside* it, and the precedence that keeps them from fighting.
        
        ## 1. Resolve the contract first
        
        Walk up from the edited file. A `.design` (schema `design.v1`) outranks everything below the user's prompt — see [[using-design-file]]. In a video project, `frame.md` → `design.md` → `DESIGN.md` resolve in that order ([[using-design-md]]). A contract's `tokens.motion` and `tokens.sound` win over this graph's duration and level tables; the graph supplies craft where the contract is silent, and says so.
        
        Precedence: user prompt → nearest `.design` → the `design` skill procedure → **this graph and its companions** → model defaults.
        
        ## 2. Classify the phase
        
        | Phase | The user is saying | Owner |
        |---|---|---|
        | Undecided | "try a few", "not sure which", "show me options" | [[prototype-picker]] — before any building skill |
        | Stuck on a vibe | "something like…", "I'll know it when I see it" | [[vibe-to-generator]] |
        | Foundation | "build this screen", "lay this out" | [[feeling-right]] → [[stacking-chains]] |
        | Pieces | card, form, component, landing page, sound, SVG | the material node below, or its subagent |
        | Works, feels off | "feels cheap", "janky", "broken on mobile" | [[ui-polish-pass]] / [[motion-auditor]] / [[touch-and-focus]] |
        | Done | "does this look right", "review before I ship" | [[ui-reviewer]] or [[review-format]] |
        | The words | "what's it called when…" | [[design-vocabulary]] — answer and stop |
        | Keeps missing | "closer, but not quite" after a round or two | [[build-a-tool]] |
        
        ## 3. Pick the material
        
        Type → [[typography-humanity]], [[type-scale-and-rhythm]]. Color → [[color-scales-oklch]]. Depth → [[depth-and-nesting]]. Motion → [[animation-decision-framework]]. Sound → [[sound-decision-framework]] / [[sound-designer]]. SVG → [[svg-creation]] / [[svg-creator]], [[svg-animation]] / [[svg-animator]]. Forms → [[forms-behavior]]. Props → [[component-api-design]]. Marketing → [[marketing-surface-rules]]. Generated look → [[unslop-pass]] / [[anti-pattern-scanner]]. Speed → [[performance-discipline]]. Docs for agents → [[design-system-docs]].
        
        ## 4. Hand off to installed companions
        
        A companion is installed when its `SKILL.md` exists under `.agents/skills/`, `.claude/skills/`, or a plugin cache, or its slash command is registered. Hand the job over, pass the contract, and run the result back through [[gotchas]], [[pov]], and (for reviews) [[review-format]]. If it is not installed, do the job from this graph and say which companion would have owned it.
        
        | Job | Companion | Boundary |
        |---|---|---|
        | Discover, follow, update, verify a `.design`; bootstrap one | AgentsORG `design` skill | It owns the contract; this graph never redefines a token |
        | A named pass: polish, critique, audit, typeset, layout, colorize, animate, distill, harden, onboard, clarify, adapt, optimize | impeccable (`/impeccable <command>`) | Its output still passes [[review-checklist]]; a DESIGN.md it writes imports per [[using-design-md]] |
        | A launch film, reveal, or any rendered motion piece | HyperFrames (`hyperframes-creative`, `hyperframes-audio`) | This graph supplies [[launch-video-sound]] and the frame.md export from the contract |
        | Generating audio files | ElevenLabs `sound-effects` / `text-to-speech` | [[sound-palette]] material is the prompt prefix; [[sound-spec]] is the acceptance test |
        | One of the thirty-two canonical CSS transitions | `transitions-dev` | Values still checked against [[easing-curves]] and [[duration-table]] |
        | Installing or theming components | shadcn CLI / MCP (`info`, `search`, `view`, `add --dry-run`, `apply --preset`, `migrate`) | Driven by the contract's `integrations.shadcn`; tokens win over `css_vars` |
        | A curated UI sound file | `soundcn` via `npx shadcn add @soundcn/<name>` | Re-pitch into the family; never ship raw |
        
        ## Rules
        
        1. **Two questions, then act.** Phase, then material. If you opened four nodes before answering either, start over.
        2. **Judge posture never writes; build posture never reviews silently.** Keep the posture the user asked for.
        3. **Companions inherit the contract and the overrides.** Nothing leaves this router without [[gotchas]] and [[pov]] applied.
        4. **Say when nothing fits.** Backend logic, data fetching, build tooling, and state management are not this graph's job; answer plainly and say no skill applies.
        
        ## Gotcha
        
        Polishing a screen whose layout is still wrong wastes the pass — the polish is thrown away with the layout. Phase decides before material does.
        
        ## Sources
        
        - HKTITAN — routing distilled from operating this graph beside its companions.
        - AgentsORG `.design` spec §5 (precedence) and `skills/design` §8 (companion skills); impeccable command surface; HyperFrames `references/design-spec.md`.
        - Related: [[routing-table]], [[disambiguation]], [[stacking-chains]].
        
      • skill-writing-rules.md 4.1 KB
        ---
        title: skill-writing-rules
        summary: A skill fights probabilistic output by forcing the same process, not the same output — encode decision trees, write the why, be strict, make every line earn its place, keep one skill per aspect, and test by running with and without a line.
        tags: [meta, skills, authoring, pov]
        ---
        
        # Writing skills that change behavior
        
        Agents are probabilistic: ask twice and you get two answers. A skill prevents that not by forcing the same *output* but the same *process* — the outputs still differ, every context is different, but the reasoning becomes yours and stays the same every run. Write like you are guiding a less experienced designer who happens to be very fast. These rules govern every node in this graph and the installer's [[pov]]; [[pov-curator]] applies them.
        
        ## 1. Encode process, not output
        
        Give a decision procedure the agent walks every time. Decision trees are ideal:
        
        ```text
        Entering or exiting the viewport? → ease-out
        Moving or morphing on screen?     → ease-in-out
        A hover change?                   → ease
        Constant motion?                  → linear
        Default                           → ease-out
        ```
        
        Without it the agent picks whatever feels right that day; with it, every run walks the same tree. You narrow the array of answers it chooses from.
        
        ## 2. Write down the why
        
        Bad: "Start scale animations from 0.95." Good: "Start from 0.95, not 0. Elements appearing from nothing feel unnatural — real objects always have a visible shape. The higher the initial value, the gentler the entrance." A rule without reasoning is applied blindly, including where it shouldn't be; a rule with reasoning is extended to cases you never wrote down.
        
        ## 3. Be strict
        
        "Reasonably short", "tasteful", "where appropriate", "try to avoid" change nothing — the agent can't act on them and falls back to its defaults. "UI animations stay under 300ms. Exits are ~20% faster than entrances. Larger elements animate slower than smaller ones." Words carry weight: *never*, *always*, *strict* anchor behavior. The strictness can feel like it leaves no room for creativity; the creative part is still yours — the skill stops the agent guessing at the parts you've already figured out.
        
        ## 4. Every line must earn its place
        
        Go through the file sentence by sentence and ask: does this line change what the agent does? It already knows what a transform is, what a modal is. Explaining these doesn't just waste space; it dilutes the lines that matter, because attention is spread across everything you wrote. A great skill, like a great animation, is defined by what you leave out.
        
        ## 5. Keep skills focused
        
        One skill per aspect of the interface — and narrower is better: building animations and reviewing them are different skills. This graph does the same with nodes: one node, one complete thought, 40–80 lines.
        
        ## 6. Test by running
        
        Unsure whether a line matters? Run the skill with it and without it and compare the output. It is the loop that built taste in the first place — create, notice what feels off, articulate why, refine — except the thing being refined is the document that carries the taste, and every improvement compounds across everything the agents build after.
        
        ## Common mistakes
        
        Describing outcomes ("animations should feel smooth"); rules without reasoning; hedge words; explaining what the agent already knows; one giant skill; shipping untested. And the meta-mistake: dumping knowledge into a skill *before* running it on a brain dump — the better order is notes first, then extraction into a strict, focused, reasoned file.
        
        ## When to apply
        
        Authoring or fixing any SKILL.md, this graph's nodes, [[pov]], a project's `AGENTS.md` design section, or [[design-system-docs]]; diagnosing "the agent ignores my skill".
        
        ## Gotcha
        
        Strict is not long. A strict skill is usually shorter than the hedged version it replaces, because every hedge was a sentence that did nothing.
        
        ## Sources
        
        - Emil Kowalski, *Agents with Taste*, and his practice on writing skills, distilled by HKTITAN.
        - Related: [[pov]], [[gotchas]], [[taste-is-trained]], [[design-system-docs]].
        
      • stacking-chains.md 4.8 KB
        ---
        title: stacking-chains
        summary: Ordered node chains for multi-step jobs — a new screen, a feel-better pass, a design system, a marketing site — so sequencing decisions aren't re-derived per session.
        tags: [meta, routing, workflow]
        ---
        
        # Stacking chains — ordered routes for multi-step jobs
        
        Single questions route to single nodes ([[routing-table]]). Real jobs — "build this screen", "make the app feel better" — chain several clusters, and the *order* matters: deciding motion before layout wastes work; polishing before the states exist polishes the wrong thing. These chains encode the orderings that survived use.
        
        ## The chains
        
        ### New feature screen, start to ship
        
        [[feeling-right]] → [[typography-humanity]] + [[color-monochromatic]] + [[shadows-whisper]] → [[forms-validation]] (if inputs) → [[animation-decision-framework]] → [[accessibility-baseline]] → [[review-checklist]] → [[review-format]]
        
        Structure first, surfaces second, behavior third, motion only after the static version works, floor checks before the gate.
        
        ### "Make this app feel better"
        
        [[motion-auditor]] (audit what exists) → [[delight-impact-curve]] (where motion is missing — propose few) → [[review-checklist]] (the finishing pass)
        
        Fix what's wrong before adding what's absent.
        
        ### Design system from scratch
        
        [[color-monochromatic]] → [[typography-humanity]] → [[shadows-whisper]] + [[border-radius]] → [[using-design-md]] (capture the tokens so agents consume, not reinvent)
        
        ### Marketing surface
        
        [[marketing-vs-product-ui]] → [[typography-humanity]] → [[ai-default-tells]] (delete pass) → [[review-format]]
        
        Marketing tolerates more expression and *less* default — the deletion pass is not optional here.
        
        ### Accessibility pass
        
        [[accessibility-baseline]] → [[contrast-and-color-scheme]] → [[prefers-reduced-motion]]
        
        ### Slow or janky app
        
        [[transform-opacity-only]] → [[debugging-animations]] → [[duration-table]]
        
        Property discipline first — most jank is animating the wrong property, not animating too much.
        
        ### Add sound to a product
        
        [[sound-decision-framework]] (reject most) → [[sound-palette]] (one material, write the manifest) → [[sound-generation-elevenlabs]] or [[sound-generation-open-source]] → [[sound-spec]] → [[sound-playback-web]] → [[sound-motion-sync]] → [[review-checklist]]
        
        Decide before designing, design before generating, spec before wiring. Or spawn [[sound-designer]] for the whole chain.
        
        ### Launch video or logo reveal
        
        [[marketing-vs-product-ui]] → [[stagger-choreography]] (lock picture) → [[launch-video-sound]] (write the sound map) → [[sound-palette]] → generation node → [[sound-motion-sync]] (transients on contact frames) → [[sound-spec]] (master to −14 LUFS)
        
        Picture locks first; sound is placed on frames and cannot be placed on frames that move.
        
        ### Don't know what it should look like yet
        
        [[prototype-picker]] — three genuinely different versions behind a live picker, each diverging on a *named axis* (layout, density, personality, motion, interaction model), numbers on controls, the decision written down, the harness deleted. Converge on one, then enter the "new feature screen" chain at the surfaces step. Only a vibe and no target → [[vibe-to-generator]]; a target the prompt keeps missing → [[build-a-tool]]. See [[taste-is-trained]]: comparison is how taste gets exercised.
        
        ### Mascot or vector asset, from clip to product
        
        [[icon-systems]] (the brief: flat, few colors, one stroke language) → [[svg-creation]] → [[video-to-vector-pipeline]] (if the source is a clip) → [[svg-animation]] → [[sound-motion-sync]] (if it has a reveal) → [[review-checklist]]
        
        ### "Looks AI-generated"
        
        [[unslop-pass]] (subtract, then sweep) → [[copy-tells]] → [[color-scales-oklch]] + [[typography-humanity]] (put a point of view back) → [[review-format]]
        
        ### Documenting the system for agents
        
        [[design-system-docs]] (inventory, template, verify by prompting) → [[using-design-file]] or [[using-design-md]] (where the machine-readable half lives) → [[skill-writing-rules]] (the language)
        
        ## Rules
        
        1. **Chains are orderings, not checklists.** Skip links that don't apply; never reorder them.
        2. **Each link is a read, not a rewrite.** Enter a link, apply its values to the work in hand, move on.
        3. **Every shipping chain ends at [[review-format]].** If the chain ends and no review happened, it isn't done.
        4. **[[gotchas]] and [[pov]] apply at every link**, not as a final step — an installer override can prune a whole link.
        
        ## Gotcha
        
        Don't run a full chain on a one-line change. Chains are for jobs that touch three or more clusters; a hover tweak routes through [[routing-table]] to one node and stops.
        
        ## Sources
        
        - HKTITAN — orderings distilled from repeated multi-cluster sessions; companions: [[routing-table]], [[disambiguation]].
        
      • using-design-file.md 5.9 KB
        ---
        title: using-design-file
        summary: How the agent discovers, reads, follows, updates, and verifies a project's .design contract — and why it outranks this skill's defaults.
        tags: [meta, design-file, tokens, contract, workflow]
        ---
        
        # Using a `.design` contract
        
        A `.design` file (schema `design.v1`, spec mirrored at [`spec/design-file-spec.md`](../../../../spec/design-file-spec.md)) is a machine-readable visual contract: tokens, components, copy voice, executable policy, and a committed aesthetic intent. When a project has one, it is the source of truth and **this skill drops to advisory**.
        
        ## Where this skill sits
        
        The format defines a precedence chain, and it is worth internalizing because it decides who wins every disagreement:
        
        1. Explicit user prompt (this task only)
        2. The nearest `.design`
        3. The `design` skill procedure
        4. **Generic taste skills — this one**
        5. Model defaults
        
        So: if `.design` sets `tokens.motion.duration.base: 400ms`, you use 400ms even though [[duration-table]] says most UI lands under 300ms. Raise the tension once, in prose, then comply. The skill supplies craft **where the contract is silent** — that gap is large, and it is where the whole motion and surface canon still applies.
        
        ## Detection
        
        Walk up from the file you're editing. Prefer a literal `.design`; otherwise exactly one `*.design` in that directory. Several candidates → ask which system. Resolve `extends` depth-first, child overriding parent. In a monorepo, a package's nearer contract beats the repo root.
        
        If UI work is requested and no contract exists, **offer to bootstrap — don't invent an untracked system.** Scan adjacent signals first: design notes in `AGENTS.md`, an existing `DESIGN.md` (see [[using-design-md]]), global CSS, Tailwind config, `components.json`. Extract the whole visual vocabulary — density, elevation language, motion character, copy tone — not just hex values.
        
        ## Reading
        
        Load `agent.instructions` first — it is required and self-contained, so the file works even where no skill is installed. Then `intent` → `constraints` → `policy` / `decisions` → `tokens` → `voice` → `components`. Pull `rationale.*` on demand for the task at hand.
        
        `intent` is the field with no `DESIGN.md` equivalent, and it is the one that resolves taste arguments: `direction` (the committed aesthetic), `signature` (where boldness is allowed to concentrate), `treatment` (`utilitarian` vs `editorial` register). Calibrate per surface — `patterns.<name>.treatment` overrides `intent.treatment`. A dashboard gets restrained product craft; only editorial surfaces run the distinctive-identity register. Everything outside `signature` stays quiet, which is the same instinct as [[delight-impact-curve]] with a budget written down.
        
        ## Following
        
        Bind every property a component lists — `backgroundColor`, `textColor`, `typography`, `rounded`, `padding`, `size`, `height`, `width`. Never write a raw hex, spacing, or radius when a token exists. Obey `when` / `when_not` and walk `decisions` first-match-wins. When a component isn't catalogued, `policy.if_missing` decides: `ask`, `nearest`, or `invent_with_note`.
        
        Apply `voice.*` to UI copy with the same force as tokens — `register`, `casing`, the `terminology` map, `action_naming` continuity, and the `errors` style rule. This is [[copy-voice]] with the vocabulary pinned.
        
        ## Updating and verifying
        
        Edit the YAML in place and bump `version`; git is the audit trail. Never add `proposed_changes` or an in-file changelog. Ask before touching any dot-path in `locked`.
        
        A drift check compares `tokens` against the real CSS/Tailwind theme and `components` against real imports, then reports per token group as **added / removed / modified** with a **regression** flag — closer to [[review-format]]'s discipline than to a lint pass. Report before fixing; update the contract only when asked.
        
        ## shadcn
        
        When `integrations.shadcn.enabled`, prefer installed shadcn primitives over parallel ones, and write `css_vars` into the declared CSS file. **Tokens outrank stale `css_vars` literals** — refresh the CSS after token edits rather than trusting what's already in `:root`. Emit the full variable set including charts and sidebar. See [`spec/design-file-spec.md`](../../../../spec/design-file-spec.md) for the Tailwind v4 mechanics.
        
        ## Sound, video, and companions
        
        Contract 1.3 adds `tokens.sound` (default toggle state, one material, levels, durations, sync tolerance), `assets.sounds`, `decisions.sound`, `targets[]` video entries, and `exports.frame_md`. When they exist they outrank [[sound-spec]] and [[sound-decision-framework]]; when the group is absent, the contract is saying *silent* and this graph must not add a sound. A `video` target flips the default — see [[launch-video-sound]] — and `exports.frame_md` is how a HyperFrames project inherits the brand's motion and material. The `design` skill also names the companions it routes to; [[skill-router]] is this graph's side of the same handshake.
        
        ## `.design` vs `DESIGN.md`
        
        Both can exist. `.design` is the machine-readable contract with intent and executable policy; `DESIGN.md` is prose-plus-tokens ([[using-design-md]]). When both are present, `.design` wins and `DESIGN.md` becomes rationale — offer to converge them rather than maintaining two sources.
        
        ## Gotcha
        
        Don't let the contract's existence stop the craft work. A `.design` that pins colors and radii says nothing about whether a spinner flashes under 800ms, whether the exit is 60% of the entrance, or whether a hover shifts 1px or 4px. Silence is not permission to default — it's where [[review-checklist]] still runs.
        
        ## Sources
        
        - AgentsORG — [github.com/AgentsORG/design](https://github.com/AgentsORG/design), `design.v1`. Mirrored at [`spec/design-file-spec.md`](../../../../spec/design-file-spec.md).
        - Starter contract encoding this skill's defaults: [`templates/design-engineering.design`](../../../../templates/design-engineering.design).
        - Related: [[using-design-md]], [[copy-voice]], [[delight-impact-curve]], [[review-format]].
        
      • using-design-md.md 5.2 KB
        ---
        title: using-design-md
        summary: How the agent reads, respects, and (rarely) updates a project's DESIGN.md file. Tokens are normative; prose is context.
        tags: [meta, design-md, tokens, workflow]
        ---
        
        # Using design.md
        
        When a user's project contains a `DESIGN.md` file (per [Google Labs Code's design.md spec](https://github.com/google-labs-code/design.md), mirrored at [`spec/design-md-spec.md`](../../../../spec/design-md-spec.md)), the agent has access to a machine-readable description of the design system. Use it.
        
        > **Check for a `.design` contract first.** If the project has one, it outranks DESIGN.md and this skill both — see [[using-design-file]]. When both exist, `.design` is the contract and DESIGN.md becomes rationale.
        
        ## Detection
        
        Before generating any UI, look for `DESIGN.md` in:
        
        1. The repo root: `DESIGN.md`.
        2. A `design/` subfolder: `design/DESIGN.md`.
        3. A `.design/` hidden folder: `.design/DESIGN.md`.
        
        If found, read it before writing any component, styling any element, or proposing any tokens.
        
        ## What to do with the frontmatter
        
        The YAML frontmatter is **normative**. Treat the tokens as the source of truth:
        
        - **`colors`** — when picking a color in generated UI, reference a token (`var(--color-primary)`, `{colors.primary}`, or `theme.colors.primary`) — *don't* hardcode a hex.
        - **`typography`** — match `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing` to a defined typography token. If the body uses `Public Sans 16/1.6`, don't generate `Inter 14/1.5`.
        - **`rounded`** — use the scale (`sm`, `md`, `lg`, `full`). Mixing `borderRadius: 6` when the scale defines `4/8/16` is a tell.
        - **`spacing`** — same. The scale is the contract.
        - **`components`** — if a `button-primary` is defined, use it. Don't invent your own primary button.
        
        ## What to do with the markdown body
        
        The body is **context**, not contract. Read it for:
        
        - **Overview** — what the system is *trying to feel like*. Use this to disambiguate ambiguous token choices.
        - **Do's and Don'ts** — explicit anti-patterns. Treat these as hard constraints.
        - **Components section** — usage notes that token definitions can't capture (e.g., "primary buttons are reserved for the single most important action per screen").
        
        ## Reading order
        
        For a typical UI generation task:
        
        1. Read `DESIGN.md` (whole file — it's small).
        2. Identify which tokens you'll need (colors, typography, spacing, the specific component variants).
        3. Cross-reference with this skill — e.g., if `colors` defines a saturated accent, check [[delight-impact-curve]] before applying it everywhere.
        4. Generate the UI using token references, not hardcoded values.
        
        ## When to propose updating DESIGN.md
        
        The agent **does not** autonomously rewrite a user's DESIGN.md. It's authored by humans (designers + design engineers), not generated.
        
        The agent *may* propose updates — always with human approval — in these cases:
        
        - **Missing token** — generating a component requires a token that doesn't exist. Propose adding it with rationale.
        - **Contrast failure** — the contrast linter (`contrast-ratio` rule) flags a token pair. Propose adjustment.
        - **Component variant gap** — `button-primary-hover` exists but `button-primary-active` doesn't, and you need it.
        
        Surface the proposal as a code review or PR comment, not a silent edit.
        
        ## Validating
        
        Use the `@google/design.md` CLI to validate any change:
        
        ```bash
        npx -y @google/design.md lint DESIGN.md
        ```
        
        Run before committing. The linter catches broken refs, contrast failures, missing sections, and section-order violations.
        
        For programmatic use in CI:
        
        ```ts
        import { lint } from '@google/design.md/linter';
        const { findings } = await lint(contents);
        if (findings.some(f => f.severity === 'error')) process.exit(1);
        ```
        
        ## Cross-references
        
        | design.md token category | This skill's relevant nodes |
        |---|---|
        | `colors` | [[color-monochromatic]], [[dark-mode]], [[contrast-and-color-scheme]] |
        | `typography` | [[typography-humanity]], [[line-length-tracking]] |
        | `rounded` | [[border-radius]] |
        | `spacing` | [[viewport-custom-design]] |
        | `components.button-*` | [[responsive-feedback]], [[hover-states-subtle]] |
        | `components.modal`, sheet, tray | [[tray-rules]], [[fly-not-teleport]] |
        | Overall feel / "Do's and Don'ts" | [[feeling-right]], [[unseen-details-compound]] |
        
        ## When to apply
        
        Any task where the user has a project with a `DESIGN.md` file. Read it once at task start; reference tokens throughout.
        
        ## Gotcha
        
        DESIGN.md is currently `version: alpha` — the spec is evolving. If the user's file uses fields you don't recognize, pass them through unchanged rather than flagging as errors. The format will expand.
        
        Also: not every project has a DESIGN.md, and not every team wants to author one. If asked "should we add a DESIGN.md?", weigh it against [[dependency-discipline]] — the file pays its way on multi-developer projects, less so on solo work.
        
        ## Sources
        
        - Google Labs Code — [github.com/google-labs-code/design.md](https://github.com/google-labs-code/design.md).
        - [`spec/design-md-spec.md`](../../../../spec/design-md-spec.md) in this repo (mirrored offline).
        - Related: [[agentation-workflow]] (the design-review feedback loop), [[review-format]] (UI critique format).
        
      • vibe-to-generator.md 4.8 KB
        ---
        title: vibe-to-generator
        summary: When the user has a mood, not a spec, don't prompt for finished outputs. Research the visual language until it has names, compress it into rules, then build a seeded generator — pure render(config), click to regenerate, every taste decision a knob, save writes the winning config.
        tags: [meta, creative, generative, workflow]
        ---
        
        # From vibe to generator
        
        Creative work has to produce something new, and prompting alone is bad at new: the model falls back on known patterns, the user rejects the result, asks for "a few more", and the loop burns time without converging. The escape has two halves. **Research** turns a vibe into rules the user can point at — most looks people reach for were shaped by a real process, and that process's constraints *are* the style. **A generator** then plays those rules: every variation one click, every taste decision a knob, the keeper reproducible forever.
        
        ## Route first
        
        ```text
        Can the user point at or precisely describe the exact output?
        ├── Yes → build it; if too intricate to land by prompting → [[build-a-tool]]
        └── No
            ├── Product UI with a few nameable directions? → [[prototype-picker]]
            ├── Can they state the idea as rules a stranger could follow? → skip research
            └── Only a vibe — mood words, an era, "I'm stuck"? → research. Never interrogate
                them into a spec first; the research exists because they can't answer yet.
        ```
        
        **The two-round rule.** About to produce a *second* round of prompted variants of one visual idea? Stop and propose this pipeline. Two rejected rounds means the user is searching a space, not requesting an item.
        
        ## Research the visual language
        
        1. Capture the vibe in the user's own words, verbatim. "Warm", "old-timey", "kind of hand-done" are data; rewriting them into your nearest cliché is how the result drifts.
        2. Research the tradition behind it, answering three questions: **what process made this look** (printing method, material, tool, era — its quirks are the process's constraints), **what did the process do to color, texture, and composition**, and **what are the recurring patterns called** (a named pattern can be requested precisely and bound to a knob).
        3. Spend tokens here without guilt; a cheap research pass produces generic rules and generic rules produce the mush the user came to escape.
        4. Compress into **5–10 rules stated as constraints** ("no more than N colors", "X darkens where it crosses Y", "edges are never clean") plus named patterns. Show them and let the user strike or keep lines **before any code exists** — rules are cheap to edit, renders are not.
        
        ## Hard rules for the generator
        
        1. **Output is a pure function of the config**, seed included. All randomness from a seeded PRNG (mulberry32) initialized from `config.seed`; never a bare `Math.random()` in the render path. The save button is a lie otherwise.
        2. **A new variation is one click**, not one prompt. Clicking the canvas assigns a fresh seed and re-renders.
        3. **Every taste decision is a knob** with tested min and max. Fixed values only for the rules that make the idea itself.
        4. **Save writes the full config** as JSON — clipboard at minimum, a file in the project when there's a dev server, path shown after saving. Production imports that file.
        5. **The tool is scaffolding; the config and render function are the deliverable.** Isolated route, nothing in production imports it; promote the JSON plus the render, or a static SVG/PNG export when the artifact needn't stay live.
        
        ## Sort, build, harvest
        
        Sort every rule into **rules** (hardcoded, what makes it this idea), **modes** (discrete sub-directions, tabs with their own knobs), and **knobs** (every continuous value). An empty knob bucket means the idea is determined — build the output. Build in one sitting: one canvas rendered large (thumbnails hide the texture that makes or breaks generative work), mode tabs, knob panel, click-to-regenerate, the seed visible, save plus a rail of saved configs re-applied on click. Hand off in two lines and stop. Harvest by reading the saved JSON, never by eyeballing the panel; keep the lab route for the next asset.
        
        ## When to apply
        
        "I'm stuck", "I need inspiration", "something like…", a mood instead of an image, endless variations of one idea, a rejected round of prompted variants. Pairs with [[svg-creation]] when the output is vector art and with [[visual-imperfection]] when the vibe is texture.
        
        ## Gotcha
        
        The generator's job is to play the rules, not to be tasteful on its own. If the rules were generic, every seed looks the same and the knobs move nothing worth moving; go back to research.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on creative tooling, distilled by HKTITAN.
        - Related: [[build-a-tool]], [[prototype-picker]], [[taste-is-trained]], [[svg-creation]].
        
    • motion
      • animation-decision-framework.md 2.5 KB
        ---
        title: animation-decision-framework
        summary: Should this animate at all? Frequency-based decision tree. Most animations should be deleted.
        tags: [motion, animation, decision]
        ---
        
        # Animation decision framework
        
        The most-skipped step in design engineering is asking "should this animate at all?" The default answer is *no*. Read this before reaching for keyframes.
        
        ## The decision tree
        
        1. **How often does the user encounter this?** If it's every keystroke, every scroll, or every primary action — strong default to **no animation**. See [[delight-impact-curve]].
        2. **What is the purpose?** Valid purposes:
           - **Continuity** — show that two states are the same component (see [[fly-not-teleport]]).
           - **Affordance** — show the user can interact (button press, hover).
           - **Feedback** — confirm the action happened.
           - **Hierarchy** — direct attention to one thing over another.
           - **Spatial** — clarify where something came from or went to.
        
           Invalid purposes:
           - **Decoration** — animation as garnish.
           - **Filling time** — animation to mask slow code (fix the code).
           - **"Because we can"** — Framer Motion is installed and we should use it.
        
        3. **What easing curve?** Custom cubic-bezier almost always beats the built-in CSS easings. See [[easing-curves]].
        4. **How long?** UI is sub-300ms. Buttons are 100–160ms. See [[duration-table]].
        5. **What property?** Only `transform` and `opacity` are cheap. See [[transform-opacity-only]].
        
        ## Quick checks before adding any animation
        
        - Would the UI feel broken without it? If no, delete it.
        - Does it survive [[prefers-reduced-motion]]? If not, fix it.
        - Will it look right at 25% playback speed? If not, the timing is off.
        - Would it still feel right on the 50th encounter? See [[delight-impact-curve]].
        
        ## Application heuristics
        
        - **Persistent elements:** if a component is present before and after a state change, animate the change — don't crossfade. See [[fly-not-teleport]].
        - **Symmetric vs asymmetric timing:** enter and exit often want different durations. Exits can be faster. See [[duration-table]].
        - **Stagger only when grouping helps comprehension.** Random stagger reads as noise.
        
        ## Gotcha
        
        The bug is almost never "this needs more animation." The bug is almost always "this animation is doing the wrong thing." Adding motion to fix bad UI compounds the problem. Fix the UI first.
        
        ## Sources
        
        - Emil Kowalski — animation decision tree (frequency-first).
        - Benji Taylor — "animation should serve architecture."
        
      • animations-dev-curriculum.md 2.9 KB
        ---
        title: animations-dev-curriculum
        summary: External pointer to Emil Kowalski's animations.dev course. Don't duplicate its content here.
        tags: [external, course, motion]
        ---
        
        # animations.dev — the canonical course
        
        The canonical resource on building animations the way this skill teaches them is Emil Kowalski's [animations.dev](https://animations.dev) course. This node exists to **point to it**, not duplicate it.
        
        ## What the course covers
        
        - Animation fundamentals (easing, timing, curves)
        - Spring animations and physics
        - Gesture and drag
        - View transitions
        - Choreography across components
        - Building production-quality animated UI in React (Motion / Framer Motion) and vanilla CSS
        
        If the user wants depth on motion, send them there.
        
        ## Companion projects
        
        - **[Sonner](https://sonner.emilkowal.ski/)** — Emil's toast library. Reference implementation for asymmetric enter/exit, gesture dismissal, and stack management. See its source for production motion patterns.
        - **[Vaul](https://vaul.emilkowal.ski/)** — Emil's drawer component for React. Reference implementation for [[gesture-momentum]].
        - **[lucide-animated.com](https://lucide-animated.com)** — dmytro / @pqoqubbw, built as practice from the course. 428+ animated React icons demonstrating path-transformation patterns. Reference for icon animation specifically.
        - **[transitions.dev](https://transitions.dev)** — Jakub Antalik's catalog of 12 production-ready transitions, framework-agnostic. See [[cross-blur-transitions]], [[compose-subtract-asymmetry]], [[distance-falloff-propagation]], [[multi-segment-shake]] for nodes drawn from it.
        
        ## When to point users here
        
        - The user wants to learn motion deeply, not just apply rules.
        - The user is building something animation-heavy from scratch.
        - The user is critiquing a complex motion design and needs vocabulary.
        - The user is hiring a design engineer and wants to know what to look for.
        
        ## Why we don't duplicate
        
        The Perplexity Agent Skills guide: *"Many engineers have plenty of experience writing readme.md files that list out every command someone needs to run. It's easy to fall back into that when you're writing a Skill because it feels like you're writing documentation, but if you do that, your Skill will be garbage."*
        
        A skill is not documentation. animations.dev is documentation, and a very good one. The skill points to it; it doesn't try to be it.
        
        ## Gotcha
        
        If you copy and paste from animations.dev into a skill node, the node becomes stale the moment Emil updates the course. Link out instead. The exception is **vocabulary** — easing curve names, duration tokens, decision frameworks — which is durable enough to encode here as long as it's credited.
        
        ## Sources
        
        - Emil Kowalski — [animations.dev](https://animations.dev), [emilkowal.ski](https://emilkowal.ski).
        - @pqoqubbw — [lucide-animated.com](https://lucide-animated.com).
        - Jakub Antalik — [transitions.dev](https://transitions.dev).
        
      • clip-path-tricks.md 3.9 KB
        ---
        title: clip-path-tricks
        summary: clip-path is animatable, GPU-accelerated, and unlocks 5+ patterns that would otherwise require canvas or SVG.
        tags: [motion, clip-path, css]
        ---
        
        # clip-path tricks
        
        `clip-path` is the most underrated animatable CSS property. It's GPU-accelerated, interpolatable when both states use the same shape function, and unlocks effects that would otherwise need canvas, SVG, or complex JS.
        
        ## The inset shape — primary tool
        
        ```css
        clip-path: inset(top right bottom left);
        clip-path: inset(0 0 0 0);      /* fully visible */
        clip-path: inset(0 100% 0 0);   /* clipped from the right */
        clip-path: inset(0 0 50% 0);    /* bottom half hidden */
        ```
        
        `inset()` is the workhorse. It clips from each edge. Both ends of a transition must use the same `inset()` for interpolation to work.
        
        ## Pattern 1 — tabs with perfect color transition
        
        The classic. A tab pill background that slides between tabs *and* the text inside flips color correctly during the transition (not before, not after).
        
        ```css
        .tab-pill {
          background: var(--accent);
          clip-path: inset(0 0 0 0 round 9999px);
          transition: clip-path 250ms var(--ease-out-quart);
        }
        /* Position the pill behind the active tab; clip everything except active region */
        ```
        
        The text uses `color: var(--text-primary)` on transparent regions and `color: var(--text-on-accent)` on the clipped region — `mix-blend-mode` or two layered text layers gives the perfect color flip.
        
        ## Pattern 2 — hold-to-delete
        
        Press and hold a button; the destructive action fills from one side over ~1.5s, releasing aborts.
        
        ```css
        .confirm-fill {
          position: absolute; inset: 0;
          background: var(--danger);
          clip-path: inset(0 100% 0 0);
          transition: clip-path 1500ms linear;
        }
        .confirm-button:active .confirm-fill {
          clip-path: inset(0 0 0 0);
        }
        ```
        
        If the user releases before completion, `clip-path` snaps back. The animation is interruptible because it's a CSS transition, not a JS-driven keyframe.
        
        ## Pattern 3 — image reveal on scroll
        
        ```css
        img {
          clip-path: inset(20% 0 20% 0);
          transition: clip-path 800ms var(--ease-out-quart);
        }
        img.in-view {
          clip-path: inset(0 0 0 0);
        }
        ```
        
        Triggered by `IntersectionObserver` adding `.in-view`. Reveals from horizontal letterbox to full image. Feels cinematic.
        
        ## Pattern 4 — comparison slider
        
        The "before/after" image slider. The "after" image is clipped to the right of the slider position.
        
        ```css
        .after-image {
          clip-path: inset(0 var(--slider-x) 0 0);
        }
        ```
        
        `--slider-x` is updated by JS on pointer move. Because `clip-path` updates are GPU-cheap, the slider is silky even on 60Hz constrained devices.
        
        ## Pattern 5 — directional content swap
        
        Two pieces of content stacked; one clips in from the right, the other clips out to the left.
        
        ```css
        .panel-in { clip-path: inset(0 100% 0 0); }
        .panel-in.active { clip-path: inset(0 0 0 0); }
        .panel-out.exiting { clip-path: inset(0 0 0 100%); }
        ```
        
        Gives a directional feel ([[fly-not-teleport]]) without `transform: translate` and without layout shift.
        
        ## Browser support
        
        `clip-path: inset()` is universal. `clip-path: polygon()`, `circle()`, `ellipse()`, `path()` are all supported in modern browsers. Both endpoints of a transition must use the *same* shape function — you can't transition from `inset()` to `circle()`.
        
        ## When to apply
        
        - Any "reveal" or "cover" effect.
        - Color-flipping text under a sliding pill.
        - Hold-to-confirm interactions.
        - Comparison sliders.
        - Scroll-driven reveals.
        
        ## Gotcha
        
        `clip-path` with `border-radius` is finicky — combine via `clip-path: inset(... round Xpx)` instead of using both properties. Mixing them produces strange double-clipping on Safari.
        
        Also: `clip-path` on a parent affects all children. Want only one child clipped? Apply it to that child directly.
        
        ## Sources
        
        - Emil Kowalski — clip-path section of [emilkowalski/skill](https://github.com/emilkowalski/skill).
        - MDN — `clip-path` reference.
        - Related: [[transform-opacity-only]] for the GPU-acceleration baseline.
        
      • compose-subtract-asymmetry.md 3 KB
        ---
        title: compose-subtract-asymmetry
        summary: Enter with 5 properties, exit with 3. Disappearance feels soft, not a reversed entrance.
        tags: [motion, transition, asymmetry]
        ---
        
        # Compose on enter, subtract on exit
        
        A signature Jakub Antalik pattern: when something enters with N animated properties, have it exit with fewer than N. The exit should feel like a soft fade-away, not a reversed entrance.
        
        ## The principle
        
        A perfectly-reversed exit (same properties, same curve, opposite direction) reads as mechanical. The element "rewinds." That's correct, but it feels off.
        
        The fix: drop properties on the exit. Keep the essentials (usually opacity + blur), drop the theatrical ones (rotate, translate-bounce, stroke-draw).
        
        ## Concrete example — success check
        
        Enter (5 properties, 350ms):
        - Opacity 0 → 1
        - Scale 0.8 → 1
        - Rotate -8° → 0
        - Y-bob translateY(4px) → 0 → translateY(-2px) → 0
        - Stroke-draw on the checkmark path
        
        Exit (3 properties, 180ms):
        - Opacity 1 → 0
        - Filter blur(0) → blur(2px)
        - Scale 1 → 0.96 (subtle, not theatrical)
        
        The exit doesn't undraw the stroke. It doesn't reverse-bob. It doesn't rotate back. It just fades softly.
        
        ## Why it works
        
        The brain notices the entrance (it's the "new" event). The exit is a "going away" event — making it visually quieter respects the user's attention. Reversing the entrance over-emphasizes the moment of leaving.
        
        ## Other examples
        
        - **Modal:** enter scales from 0.96 + opacity + slight Y-translate. Exit just opacity + minor scale. No Y-translate.
        - **Tooltip:** enter scales from 0.94 + opacity. Exit just opacity. No scale.
        - **Toast notification:** enter slides + opacity + slight bounce. Exit just slides + opacity. No bounce.
        - **Confetti:** enters with rotation + translate + opacity. Exits with only opacity fade.
        
        ## The asymmetric-duration rule
        
        This pattern pairs naturally with [[duration-table]]'s asymmetric timing — exits are usually 60–80% of enter duration. Combine both:
        
        ```css
        [data-state="open"] {
          animation: enterFull 300ms var(--ease-out-quart);
        }
        [data-state="closed"] {
          animation: exitMinimal 180ms var(--ease-out-quart);
        }
        
        @keyframes enterFull {
          from { opacity: 0; transform: scale(0.96) translateY(4px); filter: blur(2px); }
          to { opacity: 1; transform: scale(1) translateY(0); filter: blur(0); }
        }
        
        @keyframes exitMinimal {
          from { opacity: 1; transform: scale(1); filter: blur(0); }
          to { opacity: 0; transform: scale(0.96); filter: blur(2px); }
          /* no translate, no rotation, simpler */
        }
        ```
        
        ## When to apply
        
        Any element that animates on enter. Pause before writing the exit animation and ask: which properties belong here, and which are theater? Drop the theater.
        
        ## Gotcha
        
        This is *not* a reason to skip the exit animation entirely. An element that pops onto the screen and then `display: none` looks broken. The exit needs *some* animation — just less than the entrance.
        
        ## Sources
        
        - Jakub Antalik — transitions.dev, prototype P11 (success check).
        - Emil Kowalski — Sonner asymmetric enter/exit.
        
      • cross-blur-transitions.md 2.3 KB
        ---
        title: cross-blur-transitions
        summary: Pair opacity 0↔1 with `filter: blur(2px) ↔ 0` over the same timing. Masks imperfect crossfades.
        tags: [motion, transition, blur]
        ---
        
        # Cross-blur transitions
        
        Jakub Antalik's most reused technique on transitions.dev. Pair an opacity transition with a synchronized blur transition. The blur masks the moment of imperfect overlap between two states, so what would have looked like a janky crossfade reads as a smooth transformation.
        
        ## The technique
        
        ```css
        .element {
          opacity: 0;
          filter: blur(2px);
          transition:
            opacity 250ms var(--ease-out-quart),
            filter 250ms var(--ease-out-quart);
        }
        .element[data-state="visible"] {
          opacity: 1;
          filter: blur(0);
        }
        ```
        
        The blur is small — 2px on desktop, **4px on mobile** to compensate for phone backing-buffer downsampling.
        
        ```css
        @media (max-width: 640px) {
          .element {
            filter: blur(4px);
          }
        }
        ```
        
        ## When it works
        
        - Crossfading between two images or icons of similar size.
        - Page transitions in single-page apps (see [[fly-not-teleport]] for when motion is better).
        - Number transitions (a counter ticking up).
        - Tab content swaps.
        - Modal opens (paired with scale).
        
        ## When NOT to use
        
        - For text content. Blur on text reads as broken focus, not motion.
        - For high-frequency micro-interactions (hover). Blur is too expensive.
        - For elements larger than ~400px square. The GPU cost rises with surface area.
        
        ## Why this beats plain crossfade
        
        A plain crossfade — `opacity: 0 → 1` — produces a moment where both elements are at ~50% opacity. The eye sees two ghost images. Cross-blur replaces that ghost moment with a soft, out-of-focus blob that "resolves" into the new element. The brain accepts blur-resolve as a single object; it doesn't accept double-ghost as a single object.
        
        ## Gotcha
        
        `filter: blur()` is GPU-accelerated but expensive on large surfaces. If you're applying it to a full-page hero image (>1000px on a side), test on a low-end Android. If FPS drops, reduce blur to 1px or skip the technique for that element.
        
        Also: blur breaks `position: sticky` children in some browsers when applied to a parent. If you have sticky elements inside the blurred container, the technique may not work as-is.
        
        ## Sources
        
        - Jakub Antalik — transitions.dev, prototypes P1, P3, P5, P6, P9, P10.
        - Emil Kowalski — "use blur to mask imperfect transitions."
        
      • css-conventions.md 4.5 KB
        ---
        title: css-conventions
        summary: 1turn over 360deg, seconds over ms (or both with a reason), unitless line-heights, rem over px, native methods over libraries.
        tags: [motion, css, units, conventions]
        ---
        
        # CSS conventions
        
        A loose set of unit and dialect choices that, taken together, signal someone who actually writes CSS by hand. From Ben DC's [frontend-guidelines](https://github.com/bendc/frontend-guidelines).
        
        Most of these don't change behavior. They change *who reads your CSS and thinks "this person knows what they're doing."*
        
        ## Rotation: `1turn`, not `360deg`
        
        ```css
        /* Good */
        transform: rotate(1turn);
        transform: rotate(0.25turn);
        transform: rotate(-1turn);
        
        /* Works but reads as 2010 */
        transform: rotate(360deg);
        transform: rotate(90deg);
        ```
        
        A full rotation is `1turn`. A half is `0.5turn`. A quarter is `0.25turn`. Half the digits, none of the mental math.
        
        The exception: when you're rotating to match a specific design-spec degree value (e.g. "rotate this arrow 14°"), `14deg` is clearer than `0.0389turn`. Use `deg` for arbitrary angles, `turn` for fractions of a full rotation.
        
        ## Time: pick one and commit
        
        This node and `duration-table` disagree on convention — `duration-table` uses milliseconds (`200ms`). Ben DC prefers seconds (`0.2s`).
        
        The principle: pick one and commit across your codebase. Mixing `200ms` and `0.2s` in the same stylesheet is the bug.
        
        Two arguments:
        
        - **Milliseconds (this skill's default):** explicit, no decimal ambiguity, matches `setTimeout()` JS values, what design tokens usually export as.
        - **Seconds:** fewer characters, what motion software uses (After Effects, Lottie), what humans intuitively understand for durations > 1s.
        
        Stay with milliseconds in this repo for consistency with [[duration-table]]. If you fork to a project that prefers seconds, flip the token file and don't look back.
        
        ## Line-height: unitless
        
        ```css
        /* Good — scales correctly with font-size inheritance */
        line-height: 1.5;
        
        /* Bad — breaks when font-size changes */
        line-height: 24px;
        line-height: 1.5em;
        ```
        
        Unitless line-height multiplies the *current* font-size. Pixel or em values lock to the *defining* element and propagate awkwardly to children.
        
        ## Sizing: `rem` for relative, `px` for hairlines
        
        - **`rem`** for anything that should scale with the user's root font preference: typography, spacing, component padding.
        - **`px`** for hairlines, borders, and one-pixel adjustments where you actually mean 1 device pixel.
        - **`em`** when you want size to scale with the parent (rare, but right for icons inside buttons).
        
        ```css
        .body { font-size: 1rem; padding: 1rem 2rem; }
        .border { border: 1px solid var(--border); }       /* always 1 device pixel */
        .icon-in-button { width: 1.2em; height: 1.2em; }   /* scales with button text */
        ```
        
        ## `:nth-child` selectors: words, not formulas
        
        ```css
        /* Good — reads in English */
        li:nth-child(odd)
        li:nth-child(even)
        
        /* Avoid when a word exists */
        li:nth-child(2n+1)
        li:nth-child(2n)
        ```
        
        For other patterns (`3n`, `n+5`), the formula is fine — no English equivalent.
        
        ## Colors: hex, or rgba with intent
        
        ```css
        /* Good */
        color: #111;
        background: rgba(0, 0, 0, 0.04);
        
        /* Avoid */
        color: rgb(17, 17, 17);    /* same as #111, longer */
        color: hsl(0, 0%, 7%);     /* harder to read for fixed colors */
        ```
        
        Use `hex` for fixed colors. Use `rgba` when you need transparency (the alpha is the reason). Use `hsl` when you're *generating* colors (varying hue/saturation programmatically).
        
        ## Native over libraries
        
        Prefer `Array.from()`, `.includes()`, `.flat()`, `URLSearchParams`, `IntersectionObserver` over lodash / jQuery equivalents. The web platform shipped these years ago; your bundle doesn't need them.
        
        ## Avoid `!important`
        
        Almost always indicates a specificity battle that should be fixed with the cascade, not bypassed. The exception: utility-class systems (Tailwind's `!important` modifier) where it's an intentional opt-out.
        
        ## When to apply
        
        Writing or reviewing CSS. These are taste signals, not correctness rules — but accumulating them is the difference between "this CSS works" and "this CSS feels considered."
        
        ## Gotcha
        
        These conventions are *not* universal — some teams prefer the opposite (seconds over ms, em over rem, etc.). When joining a codebase, match its existing conventions first. Only when starting fresh do you get to pick.
        
        ## Sources
        
        - Ben DC — [github.com/bendc/frontend-guidelines](https://github.com/bendc/frontend-guidelines).
        - Related: [[duration-table]] (counter-point on ms vs s), [[transform-mastery]], [[transform-opacity-only]].
        
      • debugging-animations.md 3.7 KB
        ---
        title: debugging-animations
        summary: Slow-motion playback, frame-by-frame inspection, real-device testing. The three tools.
        tags: [motion, debugging, devtools]
        ---
        
        # Debugging animations
        
        An animation that "looks off" is hard to diagnose at 60fps. Three techniques expose what's actually happening.
        
        ## 1. Slow-motion playback
        
        The single most useful debugging tool. Slow the animation to 25% or 10% speed and watch each frame:
        
        ### In code (one-line)
        
        ```css
        /* Temporarily multiply all durations */
        * { transition-duration: 2000ms !important; animation-duration: 2000ms !important; }
        ```
        
        Or in Framer Motion / Motion:
        
        ```tsx
        <MotionConfig transition={{ duration: 2 }}>
          <App />
        </MotionConfig>
        ```
        
        ### In Chrome DevTools
        
        DevTools → ⋮ menu → More tools → Animations panel. There's a playback speed slider (10%, 25%, 50%, 100%). Doesn't require code changes.
        
        When animations look "twitchy" or "off," 9 times out of 10 the bug is visible at 25% speed and invisible at 100%. Examples:
        - A `scale` and `opacity` transition that don't share a curve, so they desync visibly when slow.
        - An element flashing to its end state for one frame before the animation starts.
        - A jitter caused by `transform-origin` mismatch.
        
        ## 2. Frame-by-frame inspection
        
        Chrome DevTools → Performance → Record → Run the animation → Stop. The flame chart shows every frame. Look for:
        
        - **Long frames (>16ms)** — the animation drops below 60fps. Causes are usually layout, paint, or non-GPU properties.
        - **Layout/paint events during the animation** — means you're animating a property that's not `transform` or `opacity` ([[transform-opacity-only]]).
        - **Style recalculation cost** — large when CSS custom properties on the root are being updated mid-animation. See gotcha below.
        
        The Animations panel also lets you scrub the animation timeline like a video editor, with keyframes marked.
        
        ## 3. Test on real devices
        
        What looks smooth on a MacBook Pro can be unusable on a 3-year-old Android. Real devices reveal:
        
        - GPU memory pressure (animations skip frames or stutter).
        - `will-change` over-application (browser pre-allocates layers, exhausts memory).
        - Network-heavy pages where main-thread JS blocks the animation start.
        
        Workflow:
        - iPhone: connect via USB → Safari → Develop menu → [device name] → page. Full DevTools attached.
        - Android: connect via USB → `chrome://inspect` → Inspect. Full Chrome DevTools attached.
        - Use the device's real power profile, not "throttled to 4x slowdown" simulation — they behave differently.
        
        A common shock: an animation that's flawless in desktop dev becomes choppy on a real mid-tier phone. The fix is usually reducing `filter: blur()` radius, removing `box-shadow` animations, or simplifying gradients.
        
        ## When to apply
        
        - Any animation that "feels off" but you can't articulate why.
        - Before shipping a new component to production.
        - When a user reports "the modal is choppy" but it looks fine locally.
        - Reviewing animation PRs — slow them to 25% in your browser before approving.
        
        ## Gotcha
        
        `transition-duration: !important` on `*` will affect things you didn't expect (focus rings, scrollbar animations on Safari). Apply it scoped to the component you're debugging, then remove. Don't ship the `!important` hack.
        
        Also: the Performance recording is heavy and changes the page's own behavior slightly (it's adding instrumentation). If you can't reproduce a bug with recording on, try recording for a shorter window (just before/during/after the animation).
        
        ## Sources
        
        - Emil Kowalski — Debugging Animations section of [emilkowalski/skill](https://github.com/emilkowalski/skill).
        - Chrome DevTools — Animation inspector docs.
        - Related: [[transform-opacity-only]], [[stagger-choreography]], [[gesture-momentum]].
        
      • distance-falloff-propagation.md 3.5 KB
        ---
        title: distance-falloff-propagation
        summary: Per-element lift via `lift * pow(falloff, distance)` for grouped hover. Avatar groups, list rows.
        tags: [motion, hover, group]
        ---
        
        # Distance-falloff hover propagation
        
        When you hover one element in a group (avatar stack, row of icons, list of cards), neighboring elements can subtly react. Done well, this signals "this group is connected" without being theatrical. Done poorly, it's distracting.
        
        Jakub Antalik's transitions.dev catalog uses a clean formula:
        
        ```
        shift = lift * pow(falloff, distance)
        scale = 1 + (max_scale - 1) * pow(falloff, distance)
        ```
        
        Where:
        - `lift` = maximum displacement of the hovered element (e.g. -8px on hover).
        - `falloff` = how quickly the effect decays per neighbor (e.g. 0.45).
        - `distance` = number of elements between the hovered and the current one.
        
        ## Concrete example — avatar stack
        
        ```ts
        const lift = -8;       // px
        const maxScale = 1.08;
        const falloff = 0.45;
        
        avatars.forEach((avatar, i) => {
          const distance = Math.abs(i - hoveredIndex);
          const shift = lift * Math.pow(falloff, distance);
          const scale = 1 + (maxScale - 1) * Math.pow(falloff, distance);
        
          avatar.style.transform = `translateY(${shift}px) scale(${scale})`;
        });
        ```
        
        Result:
        - Hovered avatar: full lift (-8px) and full scale (1.08).
        - Neighbor at distance 1: -3.6px, scale 1.036.
        - Neighbor at distance 2: -1.6px, scale 1.016.
        - Neighbor at distance 3: -0.7px, scale 1.007.
        
        By distance 4, the effect is below 1px and visually invisible — which is correct. The propagation should fade to nothing within 3–4 neighbors.
        
        ## The two-phase curve trick
        
        Jakub uses a different easing on **lift** vs **return**:
        
        ```css
        .avatar {
          transition: transform 200ms var(--ease-out-quart);
        }
        .avatar.returning {
          transition: transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1); /* bouncy */
        }
        ```
        
        - **Lift uses ease-out-quart** — feels responsive, settles cleanly.
        - **Return uses a spring-shaped bezier** — feels alive, has a tiny bounce on settle.
        
        The asymmetry is what gives the interaction its character. Same curve in both directions feels mechanical.
        
        ## When to apply
        
        - Avatar groups (user mentions, collaborator pills).
        - Toolbar rows where each item has personality (emoji reactions).
        - Image carousels with thumbnail rows.
        - List rows where the next/prev rows should subtly respond.
        
        ## When NOT to apply
        
        - High-density data tables. Propagation makes the whole row feel like it's moving when the user scans.
        - Touch-only UIs. There's no hover; the technique doesn't fire.
        - Lists of more than ~20 elements at once. The reflow cost rises.
        
        ## Performance
        
        This pattern modifies `transform` on N elements every mouseover. Use:
        - `transform` only (GPU-accelerated, see [[transform-opacity-only]]).
        - `requestAnimationFrame` to batch updates.
        - Don't `setState` per element in React — use refs and direct `style.transform` assignment, or use a single CSS variable trick:
        
        ```css
        .group { --hover-index: -1; }
        .item {
          --my-distance: abs(var(--my-index) - var(--hover-index));
          transform: translateY(calc(-8px * pow(0.45, var(--my-distance))));
        }
        ```
        
        (CSS `pow()` requires modern browsers. Fall back to JS for legacy support.)
        
        ## Gotcha
        
        The falloff coefficient matters more than the lift. A falloff of 0.8 propagates far and feels like the whole group is moving. A falloff of 0.2 dies too fast and looks like only the hovered item is reacting. **0.4–0.5 is the sweet spot.**
        
        ## Sources
        
        - Jakub Antalik — transitions.dev, prototype P8 (avatar group).
        
      • duration-table.md 2.1 KB
        ---
        title: duration-table
        summary: Specific durations by element type. UI < 300ms. Buttons 100–160ms.
        tags: [motion, duration]
        ---
        
        # Animation durations
        
        There is no universal "feels right" duration. The right duration depends on what's moving and how often.
        
        ## Reference table
        
        | Element | Duration | Notes |
        |---|---|---|
        | Button press feedback | 100–160ms | Anything slower feels laggy |
        | Hover state change | 120–200ms | Faster on inputs, slower on cards |
        | Tooltip appearance | 0–80ms (after delay) | The *delay* is 400–700ms; the *animation* is fast |
        | Popover / dropdown | 150–250ms | Should feel instant, not theatrical |
        | Modal | 200–300ms | Slightly slower because it's a hierarchy shift |
        | Tab content swap | 200–250ms | Crossfade or directional slide |
        | Page transition | 250–400ms | The longest "ok" UI duration |
        | Skeleton → content | 150ms | Resolve fast; the skeleton already paid the wait |
        | Confetti / celebration | 600–1200ms | Rare moment, can be theatrical |
        | Onboarding hero motion | 400–800ms | First-run gets a longer budget |
        
        ## Asymmetric enter/exit
        
        The exit is usually 60–80% of the enter. A panel that takes 250ms to slide in should take ~180ms to slide out. This is one of Emil's most-cited rules from Sonner.
        
        ```css
        [data-state="open"] { animation: slideIn 250ms var(--ease-out-quart); }
        [data-state="closed"] { animation: slideOut 180ms var(--ease-out-quart); }
        ```
        
        ## How to choose
        
        - **Smaller element, shorter duration.** A 12px icon doesn't need 300ms.
        - **More frequent, shorter duration.** See [[delight-impact-curve]].
        - **More important, slightly longer.** A modal earns more time than a tooltip.
        - **If unsure, halve it.** Most "feels slow" animations are too long. Most "feels jumpy" animations are too short.
        
        ## Gotcha
        
        `prefers-reduced-motion` does not mean "make everything 0ms." It means "no large translations or scale changes." A 120ms opacity fade is fine. See [[prefers-reduced-motion]].
        
        ## Sources
        
        - Emil Kowalski — Sonner asymmetric enter/exit, button feedback timing.
        - Vercel Design — internal duration tokens.
        
      • easing-curves.md 2.1 KB
        ---
        title: easing-curves
        summary: Custom cubic-bezier beats built-in CSS easings. Never use ease-in for UI.
        tags: [motion, easing, css]
        ---
        
        # Easing curves
        
        The default CSS easings (`ease`, `ease-in`, `ease-out`, `ease-in-out`) are blunt instruments. They were good defaults in 2010. They are a tell now.
        
        ## The rules
        
        - **Never `ease-in` for UI.** It looks like the UI is hesitating. Reserve `ease-in` for elements *leaving* the viewport — and even then, prefer custom.
        - **`ease-out` is the safer default** for entrances and primary motion. The action starts fast and settles.
        - **Custom `cubic-bezier()` beats both.** A small variation on the curve communicates personality without anyone noticing why.
        
        ## Curves worth memorizing
        
        ```css
        /* Linear's "out-quart" — sharp start, smooth tail. Default for hover/menus. */
        --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
        
        /* Snappy out — bouncy without overshoot. For taps, presses. */
        --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
        
        /* In-out — symmetric, for elements that travel between states. */
        --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
        
        /* The "Apple" feel — slight anticipation, soft landing. */
        --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
        ```
        
        ## How to choose
        
        - **Will the element overshoot?** Use `--ease-spring` (has values > 1 in the curve).
        - **Is it primarily an entrance?** Use `--ease-out-quart` or `--ease-out-expo`.
        - **Is it a two-way transition (open/close, expand/collapse)?** Use `--ease-in-out`.
        - **Is it a spring physics scenario (drag, dismiss, gesture)?** Don't use curves — use [[spring-animations]] instead.
        
        ## When to apply
        
        Every CSS transition you write should pick a curve deliberately. The browser's default `ease` is rarely the right choice. Add `--ease-*` variables to your token set and reference them everywhere.
        
        ## Gotcha
        
        A pretty cubic-bezier on the wrong duration looks worse than `ease-out` on the right duration. Curve and duration are coupled. Tune them together, never separately. See [[duration-table]].
        
        ## Sources
        
        - Emil Kowalski — easing curve guidance, custom > built-in.
        - guidelines.sh — "Spring physics over bezier" for gestures (see [[spring-animations]]).
        
      • fly-not-teleport.md 3.1 KB
        ---
        title: fly-not-teleport
        summary: When a component persists between two states, animate the change. Don't crossfade what should travel.
        tags: [motion, continuity, philosophy]
        ---
        
        # Fly, don't teleport
        
        Benji Taylor: *"Avoid static transitions… we fly instead of teleport."*
        
        When an element exists in state A and also in state B — but in a different position, size, or shape — the right answer is almost never to crossfade. Crossfading reads as two separate elements. The right answer is to *move* the element from its A position to its B position, so the user can see it's the same thing.
        
        ## The principle
        
        > If a component occupies a space and will persist in the next phase, it should remain consistent.
        
        Three corollaries:
        
        1. **Persistent elements travel, they don't restart.** No re-animation of an element that was already on screen.
        2. **Identity is communicated by motion.** A label that morphs from "Continue" to "Confirm" (sharing the letters "Con") tells the user it's still the same button. A button that crossfades is two buttons.
        3. **Direction carries meaning.** A modal opening from the bottom should close to the bottom. A tab content that slid in from the right should slide out to the left only if the user navigated *back* — otherwise it should match the entrance direction.
        
        ## In practice
        
        - **Tabs:** the underline (or background) of the active tab should *slide* between tabs, not disappear and reappear. React Aria's `LayoutGroup` pattern handles this.
        - **List reorder:** Use `view-transition` or `FLIP` to animate items to new positions instead of jumping.
        - **Modal hierarchy:** When stacking modals, the underlying modal recedes slightly (scale 0.96, blur 4px) — it doesn't disappear, because it will return.
        - **Tray heights:** Successive trays in a multi-step flow should *vary in height* to make each transition unmistakable. Same-height trays look broken.
        
        ## The FLIP technique (vanilla)
        
        ```ts
        // First — measure the starting position
        const first = el.getBoundingClientRect();
        
        // Last — measure the ending position (after layout change)
        const last = el.getBoundingClientRect();
        
        // Invert — calculate delta and apply transform
        const dx = first.left - last.left;
        const dy = first.top - last.top;
        el.style.transform = `translate(${dx}px, ${dy}px)`;
        
        // Play — transition to no-transform
        requestAnimationFrame(() => {
          el.style.transition = "transform 250ms var(--ease-out-quart)";
          el.style.transform = "";
        });
        ```
        
        ## When to apply
        
        Any time a component changes position, size, or hierarchy between two states *and* exists in both. Especially: tabs, navigation, list reorder, modal stack, drawer-to-fullscreen, carousel.
        
        ## When NOT to apply
        
        When the elements are conceptually different things. Two unrelated cards changing content should crossfade or just swap. Forcing motion between unrelated content is just decoration.
        
        ## Gotcha
        
        The View Transitions API makes this easy on the modern web but has spotty support and is hard to debug. For production, hand-rolled FLIP or Framer Motion's `layoutId` is more reliable.
        
        ## Sources
        
        - Benji Taylor — Family Values, "we fly instead of teleport."
        - Emil Kowalski — Sonner, popover hierarchy.
        
      • gesture-momentum.md 2.7 KB
        ---
        title: gesture-momentum
        summary: Velocity-based dismissal, damping at boundaries, pointer capture. The mobile-feel rules.
        tags: [motion, gesture, mobile]
        ---
        
        # Gesture momentum
        
        If a drag interaction "feels off," 90% of the time the cause is one of three things: no velocity tracking on release, no damping at boundaries, or no pointer capture. Fix those three, the gesture starts feeling native.
        
        ## Velocity-based dismissal
        
        When the user releases a draggable element, don't just snap to the closest position. Check the velocity:
        
        ```ts
        const onPointerUp = (e) => {
          const velocity = (currentY - prevY) / (now - prevTime); // px/ms
          if (Math.abs(velocity) > 0.11) {
            // user flung — dismiss in direction of velocity
            dismiss(velocity > 0 ? "down" : "up");
          } else {
            // user released slowly — snap to threshold
            snapToThreshold();
          }
        };
        ```
        
        `0.11 px/ms` is roughly the threshold below which a release reads as "the user stopped" and above which it reads as "the user threw it." Tune for your context.
        
        ## Damping at boundaries
        
        When the user drags past the edge, don't stop hard. Apply diminishing returns:
        
        ```ts
        // pull-to-refresh / overscroll
        const damped = (delta) => Math.sign(delta) * Math.pow(Math.abs(delta), 0.7);
        ```
        
        The `0.7` exponent makes each pixel of drag input produce diminishing pixels of motion. iOS uses something close to this. Without it, the boundary feels like hitting a wall.
        
        ## Pointer capture
        
        ```ts
        onPointerDown(e) {
          e.currentTarget.setPointerCapture(e.pointerId);
        }
        ```
        
        Without pointer capture, fast drags lose tracking when the pointer leaves the element. With it, the drag continues until release no matter where the pointer goes.
        
        ## Multi-touch protection
        
        A common bug: user starts dragging with one finger, lands a second finger on the element, and the gesture breaks. Guard:
        
        ```ts
        onPointerDown(e) {
          if (e.isPrimary === false) return; // ignore secondary pointers
          // ...
        }
        ```
        
        ## Friction instead of hard stops
        
        When a draggable element reaches its target, don't hard-stop at the target value. Let it overshoot slightly and settle. This is what springs are for — see [[spring-animations]].
        
        ## When to apply
        
        Any drag, swipe, dismiss, pull-to-refresh, slider, or carousel. If the interaction takes a real pointer input, all five rules above apply.
        
        ## Gotcha
        
        Touch events and pointer events are different APIs and produce slightly different velocity numbers. Always normalize to `px/ms` and tune your thresholds against the slower of the two. iOS Safari's touch event timing is notably noisier than mouse pointer timing on macOS.
        
        ## Sources
        
        - Emil Kowalski — gesture and drag interactions (Sonner, Vaul).
        - iOS Human Interface Guidelines — rubberband / damping behavior.
        
      • hover-default-imperative.md 4.4 KB
        ---
        title: hover-default-imperative
        summary: Icon-animation components animate on :hover by default. Expose an imperative trigger() for touch and programmatic use.
        tags: [motion, icon, hover, touch, api-design]
        ---
        
        # Hover by default, imperative for everything else
        
        The lucide-animated.com pattern (and the standard for any animated-icon component): the icon plays its animation on `:hover` by default. For touch devices and programmatic use, expose an imperative `trigger()` API alongside.
        
        ## Why hover-by-default
        
        Animated icons in a toolbar should *just work* without setup. The developer drops `<AnimatedHeart />` into a hover-aware UI (button, link, card) and the icon animates on hover. Zero config.
        
        ```tsx
        <button>
          <AnimatedHeart /> {/* animates when button is hovered */}
          Like
        </button>
        ```
        
        ```css
        .button:hover .icon-svg path {
          /* hover state — animation plays */
        }
        ```
        
        The CSS-only `:hover` trigger is performant (no JS state), accessible (focus styles can fire it too), and matches user intent.
        
        ## Why imperative API too
        
        `:hover` doesn't exist on touch. For:
        - A "save successful" celebration that fires on a discrete event.
        - A heart that fills when the user taps a button.
        - An onboarding tour that plays icons in sequence.
        - A multi-element choreography across the page.
        
        …you need to *trigger* the animation programmatically. Hover alone isn't enough.
        
        ```tsx
        import { useRef } from 'react';
        import { AnimatedHeart, type AnimatedIconHandle } from '@/icons';
        
        function LikeButton() {
          const ref = useRef<AnimatedIconHandle>(null);
          
          return (
            <button onClick={() => ref.current?.trigger()}>
              <AnimatedHeart ref={ref} />
              Like
            </button>
          );
        }
        ```
        
        The imperative handle exposes (at minimum) `trigger()` and optionally `reset()`, `pause()`, `play()`.
        
        ## The pattern (TypeScript)
        
        ```tsx
        import { forwardRef, useImperativeHandle, useRef } from 'react';
        
        export type AnimatedIconHandle = {
          trigger: () => void;
          reset: () => void;
        };
        
        export const AnimatedHeart = forwardRef<AnimatedIconHandle, AnimatedIconProps>(
          (props, ref) => {
            const internalRef = useRef<SVGSVGElement>(null);
            const [isAnimating, setIsAnimating] = useState(false);
            
            useImperativeHandle(ref, () => ({
              trigger: () => setIsAnimating(true),
              reset: () => setIsAnimating(false),
            }));
            
            return (
              <svg ref={internalRef} data-animated={isAnimating} {...props}>
                {/* paths that respond to data-animated and :hover via CSS */}
              </svg>
            );
          }
        );
        ```
        
        The CSS responds to both `:hover` and `[data-animated="true"]`:
        
        ```css
        .icon-svg path {
          transition: all 200ms var(--ease-out-quart);
        }
        button:hover .icon-svg path,
        .icon-svg[data-animated="true"] path {
          /* shared animated state */
        }
        ```
        
        ## Touch detection considerations
        
        `@media (hover: hover)` gates `:hover` to devices with a real pointer. On touch-only devices, the `:hover` rules don't apply — fall back to the imperative path.
        
        ```css
        @media (hover: hover) {
          button:hover .icon-svg path {
            /* hover animation only on pointer devices */
          }
        }
        ```
        
        For touch, animations fire either on tap (via `onClick` → `ref.trigger()`) or on a focus event (keyboard nav). This avoids the "hover state stuck on tap" problem touch users hit with naive `:hover`-only icons.
        
        ## When to apply
        
        - Any animated icon library you ship.
        - Icon components in a design system.
        - Reusable components where users might want both default and programmatic control.
        
        ## When NOT to apply
        
        - Static icons that don't animate. Don't add `trigger()` for icons that have nothing to trigger.
        - Pure-CSS animations that don't need state at all (e.g., a permanently-spinning loader). Don't ref-wrap them.
        - One-off animations in a specific feature. Inline the animation; don't build a reusable icon component for it.
        
        ## Gotcha
        
        Don't expose `trigger()` *without* `:hover` default. Forcing all users to manually wire up triggers defeats the zero-config promise. The hover-default + imperative-handle combination is what makes this pattern useful.
        
        Also: don't use `forwardRef` if your component is functional only. Use it only when you genuinely need the parent to call methods on the child.
        
        ## Sources
        
        - [lucide-animated.com](https://lucide-animated.com) — pqoqubbw/icons collection.
        - React docs — `useImperativeHandle`, `forwardRef`.
        - Related: [[icon-systems]], [[morphing-icons]], [[responsive-feedback]], [[animations-dev-curriculum]].
        
      • launch-video-seams.md 9.2 KB
        ---
        title: launch-video-seams
        summary: How launch videos are made to feel alive and cut so scenes read as one camera move — one easing law (exponential arrivals, tau 0.131 s), a damped spring at zeta 1/3 on three channels, blur derived from velocity, stepped decisions vs eased curves, shared-element morphs instead of cuts, a ledger per seam, no idle motion — with the numbers measured from HeyGen's launches and Skale's reel.
        tags: [motion, launch-video, hyperframes, seams, springs, storyboard, video]
        ---
        
        # Launch-video seams and the motion system
        
        A product launch video is a stack of scenes. What separates the ones that feel expensive from the ones that feel like a slideshow is not the scenes — it is the **seams** and the **physics**. HeyGen open-sourced twenty-one launch compositions and wrote the rules into their READMEs; Skale (Bay Area, launch videos for Google DeepMind, Replit, Polymarket, Bolt) posts a reel that measures the same way. Both are below.
        
        ## The motion system — four rules
        
        1. **One easing law.** Every arrival relaxes exponentially toward its target with a time constant around **0.131 s**; every exit accelerates away on the mirror curve. Nothing is linear, nothing eases *in* on arrival. Write the curves once as functions and pass them everywhere — GSAP's built-in `expo.out` is a different polynomial, so entry and exit stop being mirror-symmetric if you mix them:
        
           ```js
           const TAU = 0.131;
           const eo = (dur, tau = TAU) => (p) => (1 - Math.exp(-p * dur / tau)) / (1 - Math.exp(-dur / tau));
           const ei = (dur, tau = TAU) => (p) => 1 - eo(dur, tau)(1 - p);
           ```
        
        2. **A damped spring, zeta = 1/3, on three channels of the same body.** Position rings at **1.45 Hz**, shape (squash and stretch, volume-conserving) at **1.88 Hz**, rotation at **2.5 Hz** — the lighter the channel, the faster it rings. One `spring()` called three times is the entire "bouncy" quality. As a GSAP ease, windowed so it ends exactly at rest:
        
           ```js
           const spring = (dur, f = 1.45, z = 1 / 3) => (p) => {
             const u = p * dur, wd = 2 * Math.PI * f * Math.sqrt(1 - z * z);
             return 1 - Math.exp(-2 * Math.PI * z * f * u) * Math.cos(wd * u) * (1 - p);
           };
           ```
        
           Give two parts of one object `+A` and `−A` on the same spring (a wordmark's symbol and word, a card and its label) and it has life instead of sliding as one block.
        3. **Blur is derived, never authored.** Motion blur ramps with the same curve as the motion, so it peaks exactly when the element is fastest — into a seam with `power2.in`, out of it with the settle. CSS `blur()` scales with the element's transform: a zooming wrapper carries a raw 2 px that reads as ~16 px at ×8.
        4. **Stamp decisions, ease curves.** Anything that is a *curve* — a slide, a scale, a settle — is a tween. Anything that is a *decision* — a glyph flipbook, a typed run, an odometer digit, a cursor click, a highlight's path — is a per-frame `set`. Never tween a decision; it reads as a smear. The sound sits on the stamps ([[sound-from-motion]]).
        
        And one prohibition: **no idle motion.** Nothing floats, breathes, or pulses to fill time. If a beat feels empty, the fix is more information, not more wobble.
        
        ## Seams: shared-element morphs, not cuts
        
        No cuts and no crossfades between scenes — **one object always carries the eye across**. The outgoing element is still travelling when the scene changes and the incoming one enters already in flight on the same axis, in the same direction. A catalogue that recurs across HeyGen's launches:
        
        | seam | what carries the eye |
        |---|---|
        | **Velocity-matched cut** | exit up → enter from below, moving up; exit left → enter from the right, moving left |
        | **Zoom-through** | the scene flies past the camera (`scale 1 → 1.18`, blur up, fade); the next arrives from behind it (`0.92 → 1`, blur down) |
        | **Edge-on collapse → unfold** | a group collapses to a sliver (`scaleX → 0.02`) and the next surface unfolds from the same axis (`scaleX 0.03 → 1`) |
        | **Dock** | an object shrinks toward the rect where the next scene's hero grows from, and that hero grows from it |
        | **Conveyor** | rows surface near the bottom, ride up on a long deceleration, accelerate off the top — three cheap transforms that read as one crane move |
        | **Centre mask-open / recede** | a window materializes from a point (`scale 0.04 → 1`, ~0.6 s) or recedes (`1 → 0.82`, blur 14 px, 0.32 s) revealing the clip beneath |
        | **Explode-out** | `scale 1.55 + blur(18 px) + opacity 0`, `power2.in`, 0.34 s — a terminal or card leaving hard |
        
        Keep the agreement in a **ledger**, one row per seam, so it survives retiming:
        
        ```json
        { "fps": 30, "seams": [
          { "id": "router → review", "cut": 5.6, "technique": "edge-on collapse → unfold",
            "exit":  { "selector": "#cards", "axis": "x", "dir": 0 },
            "entry": { "selector": "#term",  "axis": "x", "dir": 0 } } ] }
        ```
        
        Exits at 60–70 % of the entrance duration, the hard kill (`tl.set`) exactly on the cut frame, and clip boundaries just *below* their frame time (`1.166`, never `1.1667`) or the outgoing scene owns one extra frame.
        
        ## What the numbers look like
        
        | | HeyGen templates | Skale reel (41.7 s) | OpenAI GPT-5 film |
        |---|---|---|---|
        | Hard cuts | 0 inside a sting; morphs only | 3 in the whole reel | 11, all in one montage |
        | Move length | tau 0.131 s → visibly settled in ~0.4 s | median 4 frames, p75 11 frames | stepped reveals, long holds |
        | Frames nearly still | — | 32 % | 64 % |
        | Motion after a cut | exponential-out | 0.67 → 0.39 by frame 3 → 0.1 by frame 12 | — |
        | Most-used eases (2,300 tweens) | `power2.out` 434 · `none` 338 · `power2.in` 275 · `power3.out` 237 · `expo.out` 89 | | |
        | Most-tweened properties | `scale` 1,840 · `filter`/blur 1,695 · `rotation` 214 · `stagger` 110 · `skew` 100 · `clipPath` 57 | | |
        
        Read the table as: arrivals are short and sharp, exits shorter; scale and blur do most of the work; rotation and skew are seasoning; almost nothing is a cut.
        
        ## The project shape
        
        ```text
        <launch>/
        ├── index.html        top-level composition: clips with data-start / data-duration on tracks
        ├── compositions/     one file per scene when a scene is worth isolating
        ├── assets/           media, fonts, sfx/  (Git LFS for binaries)
        ├── STORYBOARD.md     the act table + the audio cue map
        ├── HANDOFF.md        what changed this session, what is left, how to verify
        ├── ledger.json       the seams
        └── meta.json         id, name, createdAt
        ```
        
        ## HyperFrames specifics that bite
        
        - A sub-composition is visible for its **GSAP timeline's duration**, not its `data-duration`. Pad short timelines with `tl.to({}, { duration: slot }, 0)` or the tail flashes black.
        - An `<audio>` clip needs an `id`, or it renders silent. Preview audio is muted until the first click on the studio Play button.
        - `npx hyperframes check` runs lint, runtime, layout, motion, and contrast. Every seam exit needs a `tl.set` hard kill with the **same selector string** on the cut frame; a collapsed-but-visible element still counts for the layout sampler, so hide it until its scene.
        - Initial hidden states go in CSS, not in a `tl.set` at 0 — a zero-duration set at 0 does not render on frame 0.
        
        ## Templates as slots
        
        A finished sting is a set of **slots**, not screens: a lockup, an entry surface, a number that matters, a nav, an action taken, an endcard. Re-branding means capturing the real brand (`npx hyperframes capture <url> --json`), writing the product's own surfaces into the slots, and **keeping every frame number, ease, and cut**. If you rewrote the animation, you failed the task even if yours looks fine.
        
        ## When to apply
        
        Any multi-scene composition: launch films, feature sizzles, README demos, onboarding heroes. Not product UI transitions, where [[fly-not-teleport]] and [[cross-blur-transitions]] own the seam, and where [[spring-animations]] carries the spring rules at UI scale.
        
        ## Gotcha
        
        "Lively" is not "more motion". The Skale reel is still a third of the time and the OpenAI film two thirds; what reads as alive is that every move is *fast, springy, blurred with its own velocity, and connected to the next scene by a shared object*. Adding drift, pulse, or float to a beat that has nothing to say makes it read as generated.
        
        ## Sources
        
        - HeyGen, *hyperframes-launches* (github.com/heygen-com/hyperframes-launches, Apache-2.0 for composition source): `heygen-apple-motion/02-bouncy-ui/index.html` ("THE MOTION SYSTEM — four rules": tau 0.131, zeta 1/3 at 1.45 / 1.88 / 2.5 Hz, squash and stretch, derived blur, seek-safety); `01-ui-sting/README.md` (one ease family, matched vectors, stamp choreography, no idle motion); `03-message-sting/README.md` (shared-element morphs, velocity-matched blur, the conveyor, the traps); `sfx-music-launch/STORYBOARD.md` (act table, seam grammar, audio cue map); `hyperframes-launch/HANDOFF.md` (timeline padding, audio ids). Ease and property counts: HKTITAN, over every `.html` in the repo, 2026-09-05.
        - Skale (skale.solutions; Mark Vassilevskiy, @MarkKnd) — studio reel posted 2025-08-29, cut/stillness/move-length analysis by HKTITAN, 2026-09-05.
        - OpenAI, *Introducing GPT-5* — stillness and cut counts; see [[launch-video-sound]].
        - Related: [[stagger-choreography]], [[spring-animations]], [[launch-video-sound]], [[sound-from-motion]].
        
      • lerp-breathing.md 3.9 KB
        ---
        title: lerp-breathing
        summary: Update multiple visual elements with the same lerp rate so they breathe as one organism, not as desynced parts.
        tags: [motion, lerp, real-time, chart, benji]
        ---
        
        # Lerp breathing — one update, many elements
        
        Benji Taylor's Liveline technique. When you have a real-time visualization (chart, gauge, score) with multiple visual elements that depend on the same underlying data, lerp them all toward target with the *same* rate so they read as one breathing organism instead of N independently-jittering parts.
        
        ## The formula
        
        ```ts
        const lerpSpeed = 0.08; // 8% toward target per frame at 60fps
        
        function tick() {
          // Y-axis range, badge value, grid labels — all lerp at the same rate
          yMin.current += (yMin.target - yMin.current) * lerpSpeed;
          yMax.current += (yMax.target - yMax.current) * lerpSpeed;
          badgeValue.current += (badgeValue.target - badgeValue.current) * lerpSpeed;
          
          redraw();
          requestAnimationFrame(tick);
        }
        ```
        
        8% per frame translates to a settle time of ~12 frames (200ms). It's tuned to feel like breathing — fast enough to feel responsive, slow enough to feel alive.
        
        ## The breathing rule
        
        > Apply the same lerp mechanism uniformly across every visual element that shares the data. If three things are derived from the same number, they update together at the same rate.
        
        This is what makes Benji's Liveline chart feel cohesive: when new data arrives, the line, the badge showing current value, the Y-axis labels, and the highlighted point all approach their new positions in lockstep. The whole chart "inhales" toward the new state.
        
        ## The range-snap exception
        
        When new data exceeds the visible range, **snap the range outward instantly** (no lerp), then lerp the data into the now-correct range:
        
        ```ts
        // If new value exceeds current range, expand range immediately
        if (newValue > yMax.target) yMax.current = yMax.target = newValue * 1.05;
        if (newValue < yMin.target) yMin.current = yMin.target = newValue * 0.95;
        
        // Then lerp the badge and line toward new value normally
        badgeValue.target = newValue;
        ```
        
        This prevents the line from being momentarily clipped during the lerp. The range is allowed to "snap" because the user perceives the *line* as the primary content; the range is scaffolding.
        
        ## Direct DOM updates for 60fps
        
        React re-renders are not free. For value overlays that update at 60fps, bypass React and update DOM directly via refs:
        
        ```ts
        useEffect(() => {
          const el = badgeRef.current;
          let raf: number;
          
          function tick() {
            current += (target - current) * 0.08;
            el.textContent = current.toFixed(0); // direct DOM, no setState
            raf = requestAnimationFrame(tick);
          }
          tick();
          
          return () => cancelAnimationFrame(raf);
        }, []);
        ```
        
        This pattern is standard for finance charts, dashboards, multiplayer cursors — anywhere React's render cycle would stutter at 60fps.
        
        ## When to apply
        
        - Real-time charts and dashboards.
        - Multiplayer presence (live cursors, collaborator avatars).
        - Animated counters and badges.
        - Anywhere multiple visual elements derive from the same changing value.
        
        ## When NOT to apply
        
        - Discrete UI transitions (button press, modal open). Use curves or springs ([[easing-curves]], [[spring-animations]]).
        - One-shot animations triggered by a discrete event. Lerp is for continuous tracking.
        
        ## Gotcha
        
        The lerp rate (8% here) must be tuned to your refresh rate. If you're updating at 30fps or running on a low-end device, 8% feels slower; bump to 12–15%. If you're tracking sub-millisecond price data, 8% may feel sluggish — try 15–20%.
        
        Also: lerping multiple elements requires synchronizing them on the same `requestAnimationFrame` loop. Two separate `setInterval`s won't stay in sync; they drift. One RAF loop, all updates inside.
        
        ## Sources
        
        - Benji Taylor — *Liveline*, [benji.org/liveline](https://benji.org/liveline).
        - Related: [[spring-animations]], [[stagger-choreography]], [[fly-not-teleport]].
        
      • MOC-motion.md 5.1 KB
        ---
        title: MOC-motion
        summary: When to animate, how to animate, and the discipline of restraint.
        tags: [moc, motion, animation]
        ---
        
        # MOC — Motion
        
        The largest cluster. Read [[animation-decision-framework]] first — it tells you whether to even animate. The rest of the nodes are tactical.
        
        ## Decision-first nodes (read before deciding)
        
        - [[animation-decision-framework]] — Should this animate at all? Frequency-based decision tree.
        - [[fly-not-teleport]] — When components persist between states, motion clarifies the spatial path. From Benji.
        
        ## Tactical nodes (read while implementing)
        
        - [[easing-curves]] — Custom cubic-bezier > built-in CSS easings. Never `ease-in` for UI.
        - [[duration-table]] — Specific durations by element type. UI < 300ms. Buttons 100–160ms.
        - [[spring-animations]] — When springs beat curves. Apple's spring configuration. Interruptibility.
        - [[transform-opacity-only]] — The only two properties that are cheap to animate. The hardware acceleration rule.
        - [[performance-discipline]] — The rest of the discipline: named transition properties, `will-change` as a targeted fix, virtualization past ~50 rows, frames out of React state, reserved space, preload, off-screen pause, muted theme swaps.
        - [[transform-mastery]] — translateY percentages, scale() scales children, 3D transforms, transform-origin fundamentals.
        - [[clip-path-tricks]] — The most underrated animatable property. 5 patterns: tabs, hold-to-delete, image reveals, comparison sliders, directional swap.
        - [[never-scale-from-zero]] — Why `scale(0)` looks like an inflating balloon. Use `scale(0.95)` + opacity, or `@starting-style` for modern CSS.
        - [[gesture-momentum]] — Velocity-based dismissal. Damping at boundaries. Pointer capture.
        - [[stagger-choreography]] — Cascading reveals (30–80ms delays). When to stagger and when not.
        - [[responsive-feedback]] — Button press states, popover origin awareness, tooltip skip-delay. The micro-feedback nodes consolidated.
        
        ## Library-design nodes
        
        - [[sonner-principles]] — Ten principles from Sonner: DX is the product, defaults > options, naming, invisible edge cases, transitions over keyframes, docs as deliverable, cohesion, opacity+height trial, sleep-on-it review, asymmetric timing.
        
        ## Workflow nodes
        
        - [[debugging-animations]] — Slow-motion playback, frame-by-frame inspection, real-device testing. The three tools.
        - [[css-conventions]] — `1turn` over `360deg`, unitless line-height, hex over rgb, native over libraries. Ben DC's CSS dialect choices.
        
        ## Benji's catalog (benji.org)
        
        Distinctive techniques from Benji Taylor's body of work — Family, Honk, Liveline, Agentation.
        
        - [[lerp-breathing]] — Lerp at 8% per frame applied uniformly across multiple visual elements so the UI "breathes" as one organism instead of fragments updating independently.
        - [[morphing-icons]] — 3-line SVG constraint system. Same-shape icons rotate; different-shape icons interpolate coordinates. Unused lines collapse to invisible center points.
        - [[shared-letter-morph]] — Continue→Confirm text-state transitions where the shared letters ("Con") stay anchored as the rest morphs.
        - [[tray-rules]] — Six rules for tray / sheet / bottom-drawer UI: user-initiated, height variation, single focus, title + dismiss, context preservation, transient actions only.
        
        ## Touch + imperative API
        
        - [[hover-default-imperative]] — From lucide-animated.com. Hover triggers the animation by default; expose a `ref` / `trigger()` API for touch contexts.
        
        ## Launch video
        
        - [[launch-video-seams]] — The launch motion system and its seams: one easing law (tau 0.131 s), a damped spring at zeta 1/3 on position / shape / rotation, blur derived from velocity, stepped decisions, shared-element morphs instead of cuts, a ledger per seam, no idle motion — measured from HeyGen's launches and Skale's reel.
        
        ## Transition techniques (transitions.dev)
        
        Catalog of canonical transitions for common UI archetypes. Implementation layer under the principle layer above.
        
        - [[cross-blur-transitions]] — Pair opacity 0↔1 with `filter: blur(2px) ↔ 0` to mask imperfect crossfades.
        - [[compose-subtract-asymmetry]] — Enter with 5 properties, exit with 3. Disappearance feels soft.
        - [[distance-falloff-propagation]] — Per-element lift via `lift * pow(falloff, distance)` for grouped hover.
        - [[multi-segment-shake]] — Form-error shake at 0%, 28.57%, 57.14%, 78.57%, 100% over 280ms.
        
        ## Required-knowledge nodes (always relevant)
        
        - [[prefers-reduced-motion]] — The one accessibility rule that everyone skips and that breaks for real users.
        
        ## Cross-cluster
        
        - See [[ai-default-tells]] in [[MOC-anti-patterns]] for animation tells (crossfading icons, gradient flourishes).
        - See [[animations-dev-curriculum]] for the external course pointer; don't duplicate course material here.
        - When a motion has a sound, [[sound-motion-sync]] in [[MOC-sound]] owns the timing — the transient lands on the contact frame, never before it.
        - When the thing that moves is vector, [[svg-animation]] in [[MOC-svg]] owns the engine choice and the SVG-specific mechanics (transform-origin, stroke drawing, morphing).
        
      • morphing-icons.md 4.2 KB
        ---
        title: morphing-icons
        summary: Constraint-driven icon system — every icon = N SVG lines, unused lines collapse to invisible center points, same-shape icons rotate.
        tags: [motion, icons, svg, benji]
        ---
        
        # Morphing icons — the constraint-driven approach
        
        Benji Taylor's icon system for the Claude app: every icon in the set is constructed from exactly **three SVG lines**. Icons with fewer visible elements collapse their unused lines to invisible center points. Icons sharing geometry differ only by rotation. The result: every icon can morph smoothly into any other icon in the set, because they all share the same data shape.
        
        ## The 3-line constraint
        
        ```ts
        type IconLine = { x1: number; y1: number; x2: number; y2: number; opacity?: number };
        type Icon = [IconLine, IconLine, IconLine]; // always 3 lines
        
        const arrowUp: Icon = [
          { x1: 7, y1: 12, x2: 7, y2: 2 },   // vertical shaft
          { x1: 2, y1: 7,  x2: 7, y2: 2 },   // left wing
          { x1: 12, y1: 7, x2: 7, y2: 2 },   // right wing
        ];
        
        const dotIcon: Icon = [
          { x1: 7, y1: 7, x2: 7, y2: 7, opacity: 0 }, // collapsed
          { x1: 7, y1: 7, x2: 7, y2: 7, opacity: 0 }, // collapsed
          { x1: 7, y1: 7, x2: 7, y2: 7, opacity: 1 }, // visible single point
        ];
        ```
        
        A collapsed line is `{ x1: 7, y1: 7, x2: 7, y2: 7 }` with `opacity: 0`. Visually it's a point at center; arithmetically it's still a "line" the morph can tween between.
        
        ## Two morph strategies
        
        ### 1. Coordinate interpolation (different shapes)
        
        When morphing between two icons with *different* geometries, let Motion (or your animation library) interpolate each coordinate independently:
        
        ```tsx
        <motion.line
          x1={current[0].x1} y1={current[0].y1}
          x2={current[0].x2} y2={current[0].y2}
          animate={{ x1: target[0].x1, y1: target[0].y1, x2: target[0].x2, y2: target[0].y2 }}
        />
        ```
        
        Motion handles the tweening. The animation is smooth because the *count* of lines is constant (3 → 3); only the coordinates change.
        
        ### 2. Rotation morph (shared geometry)
        
        When two icons share the same line *shape* (e.g., all four directional arrows), rotate the whole icon instead of tweening coordinates:
        
        ```tsx
        <motion.svg
          animate={{ rotate: rotationFor(currentDirection) }}
          transition={{ type: 'spring', stiffness: 380, damping: 30 }}
        >
          {/* same SVG content for all four arrow directions */}
        </motion.svg>
        ```
        
        Rotation is cheaper, faster, and reads as more obviously "the same object turning" instead of "two different objects swapping."
        
        Use rotation when the geometric difference is purely angular (arrows by 90°, plus → cross by 45°). Use coordinate interpolation when the geometry actually changes (arrow → checkmark).
        
        ## When to apply
        
        - Toggle states: play/pause, expand/collapse, mute/unmute, follow/unfollow.
        - Directional icons: chevron up/down/left/right.
        - Status indicators that shift meaning: dot → ring → check.
        - Toolbar icons that change mode.
        
        ## When NOT to apply
        
        - Marketing/decorative icons where personality and detail matter more than morphability. Use a real icon pack like Phosphor or Hugeicons ([[icon-systems]]).
        - Icons that need filled shapes (the constraint here is stroke-only).
        - Icon sets larger than ~20 icons. The 3-line constraint gets restrictive past that.
        
        ## The metaphysical point
        
        Constraints are the engine. By forcing every icon into a 3-line schema, every icon becomes *morph-compatible* with every other one. The constraint isn't a limitation; it's what enables the system to work at all.
        
        You couldn't ship this with a heterogeneous icon set. The whole approach depends on uniformity at the data layer.
        
        ## Gotcha
        
        3 lines is right for the Claude app icon vocabulary. Your product may need 4 (more complex icons) or 2 (simpler set). Pick the smallest N that covers your icon space. Larger N means more "collapsed" lines per icon, which is fine, but eventually you've defeated the purpose — pick judiciously.
        
        Also: opacity-collapsed lines still render in the SVG. If you have hundreds of icons on a page, the DOM weight adds up. Use this pattern for ~5–20 icons in active use, not for an icon library.
        
        ## Sources
        
        - Benji Taylor — *Morphing Icons with Claude*, [benji.org/morphing-icons-with-claude](https://benji.org/morphing-icons-with-claude).
        - Related: [[icon-systems]], [[transform-mastery]], [[fly-not-teleport]], [[animations-dev-curriculum]].
        
      • multi-segment-shake.md 3.1 KB
        ---
        title: multi-segment-shake
        summary: Form-error shake at 0%, 28.57%, 57.14%, 78.57%, 100% over 280ms with 4px overshoot.
        tags: [motion, error, form]
        ---
        
        # Multi-segment shake
        
        When a form field fails validation, the shake animation is one of the highest-leverage micro-interactions. Done right, it's unmistakable. Done wrong, it's a nervous twitch.
        
        ## The Jakub Antalik formula
        
        Total duration: **280ms** total. Five keyframe stops (not four, not six — five). Translation peaks at **4px** overshoot.
        
        ```css
        @keyframes shake {
          0%    { transform: translateX(0); }
          28.57%{ transform: translateX(-4px); }
          57.14%{ transform: translateX(4px); }
          78.57%{ transform: translateX(-2px); }
          100%  { transform: translateX(0); }
        }
        
        .input--error {
          animation: shake 280ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
        }
        ```
        
        Why these specific percentages?
        
        - **0%** — start position.
        - **28.57%** — first peak, full overshoot in one direction.
        - **57.14%** — second peak, full overshoot in opposite direction (the "bounce").
        - **78.57%** — third peak, half-amplitude in opposite direction (damping).
        - **100%** — settle.
        
        The decreasing amplitude (4px → 4px → 2px → 0) is what makes it feel physical. A uniform-amplitude shake feels mechanical.
        
        ## Hold the error state
        
        After the shake completes, the field should remain visually marked as errored:
        
        ```css
        .input--error {
          border-color: var(--danger);
          background-color: var(--danger-bg);
          animation: shake 280ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
        }
        ```
        
        The shake itself is just the attention-getter. The persistent error styling is what tells the user what went wrong.
        
        Pair with an error message that appears just below the field:
        
        ```css
        .error-message {
          opacity: 0;
          transform: translateY(-4px);
          transition: all 200ms 100ms; /* 100ms delay so it appears as shake settles */
        }
        .field--errored .error-message {
          opacity: 1;
          transform: translateY(0);
        }
        ```
        
        ## Curve choice
        
        The cubic-bezier `(0.36, 0.07, 0.19, 0.97)` is a "snappy" curve — it accelerates fast and decelerates fast. A standard `ease-in-out` makes the shake look slow and tentative. The snappy curve is essential.
        
        ## When to apply
        
        - Form validation errors.
        - "This field is required" moments after submit.
        - Password-mismatch animations.
        - Wrong PIN entry on auth flows.
        
        ## When NOT to apply
        
        - Soft validation that doesn't block submission ("looks like an unusual format"). Use border color only, no shake.
        - High-frequency revalidation while typing. Shake should fire on a discrete event (submit, blur), not every keystroke.
        - More than once in quick succession. Debounce so users don't see two shakes overlapping.
        
        ## Gotcha
        
        Apply the shake to the field's *wrapper*, not the `<input>` itself. Shaking the input causes the text cursor to jump, which is disorienting and can break IME composition for non-Latin scripts.
        
        ```html
        <div class="field-wrapper" :class="{ 'field--errored': hasError }">
          <input ... />
          <span class="error-message">...</span>
        </div>
        ```
        
        ## Sources
        
        - Jakub Antalik — transitions.dev, prototype P9 (form error).
        - Material Design — motion guidelines, multi-stop keyframes.
        
      • never-scale-from-zero.md 3.4 KB
        ---
        title: never-scale-from-zero
        summary: Never animate from scale(0). Use scale(0.95) + opacity, or @starting-style for modern CSS entry.
        tags: [motion, gotcha, css, starting-style]
        ---
        
        # Never animate from scale(0)
        
        The single most common amateur mistake in entry animations is starting at `scale(0)` and animating to `scale(1)`. It looks like a balloon being inflated. UI is not a balloon.
        
        ## Why scale(0) is wrong
        
        - The element is *invisible* during the first ~30ms of the transition, then suddenly becomes visible at scale(0.05) or so.
        - The visual mass appears from a single point, which reads as theatrical and toy-like.
        - Tiny scale values render to subpixel positions, causing blur and shimmer on the first frames.
        
        ## The right pattern: 0.95 + opacity
        
        ```css
        .modal {
          opacity: 0;
          transform: scale(0.95);
          transition:
            opacity 200ms var(--ease-out-quart),
            transform 200ms var(--ease-out-quart);
        }
        .modal[data-state="open"] {
          opacity: 1;
          transform: scale(1);
        }
        ```
        
        - Start at `scale(0.95)`, not `scale(0)`. The 5% size difference is enough to read as motion.
        - Pair with `opacity: 0 → 1`. The opacity does most of the visibility work.
        - Both transitions on the same duration and curve — they should feel like a single move.
        
        For more theatrical entries (rare moments, see [[delight-impact-curve]]), `scale(0.85)` or even `scale(0.5)` can work if paired with a spring curve. But `scale(0)` is almost never right.
        
        ## @starting-style — the modern CSS way
        
        Chrome 117+ and Safari 17.5+ support `@starting-style`, which lets you define the "before" state for an element that's just been added to the DOM:
        
        ```css
        .modal {
          opacity: 1;
          transform: scale(1);
          transition:
            opacity 200ms,
            transform 200ms,
            display 200ms;
        }
        @starting-style {
          .modal {
            opacity: 0;
            transform: scale(0.95);
          }
        }
        ```
        
        No `data-state` toggle, no JavaScript. The element animates in the moment it's added to the DOM (or becomes `display: block` from `display: none`).
        
        For the *exit* in the same model, use `transition-behavior: allow-discrete` and animate the `display` property — modern browsers support this.
        
        ## Fallback for browsers without @starting-style
        
        Use a `data-mounted` attribute set in JS one frame after mount:
        
        ```tsx
        const [mounted, setMounted] = useState(false);
        useEffect(() => {
          requestAnimationFrame(() => setMounted(true));
        }, []);
        
        return <div data-mounted={mounted} className="modal">…</div>;
        ```
        
        ```css
        .modal {
          opacity: 0;
          transform: scale(0.95);
          transition: all 200ms;
        }
        .modal[data-mounted="true"] {
          opacity: 1;
          transform: scale(1);
        }
        ```
        
        This is the React pattern most production apps use today.
        
        ## When to apply
        
        Every entrance animation. Modals, popovers, tooltips, toasts, dropdowns, drawers — anywhere an element appears on screen.
        
        ## Gotcha
        
        `@starting-style` only fires on the *first* render of an element. For elements that mount and unmount repeatedly (e.g. dropdowns opened multiple times), the pattern still works if you `display: none` between opens and rely on the transition-behavior trick. But on rapid open/close, you may see flicker — at that point switch to the `data-mounted` fallback or use a library like Radix that handles this for you.
        
        ## Sources
        
        - Emil Kowalski — Component Building Principles, [emilkowalski/skill](https://github.com/emilkowalski/skill).
        - web.dev — `@starting-style` and `transition-behavior` references.
        - Related: [[responsive-feedback]], [[transform-mastery]], [[easing-curves]].
        
      • performance-discipline.md 4.3 KB
        ---
        title: performance-discipline
        summary: Fast UI is work you don't do. Name transition properties (bare Tailwind `transition` is `all`), composite-only animation, will-change as a targeted fix, virtualize past ~50 rows, keep frames out of React state, reserve space for everything async, preload critical and lazy the rest, pause off-screen, pre-render content, mute transitions during theme swaps.
        tags: [motion, performance, jank, virtualization, layout-shift]
        ---
        
        # Performance discipline
        
        Most jank is self-inflicted: DOM nodes nobody can see, properties the browser has to watch that never change, animation the GPU can't composite, content fetched at request time that could have been built, layout that moves after the user started reading. The fix is usually deleting or deferring work, not adding cleverness. [[transform-opacity-only]] states the property rule; this node is the rest of the discipline.
        
        ## Transitions and compositing
        
        - **Name the properties.** `transition: all` makes every style change a candidate animation and forfeits the engine's optimizations. Tailwind's bare `transition` class compiles to `transition-property: all` — the same bug in disguise; use `transition-transform` or `transition-[opacity,scale]`.
        - **Composite-only.** `transform`, `opacity`, `filter`, `clip-path`. Never `height`, `width`, `padding`, `margin`, `top`/`left`. Keep animated `blur()` under 20px (Safari suffers most).
        - **`will-change` is a targeted fix.** Browsers promote layers just in time, and that occasionally eats the opening frame — a 1px hitch as motion starts. Apply `will-change: transform` (or `transform, opacity`) to the *animating element* after you observe the hitch. It does nothing for layout or paint properties, `will-change: all` defeats the hint, and every promoted layer holds GPU memory.
        - **Never animate a CSS variable on a shared parent** — every descendant recalculates style. Set the property on the moving element.
        
        ## Rendering work
        
        - **Virtualize any list that can pass ~50 items** or grow unbounded (TanStack Virtual or equivalent). Render only what is visible.
        - **Frames never go through React state.** A per-frame `setState` is a re-render storm. Write transforms through refs, WAAPI, or the library's own loop. In Motion the string form `animate={{ transform: "translateX(100px)" }}` is hardware-accelerated; the `x: 100` shorthand runs on the main thread.
        - **CSS/WAAPI for predetermined motion** (stays smooth under load); a JS loop only for dynamic, gesture-driven, or spring values.
        - **Pause off-screen work**: looping animations, video, timers — `IntersectionObserver` or `animation-play-state`, plus `visibilitychange` for hidden tabs.
        
        ## Loading and layout
        
        - **Reserve space for everything asynchronous.** Images and video declare dimensions or `aspect-ratio`; skeletons match the loaded box; empty states are sized to the filled state; changing numbers use `tabular-nums`; hover never changes font weight.
        - **Preload fonts and above-the-fold images**; lazy-load below the fold. A font without preload is a visible reflow on every cold load.
        - **Pre-render content surfaces** (blog, docs, changelog) at build time with revalidation. Never fetch them at request time.
        - **Mute transitions during a theme switch**: add a `no-transitions` class, swap the theme, remove the class after a double `requestAnimationFrame`. Otherwise every color on the page tweens independently in a ragged cascade.
        
        ## Expanding panels
        
        Animating `height` triggers layout every frame. Use `grid-template-rows: 0fr → 1fr`, a clip, or measure once and animate `transform` — see [[clip-path-tricks]].
        
        ## When to apply
        
        "It's slow", "it stutters", "it jumps", any list over ~50 rows, any diff that adds a transition, a font, a hero image, or a theme toggle. Slowness is measured, not judged: if the complaint is "feels wrong" rather than "drops frames", route to [[easing-curves]] and [[duration-table]] instead.
        
        ## Gotcha
        
        `will-change` sprinkled on every card "for performance" is the most common cargo cult in generated CSS. It costs a compositor layer per card and fixes stutters the cards never had. Remove it and add it back only where a first-frame hitch is reproducible.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on performance, distilled by HKTITAN.
        - Related: [[transform-opacity-only]], [[debugging-animations]], [[empty-loading-states]], [[marketing-surface-rules]].
        
      • prefers-reduced-motion.md 2.5 KB
        ---
        title: prefers-reduced-motion
        summary: The one accessibility rule everyone skips. Disable large translations/scales, not all motion.
        tags: [motion, accessibility]
        ---
        
        # `prefers-reduced-motion`
        
        The `prefers-reduced-motion` media query is set by users with vestibular disorders, attention-related conditions, or who simply prefer less motion. About 5–15% of users have it enabled, depending on platform.
        
        The mistake almost everyone makes: setting `* { animation-duration: 0ms !important }` and calling it done. That breaks more than it fixes.
        
        ## What to actually do
        
        | Type of motion | When reduced | Notes |
        |---|---|---|
        | Large translations (slide-in panels, route transitions) | **Disable.** Replace with instant or opacity fade | Causes the most discomfort |
        | Large scale changes (modal entrance from 0.8 → 1) | **Disable.** Replace with opacity only | Same as above |
        | Parallax | **Disable.** Static positioning | The worst offender |
        | Opacity fades | **Keep.** ~120ms is fine | Not motion-disordering |
        | Color/background transitions | **Keep.** | Not motion |
        | Hover micro-interactions (1px shift, color change) | **Keep but consider shortening.** | Borderline |
        | Loading spinners | **Disable.** Use a static label or progress bar | Critical |
        | Auto-playing video / carousel | **Disable.** Require interaction to start | Critical |
        
        ## The CSS pattern
        
        ```css
        .modal {
          transform: scale(0.95);
          opacity: 0;
          transition: transform 200ms, opacity 200ms;
        }
        .modal[data-state="open"] {
          transform: scale(1);
          opacity: 1;
        }
        
        @media (prefers-reduced-motion: reduce) {
          .modal {
            transform: none;
            transition: opacity 120ms;
          }
        }
        ```
        
        Note: we keep the opacity transition, drop the scale.
        
        ## The React / Framer Motion pattern
        
        ```tsx
        const reducedMotion = useReducedMotion();
        <motion.div
          initial={{ opacity: 0, y: reducedMotion ? 0 : 8 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.2 }}
        />
        ```
        
        ## When this matters most
        
        - Any modal, dialog, drawer, or popover.
        - Page transitions in single-page apps.
        - Hero animations on marketing pages (high-motion by design — disable them).
        - Confetti and celebration moments (provide a static success state).
        
        ## Gotcha
        
        Loading spinners are *the* worst offender for reduced motion. A perpetual spinner is exactly the kind of motion the setting exists to suppress. Provide a "Loading..." text label or a progress bar instead. See [[empty-loading-states]].
        
        ## Sources
        
        - WCAG 2.1 — Animation from Interactions.
        - Emil Kowalski — prefers-reduced-motion as a first-class concern, not an afterthought.
        
      • responsive-feedback.md 3 KB
        ---
        title: responsive-feedback
        summary: Button press, tooltip skip-delay, popover origin awareness — the small responsiveness wins.
        tags: [motion, components, micro-interaction]
        ---
        
        # Responsive feedback
        
        Three consolidated micro-interactions that together account for "this feels considered" more than any other set. Each is small. The sum is what users notice.
        
        ## 1. Button press feedback
        
        Buttons must visibly respond to `:active`. The minimum:
        
        ```css
        .button {
          transition: transform 100ms var(--ease-out-quart);
        }
        .button:active {
          transform: scale(0.97);
        }
        ```
        
        Why 0.97 and not 0.95? At 0.95 the button feels squishy. At 0.97 it feels firm. The difference is small and worth caring about. Avoid scale below 0.95 unless you're going for a juicy mobile feel.
        
        For touch targets, add a subtle background dimming as well — a 4% darker overlay on press. This compensates for the fact that the user's finger covers the button.
        
        ## 2. Popover origin awareness
        
        A dropdown opened from the top-right of the screen should *animate from* the top-right, not from center. Same with context menus, tooltips, and any popover-style element.
        
        ```css
        .popover {
          transform-origin: var(--popover-origin, top right);
          transform: scale(0.96);
          opacity: 0;
          transition: transform 180ms var(--ease-out-quart), opacity 180ms;
        }
        .popover[data-state="open"] {
          transform: scale(1);
          opacity: 1;
        }
        ```
        
        Radix UI, React Aria, and Floating UI all expose the trigger position — use it to set `transform-origin`.
        
        **Exception:** modals stay centered. They are not popovers; they are hierarchy shifts. See [[fly-not-teleport]].
        
        ## 3. Tooltip skip-delay
        
        The first tooltip in a group should have a 400–700ms delay before appearing (so casual hovers don't trigger it). Subsequent tooltips in the same UI cluster should appear *instantly*.
        
        ```ts
        // pseudocode
        const TOOLTIP_DELAY = 600;
        const SKIP_WINDOW = 1500; // ms after last close in which next is instant
        
        let lastClosedAt = 0;
        const onHover = () => {
          const sinceLast = Date.now() - lastClosedAt;
          const delay = sinceLast < SKIP_WINDOW ? 0 : TOOLTIP_DELAY;
          setTimeout(showTooltip, delay);
        };
        ```
        
        Radix's `Tooltip.Provider skipDelayDuration={300}` does this for you.
        
        This is the single most reused micro-interaction in well-designed UIs. Without it, exploring a toolbar feels gluey. With it, the user gets the tooltip exactly when they want it.
        
        ## 4. Hover delay symmetry
        
        When a tooltip / popover has an open delay, it should also have a close delay. ~150ms. Otherwise the user moves slightly and the popover flickers.
        
        ## When to apply
        
        Any time you build a button, a tooltip, a popover, a context menu, or a hover-revealed UI. These are the table stakes.
        
        ## Gotcha
        
        Don't set `:active` on form `<input>` elements the same way — scaling an input mid-type is disorienting. Use background-color shift instead. Form inputs get *border-color* + subtle *background* changes on active, not scale.
        
        ## Sources
        
        - Emil Kowalski — tooltips, popovers, origin-awareness from Sonner / Vaul.
        - Radix UI — `Tooltip.Provider skipDelay` implementation.
        
      • shared-letter-morph.md 3.7 KB
        ---
        title: shared-letter-morph
        summary: Text-state morphs that share letters animate the difference. "Continue" → "Confirm" via the shared "Con".
        tags: [motion, text, morph, benji]
        ---
        
        # Shared-letter text morphs
        
        Benji Taylor's button transition pattern from Family: when two states of the same button share letters (e.g., **Continue** → **Confirm**), animate the shared letters in place and crossfade only the differing ones. The eye reads it as the same button *changing its mind* rather than two different buttons swapping.
        
        ## The principle
        
        > If a component occupies a space and will persist in the next phase, it should remain consistent.
        
        For text labels, "consistent" means the letters the user already sees should stay where they are. Only the letters that change should animate.
        
        ## How it works
        
        **Continue → Confirm** shares `Con`. The transition:
        
        1. The "Con" stays in place, unanimated.
        2. The trailing "tinue" exits (fade + slight up-translate).
        3. The new trailing "firm" enters (fade + slight up-translate, staggered slightly).
        
        ```tsx
        function MorphingText({ text }: { text: string }) {
          const chars = text.split('');
          return (
            <AnimatePresence mode="popLayout">
              {chars.map((char, i) => (
                <motion.span
                  key={`${i}-${char}`}
                  initial={{ opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -8 }}
                  transition={{ duration: 0.18 }}
                >
                  {char}
                </motion.span>
              ))}
            </AnimatePresence>
          );
        }
        ```
        
        Motion's `AnimatePresence` with `mode="popLayout"` and a key composed of `index + char` automatically does the right thing: characters that exist in both states (same index, same character) stay; characters that change get enter/exit animations.
        
        ## Why it works
        
        The brain identifies the button by its persistent letters. As long as the "Con" stays, the user perceives this as the same button entering a new state ("I am acknowledging your continue tap, and now requiring confirmation"). If both states fade entirely, the user perceives two separate buttons appearing one after another — which feels like a UI reset.
        
        ## When to apply
        
        - Two-step confirmation buttons (Continue → Confirm, Save → Saved, Submit → Submitted).
        - State labels with shared prefixes (Loading → Loaded, Connecting → Connected).
        - Counter increments where digits change (`12` → `13` — share the "1").
        - Any text-state transition where the words share characters at the same indices.
        
        ## When NOT to apply
        
        - States with no shared characters (Cancel → Done). Use a single fade-crossfade ([[cross-blur-transitions]]) instead — there's nothing to preserve.
        - Long sentences. The technique works best for short labels (1–3 words). Longer text reads as a sentence, not a label, and per-character morph looks weird.
        - Localized text. "Continue" → "Confirm" works in English; the Spanish equivalent ("Continuar" → "Confirmar") shares a different prefix. Don't hard-code shared-letter assumptions.
        
        ## Gotcha
        
        The morph fails if you re-render the parent on state change with no AnimatePresence — React unmounts the old span and mounts a new one with no animation. Wrap the morphing text in `AnimatePresence` and use stable keys (index + char, not random uuid).
        
        Also: a single character changing at the *start* of a word (Submit → Sub*j*ect-like cases) is harder to design well — the shift of the surrounding letters reads as more disruptive than a stable prefix. Where possible, design label pairs that share the *start*.
        
        ## Sources
        
        - Benji Taylor — *Family Values*, [benji.org/family-values](https://benji.org/family-values).
        - Related: [[fly-not-teleport]], [[cross-blur-transitions]], [[responsive-feedback]].
        
      • sonner-principles.md 4.7 KB
        ---
        title: sonner-principles
        summary: Ten principles for building loved components, distilled from Emil Kowalski's Sonner library.
        tags: [motion, components, library-design, sonner]
        ---
        
        # Sonner principles
        
        [Sonner](https://sonner.emilkowal.ski/) is Emil Kowalski's toast library. It's one of the most copied production components on the modern web. The principles behind it are transferable to any small, opinionated library or component.
        
        ## 1. Developer experience is the product
        
        The API a developer types is the user surface. A library used 1000 times a year by 1000 developers compounds. One unnecessary required prop = millions of keystrokes wasted. Default everything you can.
        
        ```ts
        // Good
        toast("Saved")
        
        // Bad
        toast({ message: "Saved", type: "info", duration: 4000 })
        ```
        
        ## 2. Good defaults beat configuration options
        
        Every config option is a tax: docs surface area, decision fatigue, bug surface. Pick the *right* default; let users override only when they need to. Most users will never override; design for them first.
        
        > "What happens if I don't pass anything?" should always produce a good outcome.
        
        ## 3. Naming creates identity
        
        `toast("Saved")` is recognizable. `notify({ kind: "success" })` is forgettable. Pick a verb that reads like a sentence; don't make users learn vocabulary. Sonner is named so people would *want* to say its name.
        
        ## 4. Handle edge cases invisibly
        
        Multiple toasts: stack them. Identical toasts: dedupe. Rapid open/close: throttle. The library handles these without an API. The developer shouldn't think about them.
        
        This is the opposite of "give the user knobs for everything." The library has opinions and enforces them.
        
        ## 5. Use transitions, not keyframes — for interruptible UI
        
        CSS transitions interpolate from the *current* state, even mid-animation. CSS keyframes restart from frame 0. For UI that the user can interrupt (rapid clicks, drag-to-dismiss), transitions feel right; keyframes look broken.
        
        ```css
        /* Good — interruptible */
        .toast { transform: translateY(0); transition: transform 200ms; }
        .toast.dismissing { transform: translateY(100%); }
        
        /* Bad — restarts if interrupted */
        .toast.dismissing { animation: slideOut 200ms; }
        ```
        
        This is the technical foundation behind Sonner feeling responsive even when you spam-dismiss.
        
        ## 6. Build great documentation
        
        The docs site for a library *is* the marketing. If the demo at the top doesn't immediately work and look right, no one installs. Sonner's docs site is itself a polished product (live demo, copy-paste examples, dark/light themes). Same for Vaul, Cmdk, Radix.
        
        Treat docs as a first-class deliverable, not an afterthought.
        
        ## 7. Cohesion matters — match motion to component personality
        
        A "spring-bouncy" component and a "crisp-fast" component shouldn't share an easing curve. Once you've picked a personality, every animation in the library should feel like it came from the same hand.
        
        Sonner is *quiet*. Vaul is *physical*. Cmdk is *snappy*. Each is consistent within itself.
        
        ## 8. Opacity + height is trial-and-error
        
        There's no formula for the perfect collapse animation of a height-changing element. It's: try, watch, tune, repeat. Some combos:
        
        - Animate `opacity` alone, height shifts instantly (often good enough).
        - Animate `max-height` from large to 0 (works for known-height content).
        - Use `grid-template-rows: 0fr → 1fr` (modern collapse pattern, works without knowing height).
        - WAAPI with measured height (most flexible, most code).
        
        Pick by trial. There's no single right answer.
        
        ## 9. Review work the next day
        
        Build the animation today. Sleep. Look again. If you still like it, ship. If you don't, you've saved yourself from shipping something the first day's enthusiasm masked.
        
        This is the cheapest QA step in design engineering. Builds in a 24-hour cooling period that catches 80% of "what was I thinking" mistakes.
        
        ## 10. Asymmetric enter/exit timing
        
        Covered in [[duration-table]] and [[compose-subtract-asymmetry]]. Enter is theatrical; exit is quiet. Exit at ~60–80% of enter duration. Never reverse the entrance.
        
        ## When to apply
        
        When building any reusable component, especially:
        - Toasts, snackbars, alerts.
        - Drawers, sheets, dialogs.
        - Command palettes (cmdk-style).
        - Date pickers, comboboxes.
        - Any small library you intend others to install.
        
        ## Gotcha
        
        These principles are for components you ship to others. For one-off internal UI, several rules relax (defaults matter less, naming matters less, docs aren't needed). Don't apply them with equal force in both contexts.
        
        ## Sources
        
        - Emil Kowalski — The Sonner Principles section of [emilkowalski/skill](https://github.com/emilkowalski/skill).
        - [sonner.emilkowal.ski](https://sonner.emilkowal.ski/) — reference implementation.
        - Related: [[duration-table]], [[compose-subtract-asymmetry]], [[responsive-feedback]].
        
      • spring-animations.md 2.4 KB
        ---
        title: spring-animations
        summary: When springs beat curves — interactive/draggable elements. Apple's spring config. Interruptibility.
        tags: [motion, spring, physics]
        ---
        
        # Spring animations
        
        Curves animate to a fixed duration. Springs animate to a target value with physics. The difference matters when the user is *driving* the animation.
        
        ## Use springs when
        
        - The animation is **interruptible** mid-flight (drag, gesture, scroll).
        - The animation responds to **velocity** from a real input.
        - The target value can change while the animation is running.
        - The element should feel **physical** rather than mechanical.
        
        ## Use curves when
        
        - The animation is triggered by a discrete event (button click, route change).
        - You want **exact timing** (e.g., choreograph multiple animations to land together).
        - The element is small and the difference is invisible.
        
        ## The Apple-style spring
        
        ```ts
        // Framer Motion / Motion
        const spring = {
          type: "spring",
          stiffness: 380,
          damping: 30,
          mass: 1,
        }
        ```
        
        - **Stiffness:** how aggressive the spring pulls toward target. ~380 feels Apple-like; higher = snappier.
        - **Damping:** how much friction. ~30 prevents oscillation. Lower = bouncy.
        - **Mass:** rarely change from 1.
        
        ## Interruptibility — the real reason to use springs
        
        A button that scales on press, then the user clicks rapidly again — a curve-driven animation restarts and looks jittery. A spring continues from current velocity and feels right.
        
        ```tsx
        // Spring (good for press states)
        <motion.button
          whileTap={{ scale: 0.97 }}
          transition={{ type: "spring", stiffness: 500, damping: 30 }}
        />
        
        // Curve (good for one-shot reveal)
        <motion.div
          initial={{ opacity: 0, y: 8 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
        />
        ```
        
        ## When to apply
        
        Pretty much anywhere the user's input drives motion: drag handles, dismissible cards, sliders, pinch-zoom, scroll-snap. For *triggered* motion (modals, toasts, page transitions), curves are simpler and usually better.
        
        ## Gotcha
        
        Springs without a `stiffness` and `damping` set produce slow, floaty defaults. Always set both. The Apple feel comes from a relatively *high* stiffness and *high* damping — not the other way around.
        
        ## Sources
        
        - Emil Kowalski — Sonner spring config, interruptibility argument.
        - Apple Human Interface Guidelines — spring parameter ranges.
        - guidelines.sh — "Spring physics over bezier" for interactive motion.
        
      • stagger-choreography.md 2.6 KB
        ---
        title: stagger-choreography
        summary: Cascading reveals (30–80ms delays). When stagger helps comprehension and when it just reads as noise.
        tags: [motion, stagger, choreography]
        ---
        
        # Stagger choreography
        
        A stagger is a sequence of similar animations offset by a small delay. Done well, it directs attention and reveals structure. Done badly, it looks like the app is loading slowly.
        
        ## When to stagger
        
        - Multiple sibling elements appearing for the first time (list items, dashboard cards, search results).
        - A grouped set of items whose order matters (top-down, left-to-right, by importance).
        - A choreographed reveal that pairs with a single trigger event (page enter, modal open).
        
        ## When NOT to stagger
        
        - The user is in a power-use loop (search-as-you-type, repeated filter changes). The first stagger was fine; the tenth is friction.
        - The list has more than ~12 items. Past that, the last items feel sluggish.
        - The items are visually identical and the order is arbitrary. Stagger implies hierarchy; arbitrary order misleads.
        
        ## The delay range
        
        ```ts
        // good
        const delay = (i) => i * 0.05; // 50ms per item
        
        // too slow — feels like loading
        const delay = (i) => i * 0.15;
        
        // too fast — looks like all at once with extra steps
        const delay = (i) => i * 0.02;
        ```
        
        Sweet spot is **30–80ms** between items. Adjust for item count and physical size on screen.
        
        ## The "stop at 12" pattern
        
        If you have more than 12 items, cap the stagger:
        
        ```ts
        const STAGGER_CAP = 12;
        const delay = (i) => Math.min(i, STAGGER_CAP) * 0.05;
        ```
        
        Items past the cap appear together with the last staggered item. This avoids the "still loading row 47" feel.
        
        ## Reverse stagger on exit
        
        If a list staggers in top-to-bottom, it should exit in the opposite order. Either reverse the index or simply make the exit faster and unstaggered (often best).
        
        ## Direction matters
        
        - **Top-to-bottom:** default for vertical lists.
        - **Center-out:** for celebratory moments (confetti, success screens).
        - **Left-to-right:** for narrative content (onboarding steps).
        - **Per-item motion direction matters too:** if items slide up to enter, the stagger should feel like a wave from bottom to top.
        
        ## When to apply
        
        First-render of any list of 3+ items. Modal open with multiple internal sections. Search results page on initial query (not on refinement).
        
        ## Gotcha
        
        Stagger on every state change is exhausting. Only stagger on **first appearance**. Subsequent updates (sorting, filtering) should crossfade in place or use a layout animation. Re-staggering on filter-change makes the UI feel slower than it is.
        
        ## Sources
        
        - Emil Kowalski — stagger guidance, cap-at-12.
        - guidelines.sh — "stagger reveals 30–50ms."
        
      • transform-mastery.md 4.1 KB
        ---
        title: transform-mastery
        summary: translateY with percentages, scale() scales children, 3D transforms, transform-origin fundamentals.
        tags: [motion, transform, css]
        ---
        
        # CSS transform mastery
        
        `transform` is one of the two GPU-accelerated animatable properties (the other is `opacity` — see [[transform-opacity-only]]). Mastering its mechanics unlocks effects that look impossible to people who only know `translate(10px, 0)`.
        
        ## translate with percentages
        
        `translateY(100%)` translates by the element's own height. This is enormous for layout-aware motion:
        
        ```css
        /* Slide a panel up by its own height to hide it */
        .panel { transform: translateY(100%); transition: transform 250ms var(--ease-out-quart); }
        .panel.open { transform: translateY(0); }
        ```
        
        No magic numbers tied to specific dimensions. The same code works whether the panel is 200px or 800px tall.
        
        Percentage translates also nest correctly: a child's `translateY(100%)` is its *own* height, not the parent's. This is the right behavior for most UI animations.
        
        ## scale() scales children
        
        Setting `transform: scale(1.05)` scales the entire DOM subtree, including text. This is usually NOT what you want:
        
        ```css
        /* Wrong — the text inside also scales */
        .card:hover { transform: scale(1.05); }
        ```
        
        The fix: scale the wrapper, counter-scale the child.
        
        ```css
        .card { transform: scale(1); transition: transform 200ms; }
        .card:hover { transform: scale(1.05); }
        .card-content { transform: scale(0.952); /* 1 / 1.05 */ }
        ```
        
        Or scale only specific elements (icons, illustrations) without text:
        
        ```css
        .card:hover .card-icon { transform: scale(1.1); }
        ```
        
        ## transform-origin — the rotation/scale pivot
        
        By default, transforms originate from the center of the element. To rotate or scale from a corner, set `transform-origin`:
        
        ```css
        /* Scale from top-left corner */
        .popover { transform-origin: top left; transform: scale(0.96); }
        .popover.open { transform: scale(1); }
        ```
        
        For [[responsive-feedback]] popover origin awareness, dynamically set `transform-origin` based on where the popover sits relative to its trigger.
        
        ```css
        .popover { transform-origin: var(--popover-origin, top center); }
        ```
        
        ```ts
        popover.style.setProperty('--popover-origin', `${anchor.x}px ${anchor.y}px`);
        ```
        
        ## 3D transforms — depth without WebGL
        
        `rotateX`, `rotateY`, `rotateZ`, and `perspective` give you depth in pure CSS. The trick is to set `perspective` on the parent:
        
        ```css
        .scene { perspective: 800px; }
        .card { transform-style: preserve-3d; transition: transform 400ms; }
        .card.flipped { transform: rotateY(180deg); }
        ```
        
        Flip-card pattern, in 4 lines. The `preserve-3d` on the inner element is what makes children participate in 3D space.
        
        For an orbit/parallax effect:
        
        ```css
        .orbit-container { perspective: 1200px; }
        .planet {
          transform: rotateY(0deg) translateZ(200px) rotateY(0deg);
          animation: orbit 8s linear infinite;
        }
        @keyframes orbit {
          to { transform: rotateY(360deg) translateZ(200px) rotateY(-360deg); }
        }
        ```
        
        The counter-rotation at the end keeps the planet facing the camera while it orbits.
        
        ## When to apply
        
        - **translateY percentages:** any slide-in/out where the element's own height defines the offset.
        - **scale() children:** check for unintended text scaling in any `:hover { transform: scale }` rule.
        - **transform-origin:** popovers, dropdowns, context menus, anything that opens from a known anchor.
        - **3D transforms:** flip cards, sub-window depth (modal hierarchies), parallax, intentional skeuomorphism. Use sparingly — easy to overdo.
        
        ## Gotcha
        
        Stacking transforms is not commutative. `translate(10px, 0) rotate(45deg)` is different from `rotate(45deg) translate(10px, 0)`. The second one translates *along the rotated axis*. When in doubt, do all `translate`s before all `rotate`s.
        
        Also: `transform` is a single property. If you write `transform: scale(1.05)` to override a `transform: translateY(100%)`, the translate is gone. Use CSS custom properties or `transform: translateY(100%) scale(1.05)` explicitly.
        
        ## Sources
        
        - Emil Kowalski — CSS Transform Mastery section of [emilkowalski/skill](https://github.com/emilkowalski/skill).
        - Related: [[transform-opacity-only]], [[responsive-feedback]], [[fly-not-teleport]].
        
      • transform-opacity-only.md 2.6 KB
        ---
        title: transform-opacity-only
        summary: Only animate transform and opacity. They are GPU-accelerated. Everything else forces layout.
        tags: [motion, performance, css]
        ---
        
        # Animate only transform and opacity
        
        The browser can animate `transform` and `opacity` on the GPU without going through layout or paint. Every other animatable property triggers layout, paint, or both — and you'll feel it on a low-end Android.
        
        ## The rule
        
        ```css
        /* Yes */
        transition: transform 200ms, opacity 200ms;
        
        /* No */
        transition: width 200ms, height 200ms, top 200ms, left 200ms, margin 200ms;
        ```
        
        If you find yourself animating `width`, `height`, `top`, `left`, or `margin`, rewrite the animation using `transform: translate()` and `transform: scale()`.
        
        ## The compose-with-translate pattern
        
        To animate position without `top` / `left`:
        
        ```css
        .menu {
          transform: translateY(0);
          transition: transform 200ms var(--ease-out-quart);
        }
        .menu[data-state="closed"] {
          transform: translateY(8px);
          opacity: 0;
        }
        ```
        
        To animate size without `width` / `height`:
        
        ```css
        .button {
          transform: scale(1);
          transition: transform 120ms;
        }
        .button:active {
          transform: scale(0.97);
        }
        ```
        
        Be aware: `scale()` scales children too. If you don't want that, use a wrapper with `transform: scale()` and counter-scale inside, or animate `padding`/`gap` with caution (slower, sometimes necessary).
        
        ## Framer Motion / Motion gotcha
        
        When using Motion's `x` and `y` props, it splits them into separate `translateX` and `translateY` style declarations, which can break hardware acceleration in some browsers. Prefer the full `transform` string when in doubt:
        
        ```tsx
        // Better for GPU stability
        <motion.div animate={{ transform: "translateY(0px)" }} />
        
        // Convenient but can fragment the transform
        <motion.div animate={{ y: 0 }} />
        ```
        
        ## When this breaks
        
        - **`clip-path`** is GPU-accelerated in modern browsers and is the right tool for reveals — see [[stagger-choreography]] for usage.
        - **`filter: blur()`** is GPU-accelerated but expensive on large surfaces. Use sparingly.
        - **`background-color`** is fine for hover, but don't try to animate a gradient — animate opacity of an overlay instead.
        
        ## Gotcha
        
        A `will-change: transform` on every element is *worse* than none. The browser pre-allocates GPU layers, exhausting memory on mobile. Apply `will-change` only on the element about to animate, and remove it after. Or skip it entirely — modern browsers detect transform animations automatically.
        
        ## Sources
        
        - Emil Kowalski — performance rules, Sonner internals.
        - web.dev — "High performance animations" reference table.
        
      • tray-rules.md 4.2 KB
        ---
        title: tray-rules
        summary: Six rules for tray-style modal stacks — user-initiated, varied heights, single focus, title + dismiss, context preservation, transient actions.
        tags: [motion, modal, tray, benji]
        ---
        
        # Tray rules
        
        Benji Taylor's six rules from Family for designing tray (or "sheet" / "drawer") UI — the modal pattern where successive overlays stack on top of the previous one without dismissing it.
        
        ## The six rules
        
        ### 1. User-initiated
        
        Trays open in response to a user action — never automatically, never on page load, never on idle timeout. If you find yourself wanting to "interrupt the user with a tray," that's the wrong pattern. Use a banner, toast, or in-line message instead.
        
        ### 2. Heights must vary
        
        Successive trays in a stack must have **noticeably different heights**. Same-height trays look like the UI broke — the user can't tell whether the tray actually changed.
        
        ```
        First tray:  height 320px (account form)
        Second tray: height 480px (review and confirm — taller, includes summary)
        Third tray:  height 240px (final confirmation — shorter, just buttons)
        ```
        
        The variation does the heavy lifting of communicating "you're somewhere new." Without it, the stack feels frozen.
        
        ### 3. Single focus
        
        Each tray has **one primary purpose**. One question to answer. One choice to make. One form to fill. If you find yourself adding a secondary action or a second question, that's a second tray.
        
        This is the opposite of the desktop-modal pattern where modals are stuffed with multiple sections. Trays are mobile-first and constrained — one thing per tray.
        
        ### 4. Title + dismiss are always present
        
        Every tray has:
        - A **clear title** at the top stating what this tray is for.
        - A **dismiss affordance** — usually an X in the top-right corner, plus tap-outside-to-dismiss, plus swipe-down-to-dismiss on touch.
        
        Both are mandatory. A tray without a title is disorienting; a tray without a dismiss is hostile.
        
        ### 5. Preserve context (overlay, don't displace)
        
        Trays should **overlay** the previous content, not navigate to a new screen. The previous content stays visible (dimmed) behind the tray. The user always sees where they were.
        
        This is what makes tray flows feel calm vs. modal flows or full-page navigations. You're still "here" — you've just risen a layer.
        
        ```css
        .tray-backdrop {
          background: rgba(0, 0, 0, 0.4);
          backdrop-filter: blur(8px); /* optional, can feel theatrical */
        }
        .tray {
          position: fixed;
          bottom: 0;
          left: 0;
          right: 0;
          /* tray content rises from bottom */
        }
        ```
        
        ### 6. Transient actions only
        
        Trays are for **actions the user will complete and dismiss** — not for long-running tasks, multi-step flows, or persistent UI. If the user needs to come back to a screen, it's not a tray.
        
        Typical tray tasks:
        - Confirm a purchase.
        - Enter a one-time code.
        - Pick a recipient.
        - Rename a thing.
        - Adjust a single setting.
        
        Things that are NOT trays:
        - Multi-screen onboarding (use full-page navigation).
        - Settings panels (use a dedicated screen).
        - Help docs (use a sidebar or external link).
        
        ## When to apply
        
        - Mobile-first apps where modal stacks are the primary navigation between transient actions.
        - Confirmation flows where each step adds context (review → confirm → success).
        - Inline editing of complex objects ("Edit profile" → "Edit avatar" sub-tray).
        
        ## When NOT to apply
        
        - Desktop power-user UIs where users want side-by-side panels. Trays cover the underlying content; power users hate that.
        - Long forms. A 12-field form is not a tray — it's a screen.
        - Multi-step wizards with branching paths. Use a full screen with a progress indicator.
        
        ## Gotcha
        
        The "varied heights" rule (#2) is the one most teams break. They build one tray component, set a fixed height, and stack it. The result feels broken even though each tray works in isolation. The fix is to allow per-tray height — content-driven, not container-driven.
        
        Also: the dismiss interaction must respect [[gesture-momentum]]. Swipe-down-to-dismiss requires velocity-aware threshold logic, not just "swipe more than 50px."
        
        ## Sources
        
        - Benji Taylor — *Family Values*, [benji.org/family-values](https://benji.org/family-values).
        - Related: [[fly-not-teleport]], [[gesture-momentum]], [[responsive-feedback]], [[cards-design]] (the broader modal/card-design rules).
        
    • philosophy
      • articulate-precisely.md 3.4 KB
        ---
        title: articulate-precisely
        summary: Name the phenomenon exactly, and say why it works. Precise vocabulary is what converts taste into a spec — for a teammate or for an agent.
        tags: [philosophy, taste, vocabulary, articulation]
        ---
        
        # Articulate precisely
        
        Two people look at the same screen. One says "it feels off." The other says "the leading is too tight and the uppercase labels need more tracking." Only the second can fix it, hand it off, or teach it. The gap between them is not taste — it is vocabulary.
        
        "Say precisely what you mean" is the thesis of *Index*, Emil Kowalski and Glenn Carstens-Peters' design-vocabulary project. Naming a thing exactly is the precondition for discussing it, critiquing it, and instructing it. "Make it pop" is a wish; "raise the contrast to 4.5:1," "use tabular nums so the column stops jittering" is a spec.
        
        ## Why this matters for an agent
        
        Emil's argument in *Agents with Taste*: almost every taste decision has a logical reason if you look closely enough — and a reason can be written as a rule an agent follows instead of guessing. `scale(0.95)` beats `scale(0)` on enter not by feel but because a real object never deflates to nothing; name that and it becomes [[never-scale-from-zero]]. The more of your taste you can articulate, the more an agent can carry.
        
        So precision runs both directions:
        
        - **Input** — when you describe what is wrong, the exact word ("widow," "optical centre," "layout shift") tells the agent precisely what to change. Vague input earns vague output.
        - **Output** — when the agent critiques a UI, it should name the phenomenon, not gesture at it. "The play icon looks left-heavy because it is mathematically centred, not optically centred" is reviewable. "The icon looks weird" is not.
        
        ## In practice
        
        - Reach for the exact term. [[design-vocabulary]] is the lexicon — the precise word for each phenomenon, and where this skill goes deeper on it.
        - Pair every critique with its reason. "Don't" without "because" does not transfer to the next person or the next prompt.
        - Hold the confusable pairs apart, because vague language collapses them: kerning vs tracking, opacity vs visibility, chroma vs saturation, a widow vs an orphan, a tag vs a badge. Reaching for the wrong one quietly signals you cannot see the difference.
        
        ## Relation to pointing
        
        [[pointing-beats-describing]] is the sibling principle: a precise *selector* — file, line, coordinate — beats prose when feedback goes to a machine. This node is about the *word*; that one is about the *target*. Together: point at the exact element, and name exactly what is wrong with it.
        
        ## Gotcha
        
        Precision is not jargon for its own sake. The goal is the shortest unambiguous description, not the most technical one. If a plainer word is exact, use it — "the button has no hover state" beats "the affordance lacks a mouseover transition." Both are precise; the first is also clear. Naming is in service of [[feeling-right]], not a substitute for it.
        
        ## Sources
        
        - *Index — Say Precisely What You Mean*, Emil Kowalski & Glenn Carstens-Peters — [index.how/to/articulate](https://index.how/to/articulate). The vocabulary project this node and [[design-vocabulary]] are seeded from.
        - Emil Kowalski, *Agents with Taste* — [emilkowal.ski/ui/agents-with-taste](https://emilkowal.ski/ui/agents-with-taste). Articulating *why* a choice works is what makes it packageable into a skill.
        - Related: [[taste-is-trained]], [[pointing-beats-describing]], [[design-vocabulary]].
        
      • beauty-is-leverage.md 2.5 KB
        ---
        title: beauty-is-leverage
        summary: Polish compounds into competitive moat. The thing that looks like it doesn't matter is often the thing that does.
        tags: [philosophy, taste, leverage]
        ---
        
        # Beauty is leverage
        
        Emil Kowalski's framing: polish is not a cost center, it's leverage. Two products with equivalent functionality compete on feel, and feel is decided by the details users can't articulate.
        
        ## The argument
        
        - **Most software is functionally undifferentiated.** There are five todo apps, ten chat clients, twenty CRMs. The functional surface is solved.
        - **Differentiation is therefore non-functional.** It lives in motion, typography, copy, hover states, error messages — the stuff that doesn't show up in feature comparison tables.
        - **Users feel polish without naming it.** They say "I like X more, not sure why." That "not sure why" is the leverage.
        - **Polished products win the long game.** They get recommended (people brag about products that feel good). They get used more (less friction). They get talked about (designers screenshot them).
        
        ## What this means for prioritization
        
        When a PM says "polish isn't a priority right now," the answer is *that's exactly when polish becomes most differentiating*. Functionality plateaus; polish does not. The last 5% of polish takes 50% of the time and is the part competitors can't copy in a sprint.
        
        ## What this does NOT mean
        
        - It doesn't mean every product needs to feel like Linear. Match polish to the product's segment ([[marketing-vs-product-ui]]) and audience.
        - It doesn't mean polish before correctness. A buggy product that animates beautifully is still a buggy product.
        - It doesn't mean theatrical. The best polish is *invisible*. See [[unseen-details-compound]].
        
        ## When to use this argument
        
        - When justifying time spent on the 50ms button-press feel ([[responsive-feedback]]).
        - When pushing back on "just ship it" pressure on a customer-facing surface.
        - When designing a hiring rubric for design engineers — care about leverage, not feature output.
        - When evaluating two competing approaches and one feels right but is harder to justify by metrics.
        
        ## Gotcha
        
        Beauty-as-leverage stops working when the product is broken. A pretty-but-buggy UI looks worse than an ugly-but-reliable one. Polish multiplies trust earned through correctness; it doesn't substitute for it.
        
        ## Sources
        
        - Emil Kowalski — Core Philosophy section of [emilkowalski/skill](https://github.com/emilkowalski/skill).
        - Related: [[taste-is-trained]] (where leverage comes from), [[unseen-details-compound]] (how leverage accrues).
        
      • data-is-content.md 3.6 KB
        ---
        title: data-is-content
        summary: Tables, charts, lists, dashboards deserve the same typography, alignment, and whitespace care as marketing pages.
        tags: [philosophy, data, dashboards, tables]
        ---
        
        # Data is content
        
        Designers will spend a week on a hero image and 30 minutes on the table that users actually look at every day. That's backwards. Tabular data, charts, and lists are the most-viewed surfaces in most B2B and prosumer products — and they are usually under-designed.
        
        The principle: data display IS content. Apply the same craft you'd apply to a marketing page.
        
        ## What "treating data as content" means
        
        ### Tables
        
        - **Right-align numbers, left-align text, center icons.** Numbers compare easier when their decimal points line up.
        - **Use tabular figures** (`font-variant-numeric: tabular-nums`). Without this, "$1,234" and "$987" don't align column-wise because proportional fonts size digits differently.
        - **Borders are subtle or absent.** Heavy grid lines are an Excel tell. Use whitespace and very-low-opacity row dividers (e.g., `border-bottom: 1px solid rgba(0,0,0,0.05)`).
        - **Sticky header.** Long tables need the column labels to stay visible. Subtle — no shadow, just a background.
        - **Density toggles.** Power users want compact (rows 28px). Casual users want comfortable (rows 44px). Both, with a toggle.
        - **Zebra striping is dated.** Most modern tables don't use it. White space between rows reads cleaner.
        
        ### Charts
        
        - **One color per series, picked deliberately.** Most chart libraries default to a rainbow. Pick 2–4 colors from your palette.
        - **Remove chartjunk.** Default axes, default tooltips, default legends are usually noise. Customize everything.
        - **Tooltips are content.** A line chart tooltip should show the value precisely + context (date, comparison to prev period). Not just "Y: 47."
        - **Label the axes meaningfully.** "Date" is not a label. "Revenue, last 30 days" is.
        - **Animate on first render only.** The chart drawing in is a one-time delight ([[delight-impact-curve]]). Updates should be instant.
        
        ### Lists
        
        - **Item hierarchy uses size, weight, color — not just spacing.** Title heavier and bigger. Subtitle lighter and smaller.
        - **Hover state on every interactive row.** Subtle ([[hover-states-subtle]]).
        - **Empty states matter most here.** See [[empty-loading-states]] and [[states-are-the-work]].
        
        ## Specifically for B2B / data-heavy UIs
        
        - **Density wins consistency.** Power users will toggle to dense and never go back. Default to dense if the product is for analysts.
        - **Inline editing.** Click cell → edit → blur to save. Beats modal-based edit by ~10x in flow.
        - **Keyboard navigation.** Arrows to move, Enter to edit, Esc to cancel. Every spreadsheet user expects this.
        
        ## When to apply
        
        - Any product that surfaces data the user *reads* (not just receives).
        - Dashboards, admin panels, analytics tools, finance apps, productivity apps, CRMs, log viewers.
        - Even consumer apps with rich content lists (Notion, Linear, Things) — these are "data" in disguise.
        
        ## Gotcha
        
        Don't apply marketing-page rules to data. Asymmetric layouts and "imperfect" alignment ([[visual-imperfection]]) are wrong here — data needs predictability. See [[marketing-vs-product-ui]].
        
        The exception: data *visualizations* (charts, diagrams) can have personality. Tables and lists should not.
        
        ## Sources
        
        - guidelines.sh — "Data display is content."
        - Edward Tufte — *The Visual Display of Quantitative Information* (chartjunk, data-ink ratio).
        - Linear, Notion, Pipedrive — modern data-as-content table design.
        - Related: [[marketing-vs-product-ui]], [[states-are-the-work]], [[empty-loading-states]].
        
      • delight-impact-curve.md 2.1 KB
        ---
        title: delight-impact-curve
        summary: Potential for delight rises as feature frequency falls. Daily-use features need minimal delight; rare moments earn big ones.
        tags: [philosophy, delight, animation]
        ---
        
        # The delight-impact curve
        
        The more often a user encounters a feature, the *less* delight it can carry without becoming annoying. The less often a user encounters it, the *more* delight it can carry, because each encounter is fresh.
        
        This is Benji Taylor's framing and it is the single most useful heuristic for deciding "should this animate / make a sound / have a flourish?"
        
        ## The curve, concretely
        
        | Frequency of use | Delight allowance |
        |---|---|
        | Every keystroke (typing, scroll) | **None.** Any animation here is friction. |
        | Every action (clicking primary buttons, hovering rows) | Subtle. ~100–160ms. No flourish. |
        | Daily-use moments (commit, send, save) | Small. A tick. A momentary highlight. |
        | Weekly moments (new doc, share, complete a flow) | Medium. Stagger or scale-in. |
        | Rare milestones (sign up complete, first export, year-in-review) | **Big.** Confetti is fine here. |
        
        ## What this means in practice
        
        - The keyboard never gets animation. Period.
        - Primary buttons should feel immediate and pressable, but not "delightful."
        - The big motion budget is reserved for moments the user encounters rarely. Save your best ideas for those.
        - A daily-use feature that gets weekly-tier animation will feel slow within a week.
        
        ## When to apply
        
        Anytime someone proposes "let's make this more fun." Ask: how often does the user see this? Then look at the curve. If it's high-frequency, the right answer is usually "make it more invisible," not "make it more visible."
        
        ## Gotcha
        
        The curve breaks for first-run experiences. Onboarding is rare-per-user but high-leverage; it can carry weekly-tier delight even on per-action moments. Once onboarding is done, the budget collapses back to the per-frequency norm.
        
        ## Sources
        
        - Benji Taylor, "Family Values" — the explicit delight-impact framing.
        - Emil Kowalski, [[animation-decision-framework]] — frequency-based decision tree variant.
        
      • dependency-discipline.md 4.4 KB
        ---
        title: dependency-discipline
        summary: Replicate simple utilities. Draw shapes in CSS. Prefer native browser APIs over libraries. The bundle is design.
        tags: [philosophy, dependencies, performance, restraint]
        ---
        
        # Dependency discipline
        
        A 5MB JS bundle is a design choice. So is a 50KB one. So is choosing `moment.js` over `Intl.DateTimeFormat`. So is reaching for `axios` when `fetch` exists.
        
        The principle: **every dependency you add is a tax** — bundle size, security surface, version drift, license review, time-to-interactive. The bar for adding one should be higher than "it's convenient."
        
        This is a philosophy node, not a tactical one. It applies before any CSS or motion decision.
        
        ## Replicate simple utilities
        
        Before adding `lodash` for one helper, consider whether you can write it in 5 lines:
        
        ```ts
        // 'lodash/uniq'
        const uniq = <T,>(arr: T[]): T[] => [...new Set(arr)];
        
        // 'lodash/groupBy'
        const groupBy = <T, K extends string>(arr: T[], key: (x: T) => K) =>
          arr.reduce((acc, x) => ({ ...acc, [key(x)]: [...(acc[key(x)] ?? []), x] }), {} as Record<K, T[]>);
        
        // 'classnames' — useful, but trivial to write
        const cx = (...classes: (string | false | null | undefined)[]) => classes.filter(Boolean).join(' ');
        ```
        
        If you only use 2 functions from a 70KB library, write the 2 functions.
        
        ## Draw simple shapes in CSS, not images
        
        Before adding an SVG file or PNG for a shape:
        
        ```css
        /* Triangle */
        .triangle {
          width: 0; height: 0;
          border-left: 10px solid transparent;
          border-right: 10px solid transparent;
          border-bottom: 16px solid var(--accent);
        }
        
        /* Circle */
        .circle { width: 16px; height: 16px; border-radius: 50%; background: var(--accent); }
        
        /* Donut */
        .donut { width: 32px; height: 32px; border: 4px solid var(--accent); border-radius: 50%; }
        
        /* Chevron via border + rotate */
        .chevron {
          width: 8px; height: 8px;
          border-right: 2px solid currentColor;
          border-bottom: 2px solid currentColor;
          transform: rotate(45deg);
        }
        ```
        
        No HTTP request, no decoded image bytes, no `alt` text to maintain. The shape is in the stylesheet.
        
        ## Prefer native browser APIs
        
        The web platform has shipped a lot in the last decade. Many libraries that "everyone uses" are obsolete.
        
        | Library | Native replacement |
        |---|---|
        | `moment.js` (300KB) | `Intl.DateTimeFormat`, `Intl.RelativeTimeFormat` |
        | `axios` | `fetch()` (with a thin wrapper for JSON parsing) |
        | `lodash` (most uses) | `Array.from`, `.flat`, `.includes`, `.at`, `Object.fromEntries` |
        | `classnames` | One-line `filter(Boolean).join(' ')` helper |
        | `uuid` | `crypto.randomUUID()` |
        | `query-string` | `URLSearchParams` |
        | jQuery | DOM API + `closest()`, `matches()`, `querySelector()` |
        | `intersection-observer-polyfill` | Native IntersectionObserver (universal since 2019) |
        
        Audit your `package.json` annually. Many production apps have 5+ MB of dependencies because someone added `moment.js` in 2017 and no one removed it.
        
        ## When to add a dependency anyway
        
        - **The library is large and complex** to write correctly: cryptography (`@noble/hashes`), framework runtimes (React, Vue), date parsing edge cases (`date-fns` over reinventing).
        - **The library has a security team** maintaining it (`@stripe/stripe-js`, OAuth clients).
        - **The library is small and focused** with a clear single purpose (`nanoid`, `clsx`).
        - **The team's velocity matters more than the bundle** (early-stage product, internal tool).
        
        These are real reasons. "It's convenient" is not.
        
        ## When to apply
        
        - Before `npm install <anything>` — pause for 30 seconds. Can you write the 5 lines instead?
        - During PR review — flag new dependencies, ask whether the alternative is a 10-line utility.
        - At quarterly audits — `npx bundle-phobia` or `webpack-bundle-analyzer` on the bundle. Anything over 100KB is a budget item.
        
        ## Gotcha
        
        This rule applies in proportion to the project's expected lifetime and audience. A throwaway prototype can `import _ from 'lodash'` and ship in an afternoon. A production app shipping to mobile users across slow networks pays for that dependency every page load forever. Calibrate.
        
        ## Sources
        
        - Ben DC — [frontend-guidelines](https://github.com/bendc/frontend-guidelines) — "Favor native methods."
        - Vercel — bundle-size as a design dimension.
        - web.dev — "Tame third parties," bundle audits.
        - Related: [[transform-opacity-only]] and [[debugging-animations]] (the performance-is-design framing lives in those motion nodes), [[unseen-details-compound]].
        
      • feeling-right.md 2.3 KB
        ---
        title: feeling-right
        summary: Optimize for "feeling right," not just "working." This is the part the model cannot infer from training data.
        tags: [philosophy, taste, feel]
        ---
        
        # Feeling right
        
        A UI can be technically correct, accessible, performant, and consistent — and still feel cheap. "Feeling right" is the residual quality that's left after every other box is ticked. It's also the part of design engineering that LLMs are weakest at.
        
        Benji Taylor: *"Claude got morphing icons technically correct, but couldn't tell when something looked wrong. That judgment is human."*
        
        ## What feeling right means
        
        - Motion clarifies intent rather than decorates.
        - The first frame and the last frame both look intentional, not accidental.
        - Persistent elements stay continuous (see [[fly-not-teleport]]).
        - A lifeless product feels uncared for; a lifeless product *is* uncared for.
        - The thing the user notices last, after using it for a week, is the thing the designer worked hardest on.
        
        ## How to test for "feels right"
        
        You cannot test this with automated checks. You can only test it with:
        
        - **The next-day review.** Build it, sleep, look again. If you still like it, ship it. Emil's principle.
        - **The phone-call test.** Demo to a friend on a phone call. If they say "wait, what?" the timing is off.
        - **The slow-motion playback.** Record the interaction, slow it to 25%, watch each frame. Almost every motion bug shows up here.
        - **The cold-eyes test.** Hand it to someone who has never seen the product. Their first reaction is the truth.
        
        ## When to apply
        
        - Use this as the **final** check, after correctness, accessibility, performance, and consistency.
        - If the user says "it works but something's off," this is the cluster to load. The fix is rarely a single property; usually it's the rhythm and timing across several elements.
        - See [[delight-impact-curve]] for *where* feeling-right is most worth chasing.
        
        ## Gotcha
        
        "Feeling right" is not a synonym for "fancy." A static screen with the right hierarchy, type, and color feels more right than a busy screen with five animations. Restraint reads as confidence; over-design reads as anxiety.
        
        ## Sources
        
        - Benji Taylor — [benji.org/agentation](https://benji.org/agentation), [benji.org/morphing-icons-with-claude](https://benji.org/morphing-icons-with-claude).
        - Emil Kowalski — Sonner principles, "review work the next day."
        
      • marketing-vs-product-ui.md 2.4 KB
        ---
        title: marketing-vs-product-ui
        summary: Marketing is brand expression and narrative pacing. Product is consistency for the 100th session, not the first. Different rules apply.
        tags: [philosophy, marketing, product]
        ---
        
        # Marketing UI vs Product UI
        
        The single most common mistake in modern design is applying marketing-page energy to in-app product UI, or vice versa. They are different mediums with different goals.
        
        ## The split
        
        | | Marketing | Product |
        |---|---|---|
        | Frequency | Visited once or twice | Used 100s of times |
        | Goal | Convert, communicate, impress | Get out of the way |
        | Asymmetry | Welcome (narrative pacing) | Bad (looks broken) |
        | Animation budget | Generous (scroll reveals, hero motion) | Tight (see [[delight-impact-curve]]) |
        | Custom typography | Encouraged | Risky (legibility at small sizes) |
        | Density | Loose | Often dense |
        | Personality | Front and center | Earned in small moments |
        
        ## What this means in practice
        
        - **Marketing pages can break the design system.** Custom layouts, asymmetric grids, hand-rendered illustrations, scroll-driven hero motion — all valid here. They would be tells in-app.
        - **Product UI cannot break the design system.** The 100th session reveals every inconsistency. The micro-stagger that delighted on session 1 nauseates on session 50.
        - **States are the real work in product UI.** Empty, loading, error, full, dense, sparse. Marketing rarely has these problems; product has them constantly.
        - **Power-user features signal craft in product UI.** Keyboard shortcuts, multi-select, bulk operations. These are how product UI earns delight at low cost.
        
        ## When to apply
        
        - Before reviewing any UI, ask: is this marketing or product? The criteria are different.
        - When a designer proposes "let's add an animation here," ask the same question. The answer changes the verdict.
        - See [[ai-default-tells]] for the giveaways of mixing the two — e.g., generic hero + 3-cards + FAQ on a *product* page, or pristine grid on a *marketing* page.
        
        ## Gotcha
        
        Onboarding straddles both. It is in-product but rare-per-user. Treat onboarding more like marketing for delight budget, but more like product for layout discipline. The first run is allowed to be theatrical.
        
        ## Sources
        
        - guidelines.sh — "Marketing: brand expression, asymmetry, narrative pacing. Product: consistency, design for the 100th session, states are the real work."
        - Benji Taylor — Family Values essay, Honkish.
        
      • MOC-philosophy.md 2.3 KB
        ---
        title: MOC-philosophy
        summary: When polish matters, what taste is, and how delight earns its weight.
        tags: [moc, philosophy]
        ---
        
        # MOC — Philosophy
        
        The cluster that answers "why bother." Use when the user asks why something feels off, when picking between two competing approaches, or when justifying a polish investment.
        
        ## Nodes
        
        - [[taste-is-trained]] — Taste is a trained instinct, not innate. Collect references obsessively. Constraints breed taste.
        - [[unseen-details-compound]] — The details users don't consciously notice are the ones that accrue trust. Paul Graham × Emil.
        - [[beauty-is-leverage]] — Polish is competitive moat in undifferentiated software markets. The "polish isn't priority" objection backwards.
        - [[delight-impact-curve]] — Potential for delight rises as feature frequency falls. Daily-use features get minimal delight; rare moments earn big ones.
        - [[feeling-right]] — Optimize for "feeling right," not just "working." This is the part the model can't infer from training data.
        - [[marketing-vs-product-ui]] — Marketing is brand expression and narrative pacing. Product is consistency designed for the 100th session, not the first. Different rules apply.
        - [[states-are-the-work]] — The empty, loading, error, partial, offline, permission-denied states are 80% of the actual product work — the "happy path" is the easy 20%.
        - [[data-is-content]] — Tables, charts, lists, dashboards deserve the same typography / alignment / whitespace care as marketing prose. Data display *is* design.
        - [[dependency-discipline]] — Every dependency is a tax. Replicate simple utilities. Draw shapes in CSS. Prefer native browser APIs. The bundle is design.
        - [[pointing-beats-describing]] — Precise machine-readable selection (selectors, file paths, coordinates) outperforms prose when feedback goes to an AI agent. Benji Taylor's framing.
        - [[articulate-precisely]] — Name the phenomenon exactly and say why it works; precise vocabulary is what turns taste into a rule an agent can follow. Its lexicon is [[design-vocabulary]]. Emil Kowalski & Glenn Carstens-Peters (Index).
        
        ## When to read this whole cluster
        
        When the user asks a meta question: "should we polish this?", "is this worth the time?", "why does this feel cheap?". For tactical animation/typography questions, skip straight to [[MOC-motion]] or [[MOC-typography]].
        
      • pointing-beats-describing.md 5 KB
        ---
        title: pointing-beats-describing
        summary: Precise machine-readable selection (selectors, file paths, coordinates) outperforms natural-language description when feedback goes to an AI agent.
        tags: [philosophy, feedback, agentation, agents]
        ---
        
        # Pointing beats describing
        
        Benji Taylor's framing from [agentation.com](https://www.agentation.com) and [benji.org/annotating](https://benji.org/annotating): when feedback is destined for an AI coding agent, **precise selection beats prose every time**.
        
        A user saying "the blue button in the sidebar looks misaligned" sends the agent on a search through the codebase. A user clicking on that button and producing `.sidebar > button.primary { class="cta is-large" }` sends the agent directly to the right line.
        
        ## The principle
        
        | Prose feedback | Pointing feedback |
        |---|---|
        | "the blue button in the sidebar" | `.sidebar > button.primary` |
        | "the spacing on the card feels off" | annotation on `.product-card`, file `src/components/ProductCard.tsx:42`, computed style `padding: 16px 12px` |
        | "make this section more interesting" | annotation on `<section id="hero">`, with reference: "like Stripe's homepage hero" |
        | "the dark mode is wrong" | annotation on `body.dark`, computed style `background: #000`, fix link to [[dark-mode]] |
        
        The right-column versions cost the user marginally more effort but cost the agent **much less context to act on correctly**.
        
        ## Why this matters now
        
        LLM coding agents are good at execution, weaker at search. Every token spent searching is a token not spent on the actual fix. Selectors, file paths, and computed styles compress search-cost to near-zero.
        
        This is why tools like [Agentation](https://www.agentation.com) exist — they let humans *click* elements instead of describing them, then emit structured selectors the agent can grep for directly.
        
        ## Where pointing applies
        
        - **UI review feedback** — annotate the element; don't describe it.
        - **Bug reports to an AI agent** — include selectors, console errors, file paths.
        - **"Build me X" requests** — link a reference (screenshot, URL, or comparable product), don't write a 500-word description.
        - **Code reviews destined for an agent fix** — quote the exact lines.
        
        ## Where prose still wins
        
        - **Justifying *why*** — "this feels off" needs prose to explain the principle ("the elevation hierarchy is inverted — child elements have stronger shadows than parents"). Pointing identifies the WHAT; prose explains the WHY.
        - **Naming the principle** — "this violates visual hierarchy" or "this is a Gestalt grouping issue" — invoking the principle gives the agent the right vocabulary to look up.
        - **Talking to humans** — humans interpret prose better than selectors. Use pointing when the audience is the agent; use prose when the audience is a designer or PM.
        
        ## The two-channel pattern
        
        Best feedback combines both:
        
        > [Click annotation on `.product-card`, file `ProductCard.tsx:42`]
        >
        > "The padding feels cramped — `16px 12px` reads as B2B form-density, not the consumer-feel we're going for. Bump to `24px 20px` to match the hero card. See [[border-radius]] for the parallel-padding-to-radius rule."
        
        The pointing tells the agent **where**. The prose tells it **why and what**. The wikilink tells it **the principle**.
        
        ## When to apply
        
        - Any time you're giving feedback that an agent will execute.
        - Any time you're authoring a design-review document that includes an agent in the workflow.
        - Any time you're tempted to write "the X in the Y" — instead, click the X and let the tool generate the selector.
        
        ## Tools that enable pointing
        
        - **Agentation** — the productized version. Toolbar in your localhost app, click → annotate → emit selectors. Recommended for any installer of this skill. See [[agentation-workflow]] for installation.
        - **Browser DevTools "Copy selector"** — manual but free. Right-click any element → Inspect → right-click in DOM → Copy → Copy selector.
        - **Chrome's Recorder** — captures a full user-flow as selectors + actions. Heavier; useful for repro steps.
        - **Screenshot annotations** (CleanShot, Figma, Loom) — visual pointing rather than DOM pointing. Works for non-web UIs.
        
        ## Gotcha
        
        Don't use this principle as an excuse to skip explanation. A bare selector with no rationale ("`.sidebar > button.primary` — fix it") is *worse* than prose, because the agent has no idea what's wrong. The principle is *pointing + reason*, not *pointing instead of reason*.
        
        Also: brittle selectors break. `.product-card > div:nth-child(3) > span` is technically a selector but it's lottery-ticket fragile. Prefer semantic selectors (`[data-testid="product-card-name"]`) or className-stable ones (`.product-card__name`).
        
        ## Sources
        
        - Benji Taylor — [benji.org/annotating](https://benji.org/annotating), [benji.org/agentation](https://benji.org/agentation).
        - [Agentation](https://www.agentation.com) — the productized version.
        - Related: [[agentation-workflow]] (how to install + use Agentation), [[feeling-right]] (the prose-side of feedback), [[review-format]] (structured UI-critique format).
        
      • states-are-the-work.md 3.1 KB
        ---
        title: states-are-the-work
        summary: Loading, empty, error, partial, offline, permission-denied. The real design surface is the states, not the happy path.
        tags: [philosophy, states, product]
        ---
        
        # States are the work
        
        Designers default to designing the happy path — data loaded, user authenticated, content present. Engineers ship the happy path. Then real users hit states the design never considered, and the product looks broken.
        
        The 100th-session truth: most of what a user sees is *not* the happy state. It's a state the designer never opened Figma for.
        
        ## The state taxonomy
        
        For every screen, design (or at least sketch) each of these:
        
        - **Loading** — data fetch in progress. See [[empty-loading-states]].
        - **Empty** — query returns zero results. No items yet. New account.
        - **Partial** — some content loaded, some still loading. Lazy-rendered lists.
        - **Error** — API failed. Network down. Permission denied.
        - **Offline** — no connection at all. Different from a single failed call.
        - **Permission-denied** — authenticated but unauthorised. Hide vs. show-locked.
        - **Stale** — content cached, last refreshed N minutes ago. Show the staleness.
        - **Over-quota** — user hit a limit. Free tier exceeded. Read-only mode.
        - **Maintenance** — feature temporarily disabled. Read-only, banner explaining.
        - **Onboarding** — first-run experience, sample data, guided tour.
        - **Power-user** — bulk selection mode, keyboard nav active.
        - **Sync conflict** — local and remote disagree. Which wins, how does user choose.
        
        ## What "design the states" means concretely
        
        - Each state has its own Figma frame.
        - Each state has copy written for it (real copy, not "Error: something went wrong").
        - The state-to-state transitions are designed: how do you go from loading → empty without flash? From error → retry-success without confusion?
        - Power-user features (keyboard shortcuts, bulk select) are first-class, not bolt-ons. If you accumulate enough lessons here, split out a dedicated `power-user-paths.md` node.
        
        ## The 90/10 rule
        
        90% of "this product feels broken" reports trace to an unhandled state. Not a bug in the happy path — a state nobody designed for.
        
        Fixing states is the single highest-leverage post-launch design work.
        
        ## When to apply
        
        - During design reviews: ask "what about loading? empty? error?" for every screen.
        - During code reviews: grep for `useState`, `useQuery`, `useFetch` — every one of these has at least a loading and error state. Are they both handled?
        - During QA: deliberately set network to offline, deliberately log out mid-session, deliberately exhaust quota. Most apps fail this within 60 seconds.
        
        ## Gotcha
        
        Don't conflate "empty" with "error." An empty inbox and a broken inbox should look different and read differently. Generic "No data" messages collapse the two and frustrate users who can't tell which.
        
        ## Sources
        
        - guidelines.sh — "States are the real design work."
        - Brad Frost — "Loading state best practices."
        - Related: [[empty-loading-states]], [[marketing-vs-product-ui]] — the "design for the 100th session" framing is covered in `marketing-vs-product-ui`.
        
      • taste-is-trained.md 2.1 KB
        ---
        title: taste-is-trained
        summary: Taste is a trained instinct, not innate. Reference libraries, constraints, and time spent looking are the inputs.
        tags: [philosophy, taste]
        ---
        
        # Taste is trained
        
        Taste is not innate. It is a trained instinct built from exposure, constraint, and time spent paying attention to things most people skip past.
        
        The people whose work feels "obviously right" — Emil Kowalski, Benji Taylor, the Vercel design team — all share a habit: they collect references obsessively. Screenshots, recordings, products opened just to feel them. The library is the training set.
        
        ## What this means in practice
        
        - **Build a reference library.** Save the interactions, animations, and screens that stop you. Re-watch them when stuck.
        - **Constraints breed taste.** Limiting yourself to two fonts and three colors forces decisions. Unlimited choice is paralyzing and tends to produce average work.
        - **Imitate before deviating.** The first hundred buttons should look like buttons. Originality earned after fluency lasts. Originality before fluency reads as confusion.
        - **Taste compounds with care.** The first version of a thing should be ugly. The third version starts to feel inevitable.
        
        ## When to use this knowledge
        
        - When the user says "I don't know why this feels off" — the answer is usually that they haven't looked at enough reference yet. Ask what they've been studying.
        - When choosing between two valid approaches — usually one is closer to what the user's reference set looks like. That one wins.
        - When justifying time spent on polish that "doesn't matter" — see [[feeling-right]] and [[delight-impact-curve]].
        
        ## Gotcha
        
        Don't confuse taste with novelty. A button that looks exactly like Linear's is more tasteful than a button that "innovates" with a gradient and a custom shape. Imitation of considered work is taste; reinvention of solved problems is not.
        
        ## Sources
        
        - Emil Kowalski, [emilkowal.ski](https://emilkowal.ski) — "Taste is trained, not innate."
        - Benji Taylor, [benji.org](https://benji.org) — collecting references as a daily habit.
        - guidelines.sh — "Taste is opinions; collect references obsessively."
        
      • unseen-details-compound.md 2.7 KB
        ---
        title: unseen-details-compound
        summary: The details users never consciously notice are the ones that decide whether they trust the product.
        tags: [philosophy, details, taste]
        ---
        
        # Unseen details compound
        
        Paul Graham's framing, sharpened by Emil Kowalski: the details a user *consciously* notices are mostly noise; the details they *don't* notice are signal. A product that gets the unseen details right accumulates trust that has no single attributable source.
        
        ## What "unseen details" are
        
        - The button that scales to 0.97 on press, not 0.95.
        - The 600ms tooltip delay that drops to 0ms after the first hover ([[responsive-feedback]]).
        - The skeleton placeholder that matches the actual layout ([[empty-loading-states]]).
        - The dark-mode background that is `#18181b`, not pure black ([[dark-mode]]).
        - The font tracking that tightens at display size ([[line-length-tracking]]).
        - The shadow that uses three layers at 4% opacity instead of one at 10% ([[shadows-whisper]]).
        
        None of these are noticed individually. All of them compound.
        
        ## How compounding works
        
        Each invisible detail does one of two things:
        1. **Removes a micro-friction.** The user doesn't notice the absence of friction; they just feel better using the product.
        2. **Signals care.** Even unconsciously, users distinguish between a product where someone tuned the details and one where nobody did.
        
        Twenty unseen details = twenty micro-frictions removed = a product that feels uncared-for is replaced by one that feels considered. Users won't say "the tooltip delay is great"; they'll say "this feels nice."
        
        ## The corollary
        
        If you can articulate why a detail matters in one sentence, it's probably not the most valuable detail. The most valuable details are the ones that need three paragraphs to explain *why* the absence is felt. That's why this skill exists — to encode them.
        
        ## When to apply
        
        - When deciding what to do with the last day of a sprint. The features are done; spend it on the unseen.
        - When reviewing a PR that's "functionally complete." The PR is *not* done. Ask what's been tuned.
        - When picking what to deep-dive in a design critique. The obvious problems are obvious. The valuable ones are below the surface.
        
        ## Gotcha
        
        Detail-obsession can become procrastination. There are details that compound and details that don't — taste tells you which is which ([[taste-is-trained]]). If you're spending three days on the loading spinner of a feature that ships once a quarter, you've lost the plot.
        
        ## Sources
        
        - Paul Graham — "Taste for Makers" (the compound-details argument).
        - Emil Kowalski — Core Philosophy in [emilkowalski/skill](https://github.com/emilkowalski/skill).
        - Related: [[taste-is-trained]], [[beauty-is-leverage]], [[delight-impact-curve]].
        
    • sound
      • launch-video-sound.md 5.9 KB
        ---
        title: launch-video-sound
        summary: The launch-video register, measured twice — the sparse logo-reveal (no bed, every hit on a motion peak, true silence) and the OpenAI brand-film (a warm sub-heavy bed that carries the piece, dry clicks 10–20 dB under it on every stepped reveal, silences as punctuation) — with sound maps, numbers, and a timeline recipe.
        tags: [sound, launch-video, marketing, motion-graphics, audio, openai]
        ---
        
        # Launch-video sound
        
        A product launch video inverts the product-UI default. On the product, sound is opt-in and rare; in the video, sound *is* the emotional layer. Two registers read as premium in 2026, and they are not the same thing. Pick one on purpose.
        
        ## Register A — the logo reveal (no bed)
        
        Measured from a 10.7 s logo reveal for Base by bruno (@tvnxty, superfx.co): 32 onsets, every one within two frames of a visual motion peak; holds at −56 dBFS; peak −3.1 dBFS; program average −25 dBFS; no music.
        
        1. **Sound density mirrors motion density.** A fan of cards gets a cluster of ticks, one per card; a single snap gets one hit; a hold gets nothing.
        2. **Holds are true silence.** Not a pad, not room tone.
        3. **Size maps to pitch and length.** Ticks 3–6 kHz for 100–200 ms; the wordmark ~1 kHz for 700 ms.
        4. **Materials, not effects.** Taps, mallets, breath, paper — [[sound-palette]].
        
        Use it for reveals under fifteen seconds, stings, and anything where the silence is the point.
        
        ## Register B — the brand film (bed + clicks)
        
        Measured from OpenAI's *Refreshed.* (110 s, Studio Dumbar/DEPT with the OpenAI design studio, type by Dinamo, 2025) and *Introducing GPT-5* (89 s, 2025), 720p rips analysed frame by frame:
        
        | | Refreshed. | GPT-5 |
        |---|---|---|
        | Integrated loudness / range | −19.1 LUFS · 15 LU | −20.6 LUFS · 9 LU |
        | Energy under 120 Hz / 120–500 Hz | 66 % / 25 % | 67 % / 29 % |
        | Bed root and partials | F1 43 Hz · C2 · F2 · A2 (an F chord) | same key |
        | Bed present | 78 % of runtime | 97 % |
        | Sub-envelope pulse | 0.26 s, weak (12 %) | 0.26 s, 25 % |
        | Hits above the bed | 3.1 / s, centroid 3.5 kHz | 5.1 / s, centroid 4.7 kHz |
        | Hit decay (−10 dB / −20 dB) | 20 ms / 115 ms | 30 ms / 55 ms |
        | Hit level vs the sub at the same moment | level with it | 7 dB under |
        | Hits on a tempo grid | no (phase concentration 0.02) | no (0.09) |
        | Hard cuts | 39, of which 11 in one 2 s glyph flipbook | 11, all in one montage |
        | Frames nearly still | 61 % | 64 % |
        
        What the numbers say:
        
        - **The bed is the material.** A warm, sub-heavy drone in one key, breathing slowly, with a faint quarter-second pulse. It is not a beat: nothing sits on a grid. It carries every cut, so there are no whooshes.
        - **The hits are clicks, and they are quiet.** Dry, 3–5 kHz, ten dB down inside 30 ms, sitting 10–20 dB under the bed's low end. They land on *stepped* reveals — a glyph flicking through variants every seven frames, a sentence streaming in word by word, a table filling cell by cell (210 ms apart in GPT-5) — so they read as the interface's own sound, not as a soundtrack. A big element settling gets a low thud on the bed's root instead.
        - **Silence is punctuation, not the default.** *Refreshed.* opens with nine seconds of near-silence and tiny clicks before the bed arrives, drops to nothing for 0.5–1 s at a time in its breakdown (47–58 s), and pulls the sub out under the photography (79–91 s). GPT-5 pulls the sub — and only the sub — for the "thinks deeply" beat at 32–38 s and again at 61–64 s, keeping the pad. The reveal that follows lands with the sub returning.
        - **Dynamics come from the arc, not from the hits.** The film's loudness moves in acts: quiet open, full section, breakdown, finale, out. Inside an act the level barely moves.
        - **Most frames are still.** The eye is given long holds on type; motion is stepped or slow. Sound density follows: dense while text streams, sparse in the holds.
        
        ## Timeline recipe (either register)
        
        1. **Lock picture first.** Sound is placed on frames; it cannot be placed on frames that move.
        2. **Write the sound map** — one row per visual event with its contact frame and its box on the canvas — before generating anything. [[sound-from-motion]] renders that map deterministically: `bed` for register B, `click` / `thud` / `type` / `flicker` for stepped reveals, `land` / `tick` / `whoosh` for register A.
        3. **Place transients on contact frames**, not file starts — [[sound-motion-sync]].
        4. **In register B, write the bed's arc as gain points** (an act table in dB) and its dropouts (start, length, whether the pad stays). Duck it 3–4 dB under every thud.
        5. **Master to −14 LUFS, −1 dBTP** for social; both OpenAI films sit 5–6 LU under that and let the platform normalize. Check on phone speakers — anything under 200 Hz vanishes there, which is why the clicks carry the sync and the bed carries the feeling.
        
        ## Gotcha
        
        A stock music bed is the AI-default of launch videos because it hides the sync work. The OpenAI bed is not that: it is one drone in one key with no beat, and every click is on a frame. If you cannot place the clicks, cut the bed and use register A — a wrong bed with unsynced hits is the worst of both.
        
        ## Sources
        
        - OpenAI, *Refreshed.* (YouTube k3d_xeVxEOE, 2025-02-04) and *Introducing GPT-5* (boJG84Jcf-4, 2025-08-08) — onset, band-energy, bed-pitch, cut, and motion-stillness analysis by HKTITAN, 2026-09-05.
        - Studio Dumbar/DEPT — OpenAI brand film case study (studiodumbar.com/work/openai-brand-film): "dots and logos animate naturally, interface elements pulse gently, and typography appears intuitively"; sound "drawing from human interactions"; D&AD pencil 2025. Creative Review, *OpenAI's brand refresh subtly signals a new era*.
        - bruno (@tvnxty), superfx.co — Base logo reveal, 2026-09-03; analysis by HKTITAN.
        - Twenty Thousand Hertz, *The Sound of Apple* — organic materials over synthesis.
        - Related: [[sound-from-motion]], [[launch-video-seams]], [[marketing-vs-product-ui]], [[sound-spec]].
        
      • MOC-sound.md 3.8 KB
        ---
        title: MOC-sound
        summary: When an interface should make a sound, how to design a family of sounds that belong together, how to sync them to motion, and how to generate them — ElevenLabs on demand, or open-weight and CC0 without a key.
        tags: [moc, sound, audio, launch-video]
        ---
        
        # MOC — Sound
        
        Sound is the sense the web forgot. That makes it the highest-leverage polish move left — and the easiest one to get wrong, because a bad sound is worse than no sound and users remember it. Read [[sound-decision-framework]] first; it says *no* most of the time. The rest of the cluster is for the moments that earn a sound.
        
        Two surfaces share this cluster and have opposite defaults. **Product UI** is silent by default, opt-in, rare, and tiny. A **launch video** is the reverse: sound is the emotional layer, and silence is a material you place deliberately. [[launch-video-sound]] owns the second; everything else defaults to the first.
        
        ## Decision-first nodes (read before making a sound)
        
        - [[sound-decision-framework]] — Should this make a sound at all? Frequency, purpose, and the 1st-vs-100th-use test. Extends [[delight-impact-curve]] and [[animation-decision-framework]].
        - [[sound-motion-sync]] — Sound and motion are one event. The transient lands on the contact frame, never before it. Measured tolerances.
        
        ## Design nodes (read while designing the family)
        
        - [[sound-palette]] — A product gets one material and a handful of sounds that share it. Size → pitch, direction → meaning, consonance → success, dissonance → error.
        - [[sound-spec]] — The numbers: duration by category, loudness in LUFS, mono, 44.1 kHz, zero leading silence, peak headroom.
        
        ## Implementation nodes
        
        - [[sound-playback-web]] — Unlock the AudioContext on the first gesture, decode once, one source node per play, persisted mute toggle, never sound-only information.
        
        ## Generation nodes
        
        - [[sound-generation-elevenlabs]] — On-demand generation for installers with an `ELEVENLABS_API_KEY`: the prompt formula, high `prompt_influence` for functional sounds, one session per family, post-processing.
        - [[sound-generation-open-source]] — No key: open-weight models (Stable Audio 3 Small-SFX runs on CPU), procedural synthesis (ZzFX, Web Audio), and CC0 libraries (Kenney, soundcn, Freesound). A decision table for which.
        
        ## Launch-video register
        
        - [[launch-video-sound]] — Two measured registers: the sparse logo reveal (no bed, every hit on a motion peak, true silence) and the OpenAI brand film (a sub-heavy bed in F, dry clicks 10–20 dB under it on stepped reveals, silences as punctuation). Numbers from *Refreshed.* and *Introducing GPT-5*.
        - [[sound-from-motion]] — Derive the sound from the motion: size → pitch and decay, x → pan, y → brightness, direction → contour, tween → length, contact frame → transient. The cue sheet and `scripts/sound-sheet.mjs`, which renders a stereo stem from it.
        
        ## Shipped tooling
        
        `scripts/sound-family.mjs` (next to this skill's `SKILL.md`) turns one family manifest into a normalized set of files — ElevenLabs when a key is present, a dependency-free synthesizer when it is not. `scripts/sound-sheet.mjs` renders a launch video's whole stem from a motion cue sheet, and writes the same six product one-shots from the same voices. Spawn [[sound-designer]] when the job is the whole workflow rather than one question.
        
        ## Cross-cluster
        
        - [[interaction-personality]] names sound as a personality lever; this cluster is where it goes deeper.
        - [[prefers-reduced-motion]] has no audio twin in CSS — [[sound-playback-web]] explains why you treat the mute toggle as that twin.
        - [[ai-default-tells]] carries the sound rows: stock library sounds, a beep on every click, a whoosh on every transition.
        - [[review-checklist]] rows 12–13 are the sound gate for any UI review.
        
      • sound-decision-framework.md 4.4 KB
        ---
        title: sound-decision-framework
        summary: Should this make a sound at all? Frequency first, then purpose, then the 100th-use test. The default answer is no — and the exceptions are specific.
        tags: [sound, decision, delight, audio]
        ---
        
        # Sound decision framework
        
        The default is silence. A sound has to earn its place harder than an animation does, because a user can look away from motion but cannot un-hear a chime — and the web offers no system setting that turns your sounds off the way `prefers-reduced-motion` turns motion down. Read this before generating, downloading, or synthesizing anything.
        
        ## The decision tree
        
        1. **How often will the user hear it?** Sound sits one notch *stricter* than the [[delight-impact-curve]]. Every keystroke, every scroll, every hover: **never**. Every primary action (click, select, toggle): **no, unless the product is explicitly tactile** — a wallet, a game, a hardware companion. Daily moments (send, save, complete): **maybe, one short sound**. Rare milestones (first payment, onboarding done): **yes, this is where the budget goes**.
        2. **What is it for?** Apple's audio-haptic principle is *utility*: add feedback only where it communicates something the eye may miss. Valid purposes:
           - **Confirmation when attention is elsewhere** — a message sent while the user has already started typing the next one; a recording that started.
           - **State change that has no visual** — a background upload finishing on another tab; a timer ending.
           - **Physicality** the product is built around — a card snapping into a slot, a crown click.
           - **Brand moments** on marketing surfaces and launch videos, where sound *is* the medium. See [[launch-video-sound]].
        
           Invalid: decoration, "because the animation has no sound", filling the silence of a slow request, or copying a competitor's ping.
        3. **Does it survive the 100th use?** Apple's test from *Designing Audio-Haptic Experiences*: imagine the sound on first use, then on the hundredth. If it would make you reach for the mute, cut it or make it smaller. Hugo Verweij's notification "Rebound" was tuned to be *tolerable at frequency* — that is the bar.
        4. **Is there a visual equivalent?** Sound reinforces; it never carries information alone. If the sound is the only signal, the design is broken for the muted majority. See [[sound-playback-web]].
        5. **Can the user turn it off, and is it off by default?** Product UI sound ships off with a discoverable toggle. Users who turn it on become attached; users who cannot turn it off uninstall.
        
        ## What passes, concretely
        
        | Moment | Sound? | Why |
        |---|---|---|
        | Typing, scrolling, hover | No | Frequency. The keyboard never gets sound. |
        | Button press | No (default) | A press already has visual and haptic feedback; sound here reads as a toy. |
        | Toggle in a tactile product | Small tick, ≤ 80 ms | Physicality is the product's premise. |
        | Message sent, file saved | One short tone, ≤ 250 ms, opt-in | Confirmation when attention has moved on. |
        | Error on submit | Low, short, falling | Attention is needed and the eye may be elsewhere. Pair with [[multi-segment-shake]]. |
        | Payment complete, onboarding done | A designed two- or three-note figure | Rare. Earned. |
        | Notification while the tab is hidden | Yes, once, respect OS focus modes | The whole point of a notification. |
        
        ## When to apply
        
        Any time someone says "let's add a sound", "it needs a satisfying click", or "the OpenAI video has that nice tick". Ask how often, for what, and whether it survives the 100th time. Then, if it passes, go to [[sound-palette]] to design it and [[sound-motion-sync]] to place it.
        
        ## Gotcha
        
        The 100th-use test is asymmetric. A sound that is *slightly* too loud or too long is annoying on the tenth use; a sound that is slightly too quiet or too short is never noticed as a flaw. When in doubt, halve the length and drop 3 dB — the same instinct as "if unsure, halve it" in [[duration-table]].
        
        ## Sources
        
        - Apple, *Designing Audio-Haptic Experiences* (WWDC19, Hugo Verweij & Camille Moussette) — the utility principle and the first-vs-hundredth-use test.
        - Benji Taylor — [[delight-impact-curve]]; Family plays "a satisfying sound effect" on completing a trash action, not on every tap.
        - Josh Comeau, *use-sound* announcement — sound is rare on the web and therefore impactful; ship a mute toggle.
        - Material Design, *Applying sound to UI* — "silence is an important part of the experience."
        - Related: [[animation-decision-framework]], [[interaction-personality]].
        
      • sound-from-motion.md 7.7 KB
        ---
        title: sound-from-motion
        summary: Derive every audio property from the motion it accompanies — pitch and decay from size, pan from position, brightness from height, contour from direction, length from the tween — and, in the brand-film register, a bed whose arc, dropouts, and ducking are written as data next to the timeline. The cue-sheet workflow and the numbers.
        tags: [sound, motion, spatial, launch-video, cue-sheet, stereo, bed]
        ---
        
        # Sound from motion
        
        A library sound placed on a frame is still a guess: it has a pitch, a length, and a position that were decided somewhere else. When the sound *feels wrong* against the animation, it is almost always one of those three disagreeing with what the eye sees — a small chip landing with a low thud, a card on the right edge sounding from the centre, a whoosh that peaks before the element stops. The fix is not a better library. It is to **derive the sound from the motion** and let the picture decide.
        
        ## The mapping
        
        | The motion says | The sound does | Rule of thumb |
        |---|---|---|
        | **Size** (√ of the element's area) | pitch and decay | 60 px → ~5 kHz and 50 ms; a 460 px headline → ~1.1 kHz and 600 ms. `f0 = 5200 · (60 / size)^0.75`, `t60 = 0.1 + size / 460 · 0.5`. Clicks stay in 2.8–5.2 kHz whatever the size: a 20 ms sound has no room for a low fundamental, so a *big* thing gets a thud on the bed's root instead |
        | **x on the canvas** | stereo pan | constant-power, `pan = (x / W − 0.5) · 1.4`, clamped to ±0.7 — never hard left or right; thuds pan at 40 % |
        | **y on the canvas** | brightness of the attack | higher on screen = brighter contact noise; the ear reads height as brightness, not as pitch |
        | **Direction of travel** | contour | leaving upward → the breath sweeps up; sliding in from the left → the tick's pan starts left of where it lands |
        | **Tween duration** | length of the breath | a whoosh starts with the tween and **peaks on the settle**, then dies in 60 ms |
        | **A stepped reveal** | one click per step | a glyph flipbook at seven frames a step, words at 110 ms, table cells at 210 ms — the cadence is the sound |
        | **Level** | six dB per doubling of size | inside a narrow window so the biggest thing is loudest but nothing shouts; clicks sit 10–20 dB under the bed |
        
        Two things stay constant: **one material** for the whole piece ([[sound-palette]]) and a deliberate relationship to silence — true silence in the dry register, silence as punctuation in the bed register ([[launch-video-sound]]).
        
        ## The cue sheet
        
        Write the sound as data next to the timeline, one row per visual event, *before* rendering any audio:
        
        ```json
        { "canvas": { "w": 1920, "h": 1080 }, "fps": 30, "duration": 12,
          "bed": { "root": 43.1, "level": -22, "pad": -31, "in": 0, "out": 11.55,
                   "gainPoints": [ [0, -9], [1.0, -5], [2.9, -2], [5.9, 0], [11.4, 1] ],
                   "dropouts": [ { "t": 6.95, "dur": 0.6, "keep": "pad" } ],
                   "swells":   [ { "t": 11.2, "dur": 0.35, "db": 2.5 } ] },
          "cues": [
            { "id": "s1-glyphs", "kind": "flicker", "t": 0.20, "n": 3,  "x": 810, "y": 480, "w": 300, "h": 132 },
            { "id": "s1-title",  "kind": "thud",    "t": 0.90, "x": 810, "y": 480, "w": 1300, "h": 132 },
            { "id": "s1-sub",    "kind": "type",    "t": 1.30, "n": 13, "every": 0.11, "x": 700, "y": 640, "w": 120, "h": 40, "gain": -8 },
            { "id": "s3-cells",  "kind": "type",    "t": 6.25, "n": 9,  "every": 0.21, "x": 604, "y": 640, "w": 260, "h": 60 },
            { "id": "s3-modal",  "kind": "thud",    "t": 7.51, "x": 1432, "y": 620, "w": 520, "h": 420, "semitones": 7 } ] }
        ```
        
        - `t` is the **contact frame**: tween start + ~85 % of its duration for a decelerating ease (where `power3.out` has visibly stopped), not the tween start and not its mathematical end — see [[sound-motion-sync]]. For `whoosh` and `air`, `t` is the tween start and `dur` its length; the peak lands on `t + dur`. For `type` and `flicker`, `t` is the first step and the run expands into `n` clicks at `every` (a `type` run gets ±8 ms of hand jitter; a `flicker` run is cut-exact).
        - `kind` is the gesture: `click` (a stepped reveal), `thud` (something big settles while the bed is on), `type` / `flicker` (runs), `land` / `tick` (the dry register's mallet and tick), `whoosh` / `air` (travel and overlays), `success` / `error` (a fifth or a minor second on the derived pitch).
        - `bed` is the brand-film register: a drone on `root` (F1 by default) with an octave and a pad on the 4th, 5th, 6th, and 8th partials, a faint 0.26 s pulse, **gainPoints** as the film's act-by-act arc in dB, **dropouts** (with `keep: "pad"` to pull only the sub, the GPT-5 "thinking" move), **swells**, and automatic 3–4 dB ducking under every thud. Omit `bed` for the dry register.
        - Staggers get one cue per member with `semitones` stepping up — the ear hears the count.
        
        `scripts/sound-sheet.mjs cues.json --out stem.wav --report` renders the stereo stem deterministically (no randomness; a fixed seed per cue id), peak-normalized to −1 dBFS, and prints the frame, pitch, length, and pan of every onset so you can check them against the timeline. `--family <dir>` writes six product one-shots from the same voices so the app and its launch video share a material.
        
        ## Workflow
        
        1. **Lock picture.** Cues are placed on frames; frames that move invalidate them.
        2. **Extract the events** from the timeline: every `from`/`to` that translates or scales an element the viewer will notice, and every stepped `set` run. Fades of small labels are not events. Two settles on the same frame are **one** cue — voice the bigger element.
        3. **Measure the boxes.** Centre and size on the composition canvas, from the layout, not from memory.
        4. **Write the arc.** In the bed register, decide where the film opens low, where it fills, where it holds its breath, where it resolves — as gain points and dropouts — before touching a single click.
        5. **Render, read the report, listen once on phone speakers.** Anything under 200 Hz vanishes there; the clicks carry the sync, the bed carries the feeling.
        6. **Place the stem as one clip** at `t = 0`, not as seventy clips. One clip keeps sync exact and keeps the lint quiet.
        7. **Check the onset list** against motion peaks the way [[launch-video-sound]] measures it: every onset within two frames of a visual event, nothing in the holds.
        
        ## When to apply
        
        Launch videos, logo reveals, feature sizzles, product tours — any composition where sound is placed against a timeline. In product UI the same voices apply, but pan does not: UI sound is mono ([[sound-spec]]), because the element's position on a phone is not a position in the room.
        
        ## Gotcha
        
        Deriving is not the same as sonifying. If a scene has forty tweens, it does not get forty sounds; it gets the ones the eye actually tracks. And the bed is not a music track: one drone in one key, no beat, quiet enough that a click at −20 dB under it still reads. The mapping decides *how* a sound behaves; [[sound-decision-framework]] and the density rule in [[launch-video-sound]] decide *whether*.
        
        ## Sources
        
        - HKTITAN — `sound-sheet.mjs` and the demo cue sheet in `docs/demo/hyperframes/assets/sfx/cues.json` (72 onsets from 22 cues, a bed with one dropout, integrated −16.5 LUFS).
        - OpenAI, *Refreshed.* and *Introducing GPT-5* — the bed-and-clicks register, measured; numbers in [[launch-video-sound]].
        - HeyGen, *hyperframes-launches* — every launch ships an audio cue map in `STORYBOARD.md` (film time · cue · source) and an `audio_meta.json`; the sheet above is that table made renderable.
        - ITU-R BT.1359-1 — audio may lag video, never lead; see [[sound-motion-sync]].
        - Blattner, Sumikawa & Greenberg, *Earcons and icons* (1989) — pitch, rhythm, and register as a grammar of families.
        - Related: [[sound-palette]], [[launch-video-seams]], [[stagger-choreography]].
        
      • sound-generation-elevenlabs.md 4.8 KB
        ---
        title: sound-generation-elevenlabs
        summary: Generate a whole sound family on demand with ElevenLabs — material-first prompts, high prompt_influence for functional sounds, short durations, one session per family, then trim and normalize.
        tags: [sound, generation, elevenlabs, audio, tooling]
        ---
        
        # Sound generation with ElevenLabs
        
        For installers with an `ELEVENLABS_API_KEY`, text-to-sound-effects turns a written [[sound-palette]] into files in one pass, and lets you iterate on *words* ("more felt, less glass") instead of on a synth patch. The model is good at materials and bad at abstractions; prompt accordingly.
        
        ## The endpoint
        
        `POST https://api.elevenlabs.io/v1/sound-generation` with header `xi-api-key`.
        
        | Field | Range / default | Use for UI sounds |
        |---|---|---|
        | `text` | required | The material-first prompt below |
        | `duration_seconds` | 0.5–30, default auto | **0.5** for ticks and taps; 0.6–1.0 for tones; never auto for UI |
        | `prompt_influence` | 0–1, default 0.3 | **0.8+** — functional sounds need literal, low-variance output |
        | `loop` | false | Only for ambiences and processing loops |
        | `model_id` | `eleven_text_to_sound_v2` | — |
        | `output_format` | `mp3_44100_128` default | `pcm_44100` if you will post-process; MP3 otherwise |
        
        Each generation costs 200 credits; the free tier's 10,000 credits a month is roughly fifty sounds — enough to generate a six-sound family three or four times over while you tune the prompts. SDKs: `@elevenlabs/elevenlabs-js` (never the deprecated `elevenlabs` npm package) and `elevenlabs` on PyPI.
        
        ## The prompt formula
        
        **Material + action + envelope + mood + frequency hint + purpose.** The model responds to physical descriptions and audio vocabulary (transient, one-shot, dry, muted, no tail), not to UI nouns ("a success sound" produces a stock jingle).
        
        ```text
        tick     → "single very short tick, fingernail on thin plastic, dry, no tail, bright, one-shot UI feedback"
        tap      → "soft muted tap, felt mallet on a small wooden block, dry, clean transient, short body, no reverb"
        send     → "two quick rising notes on a muted kalimba, warm, dry, short decay, confirmation"
        receive  → "two soft descending notes on a glockenspiel, gentle, dry, short, incoming message"
        error    → "short low dull thud with a slight downward pitch, rubber on wood, dry, no tail"
        success  → "three ascending mellow marimba notes, major, warm, dry, short decay, completion"
        ```
        
        Rules that came out of use:
        
        - **Say the material and the *stick*.** "Felt mallet on wood" and "fingernail on plastic" are different sounds; "a click" is a lottery.
        - **Say "dry" and "no tail" every time.** The default has room reverb, and reverb is the first thing that breaks [[sound-spec]].
        - **Give the contour, not the emotion.** "Two rising notes" beats "happy." "Downward pitch" beats "sad."
        - **One session, one material.** Generate the whole family in one sitting with the same material phrase prefixed to every prompt; it is the cheapest way to get sounds that belong together.
        - **Ask for 0.5 s and cut.** The model pads short prompts with air; a 0.5 s request trimmed to 80 ms is cleaner than a 0.1 s request.
        
        ## After generation
        
        Every file still needs [[sound-spec]]: trim leading silence to zero, high-pass at 150 Hz, peak to −3 dBFS, mono. `scripts/sound-family.mjs` does the trim and peak with ffmpeg when it is installed; the high-pass and the LUFS check are a five-second ffmpeg or DAW step.
        
        ## The shipped script
        
        `node scripts/sound-family.mjs family.json --out public/sfx` reads a manifest of `{ material, sounds: { name: { prompt, seconds, synth } } }`, calls ElevenLabs when the key is present (or `--provider synth` to force the offline path from [[sound-generation-open-source]]), writes normalized files and a `manifest.json`. Add `--dry-run` to print the prompts without spending credits.
        
        ## When to apply
        
        An installer has the key and the [[sound-decision-framework]] has said yes to at least one sound. Also for launch videos — the same endpoint produces whooshes, risers, and impacts; see [[launch-video-sound]] for those prompts.
        
        ## Gotcha
        
        Generated files are not deterministic. Regenerating `tick` next sprint produces a *different* tick, and a family with one re-rolled member sounds patched. Commit the WAVs, record the prompts and settings in the manifest, and regenerate the *whole family* when the material changes.
        
        ## Sources
        
        - ElevenLabs API reference, *Create sound effect* — fields, ranges, defaults, output formats.
        - ElevenLabs product guide, *Sound effects* — prompt vocabulary (impact, whoosh, one-shot, loop, drone, braam).
        - ElevenLabs pricing — 200 credits per generation; 10,000 free credits a month.
        - Envato, *UI sound design with AI* — the Action + Material + Mood + Frequency + Purpose formula; prompt-influence bands.
        - Related: [[sound-palette]], [[sound-spec]], [[sound-designer]].
        
      • sound-generation-open-source.md 5.3 KB
        ---
        title: sound-generation-open-source
        summary: No API key needed — open-weight text-to-audio that runs on a CPU, procedural synthesis in under a kilobyte, and CC0 libraries you can ship commercially. A decision table for which path.
        tags: [sound, generation, open-source, synthesis, cc0, audio]
        ---
        
        # Sound generation without a key
        
        Three open paths, each right for a different job. Pick by *what you need the sound to be*, not by what is installed.
        
        | You need… | Path | Cost |
        |---|---|---|
        | A bespoke material that matches the brand | **Open-weight model** — Stable Audio 3 Small-SFX | A few minutes of CPU per family |
        | Sounds now, zero assets, tweakable in code | **Procedural synth** — Web Audio or ZzFX | 0 bytes of audio, < 1 KB of JS |
        | Safe, decent, shipping today | **CC0 library** — Kenney, soundcn, Freesound | One command |
        
        ## Open-weight models
        
        - **Stable Audio 3 Small-SFX** (`stabilityai/stable-audio-3-small-sfx`, ~460 M parameters) — Stability's dedicated sound-effects model, sized for CPU inference, up to two minutes per clip. Stability AI Community License: free for research and commercial use under US $1 M annual revenue; above that, an enterprise license. The right default in 2026.
        - **Stable Audio Open Small** (`stabilityai/stable-audio-open-small`, ~340 M) — the earlier 11-second model, Arm-optimized; generates ten seconds of audio in about seven seconds on a phone-class CPU. Same license.
        - **Meta AudioGen** (AudioCraft) — code is MIT, but the weights are CC BY-NC 4.0. Fine for a prototype, not for a shipped product.
        
        ```python
        from stable_audio_tools import get_pretrained_model
        from stable_audio_tools.inference.generation import generate_diffusion_cond
        model, cfg = get_pretrained_model("stabilityai/stable-audio-3-small-sfx")
        audio = generate_diffusion_cond(model, steps=8, device="cpu",
            conditioning=[{"prompt": "single very short tick, fingernail on thin plastic, dry, no tail",
                           "seconds_total": 1}])
        ```
        
        Prompt exactly as in [[sound-generation-elevenlabs]] — material first, "dry, no tail" always — then post-process per [[sound-spec]].
        
        ## Procedural synthesis
        
        For ticks, taps, pops, and two-note tones, a synthesizer beats any model: the sound is deterministic, weighs nothing, and its pitch and length are *parameters* you can tie to the UI (a taller card, a lower thud). `scripts/sound-family.mjs --provider synth` ships six archetypes — `tick`, `tap`, `chime`, `thud`, `pop`, `whoosh` — and writes WAVs from the same manifest the ElevenLabs path uses, so a team without a key gets a coherent family, not silence.
        
        In the browser, the same idea in eight lines is often enough:
        
        ```ts
        // A tick: 60 ms of high sine with an exponential decay. No file, no fetch.
        function tick(ctx: AudioContext, freq = 2400, ms = 60) {
          const o = ctx.createOscillator(), g = ctx.createGain(), t = ctx.currentTime;
          o.frequency.value = freq;
          g.gain.setValueAtTime(0.5, t);
          g.gain.exponentialRampToValueAtTime(0.001, t + ms / 1000);
          o.connect(g).connect(ctx.destination); o.start(t); o.stop(t + ms / 1000);
        }
        ```
        
        **ZzFX** (MIT, under 1 KB) is the mature version of that idea — twenty parameters covering waveform, envelope, pitch sweep, modulation, and filter — with a visual designer that exports one-liners. Its idiom is 8-bit; tame the square waves and shorten everything for product UI. **jsfxr** is the browser port of the classic sfxr and lives in the same register.
        
        ## CC0 libraries
        
        - **Kenney** — *Interface Sounds* (100 files) and *UI Audio* (50 files), CC0, the most-used free UI sets in existence. Which is also the problem: they are recognizable. Use them as a starting family and re-pitch or layer, don't ship them raw.
        - **soundcn** — 700+ short sounds packaged for the shadcn CLI: `npx shadcn add @soundcn/click-soft` drops a base64-inlined TypeScript module and a `useSound` hook into your repo. Mostly Kenney-derived CC0; skip its World of Warcraft pack, which is non-commercial.
        - **Freesound** — APIv2 with `filter=license:"Creative Commons 0"`; token auth for search, OAuth2 for download. The largest pool and the most uneven; budget time to audition.
        
        Whatever the source, the [[sound-palette]] rule still applies: one material. A CC0 grab-bag is the audio version of the Lucide-icon tell in [[ai-default-tells]].
        
        ## When to apply
        
        Any time an installer wants sound and has no ElevenLabs key — or has one and wants deterministic, parameter-driven sounds. `sound-family.mjs` chooses automatically: key present → ElevenLabs, absent → synth.
        
        ## Gotcha
        
        "Open source" and "commercial use" are not the same claim. AudioGen weights are non-commercial; Stability's license flips at US $1 M revenue; soundcn mixes CC0 with a proprietary pack. Record the license next to every file in the manifest, and re-check it before a launch.
        
        ## Sources
        
        - Stability AI — Stable Audio 3 family (May 2026), Stable Audio Open Small model card, Community License terms.
        - Arm, *Running Stable Audio Open Small with KleidiAI* — on-device timing.
        - Meta, AudioCraft repository — MIT code, CC BY-NC 4.0 weights.
        - KilledByAPixel, *ZzFX* — parameters, size, license; chr15m, *jsfxr*.
        - Kenney — Interface Sounds and UI Audio packs (CC0); kapishdima, *soundcn*; Freesound APIv2 docs.
        - Related: [[sound-generation-elevenlabs]], [[sound-playback-web]], [[dependency-discipline]].
        
      • sound-motion-sync.md 4.4 KB
        ---
        title: sound-motion-sync
        summary: Sound and motion are one event. The transient lands on the contact frame — the settle, not the start — and audio may lag by a frame but must never lead.
        tags: [sound, motion, sync, audio, timing]
        ---
        
        # Sound–motion sync
        
        A sound that is off by two frames reads as *broken*, not as *slightly off*. Apple calls the principle *harmony*: things should feel the way they look and sound, and in software you have to build that agreement by hand because there is no physics doing it for you. The rule that produces harmony is simple: **the transient of the sound sits on the frame where the visual makes contact** — the card lands, the toggle seats, the wordmark appears — not on the frame where motion begins.
        
        ## The measured version
        
        A 10.7 s logo reveal by sound designer bruno (@tvnxty, superfx.co) was analyzed frame-by-frame against its audio. Thirty-two audio onsets; every one of them within two frames (≤ 67 ms at 30 fps) of a visual motion peak, most within one. Where a fan of small cards shuffled, there was a cluster of ticks, one per card movement. Where the wordmark cut in, one hit, exactly on the cut frame. Where nothing moved, the track sat at −56 dBFS — real silence, not a bed.
        
        That is what "synced precisely" means in practice: **sound density mirrors motion density**, and each transient is placed on a specific frame.
        
        ## Tolerances
        
        ITU-R BT.1359 gives the human thresholds for sound-vs-picture timing:
        
        | | Audio leads video | Audio lags video |
        |---|---|---|
        | Detectable | +45 ms | −125 ms |
        | Acceptable | +90 ms | −185 ms |
        
        The asymmetry is the instruction. We tolerate sound arriving *after* the visual (it is how the physical world works: light is faster) but notice sound arriving *before* it almost immediately. So:
        
        - **Never let the sound lead.** If the audio pipeline has latency you cannot remove, trigger the sound from the animation's *start* only when the animation is shorter than that latency.
        - **Aim for 0–30 ms of lag.** One frame at 30 fps. Beyond ~45 ms lag on a short, sharp visual (a snap, a click) it begins to feel like an echo.
        - **Long sounds sit on the settle.** A whoosh peaks at the moment the element stops, so its attack starts *before* the settle and its loudest point lands on it.
        
        ## In UI code
        
        Trigger from the same event that starts the state change, and use the animation's own timing to place the transient on the settle when the sound is meant to confirm rather than accompany:
        
        ```ts
        // Sound confirms the landing, so it fires at the end of the enter transition.
        el.addEventListener("transitionend", (e) => {
          if (e.propertyName === "transform") play("land");
        }, { once: true });
        
        // A tick that accompanies a toggle fires with the state change itself.
        toggle.addEventListener("change", () => play("tick"));
        ```
        
        Trim leading silence to zero on every file so `play()` and the transient are the same instant — see [[sound-spec]]. Preload and decode before the interaction so there is no fetch in the path — see [[sound-playback-web]].
        
        ## In a video timeline
        
        Place the file so its **transient**, not its first sample, lands on the contact frame; most library files carry a few frames of near-silence before the hit, and lining up the file start is why an effect that looks right still reads late. In Remotion that is `<Audio startFrom>` with the offset corrected for the file's pre-roll; in HyperFrames it is the clip's `data-start` on the settle frame. [[launch-video-sound]] has the full authoring pattern.
        
        ## When to apply
        
        Every time a sound and an animation describe the same event. If they don't describe the same event, one of them should probably go — see [[sound-decision-framework]].
        
        ## Gotcha
        
        Springs never settle at a clean frame — they overshoot and oscillate. Sync the sound to the *first* crossing of the rest position (the visual "contact"), not to the spring's mathematical end. Firing on `animationend` of a spring lands the sound 100–200 ms late.
        
        ## Sources
        
        - Apple, *Designing Audio-Haptic Experiences* (WWDC19) — the harmony principle; "adding latency between visual and feedback completely breaks the illusion."
        - ITU-R BT.1359-1, *Relative timing of sound and vision for broadcasting* — detectability +45/−125 ms, acceptability +90/−185 ms.
        - bruno (@tvnxty), logo reveal for Base, 2026-09-03 — onset-vs-motion analysis by HKTITAN; see [[launch-video-sound]] for the sound map.
        - Related: [[spring-animations]], [[responsive-feedback]].
        
      • sound-palette.md 4.4 KB
        ---
        title: sound-palette
        summary: One material, a handful of sounds. Size maps to pitch and length, direction maps to meaning, consonance to success and dissonance to error. Stock packs are tells.
        tags: [sound, palette, earcon, audio, design-system]
        ---
        
        # Sound palette
        
        A product's sounds are a type family, not a clip-art folder. Pick **one material** — a struck wooden bar, a soft plastic tap, a breath of filtered noise, a muted glockenspiel — and derive every sound from it, the way a palette derives every tint from one hue in [[color-monochromatic]]. Five sounds that share a material feel like a product. Five sounds from five packs feel like a browser toolbar from 2003.
        
        ## The grammar
        
        Earcons (Blattner, Sumikawa & Greenberg, 1989) are learned, not mimetic: meaning comes from *contour* and *interval*, and users learn it in a handful of exposures.
        
        | Dimension | Rule | Why |
        |---|---|---|
        | **Size → pitch and length** | Small elements tick high and short (2–6 kHz centroid, 40–120 ms). Big elements land low and long (0.8–1.5 kHz, 300–800 ms). | Physics. A card is lighter than a wordmark; the ear knows. |
        | **Direction → meaning** | Rising figure = success, open, join. Falling = dismiss, leave, error. | Discord's join/leave pair; every OS you have used. |
        | **Interval → valence** | Major third or perfect fifth for confirmation. Minor second or tritone for error. | Consonance reads as safe; dissonance as tense (IWC study on earcon dissonance and perceived hazard). |
        | **Complexity → priority** | Simple, single-hit for frequent actions. Multi-note only for rare or urgent moments. | Frequent complex sounds fatigue in a day (Toptal). |
        | **Repeat → variation** | Rapid repeats step pitch up (`playbackRate` +5–10% per hit) or randomize ±3%. | Identical repeats read as a machine; Josh Comeau's "glug" pattern. |
        
        ## A minimal family
        
        Most products need **four to six** sounds, not forty:
        
        | Name | Role | Shape |
        |---|---|---|
        | `tick` | Toggle, select, snap | Single transient, ≤ 80 ms, high |
        | `tap` | Primary press in tactile products only | Dull transient with a short body, ≤ 120 ms |
        | `send` | Send / save / complete | One or two notes rising, ≤ 250 ms |
        | `receive` | Incoming message / notification | Two notes, lower than `send`, ≤ 400 ms |
        | `error` | Rejected submit, invalid drop | Low, falling, ≤ 200 ms — paired with [[multi-segment-shake]] |
        | `success` | Rare milestone | Three-note figure in the family's key, ≤ 800 ms |
        
        Write the family down as a manifest before generating anything — `scripts/sound-family.mjs` next to `SKILL.md` takes exactly this shape and produces the files via [[sound-generation-elevenlabs]] or the built-in synth from [[sound-generation-open-source]].
        
        ## Material, not genre
        
        Apple's sound team records real instruments — glockenspiel, kalimba, marimba — and even struck a hollow Apple Watch case with mallets to find its resonance. "Organic over futuristic" is the stated reason: familiar materials make new technology comfortable. The AI-default failure is the opposite — a glossy sci-fi "digital UI blip" that belongs to no product. Describe a *thing* (soft felt mallet on a wooden bar; a fingernail on glass; a breath through a paper cone), never a *style*.
        
        ## When to apply
        
        Before generating or downloading a single file. Decide the material, list the family, then produce. Reviewing a product with sounds: ask whether they share a material; if not, that is the finding.
        
        ## Gotcha
        
        Do not tune the family in isolation with headphones at midnight. Laptop speakers have nothing below ~200 Hz, so a warm low thud disappears and a bright tick becomes a needle. Design in mono, check on the worst speaker in the office, then set levels per [[sound-spec]].
        
        ## Sources
        
        - Blattner, Sumikawa & Greenberg (1989), *Earcons and icons* — structured families of tones whose contour carries meaning.
        - *Perceived Hazard of Earcons in IT Exception Messages* (Interacting with Computers, 2013) — consonance/dissonance and pitch drive perceived urgency.
        - Twenty Thousand Hertz, *The Sound of Apple* (Hugo Verweij) — real instruments, organic over sci-fi, the Watch case experiment.
        - Toptal, *A Quick Guide to Designing UX Sounds* — simple for frequent, complex for high-priority; mid-range frequencies.
        - Josh Comeau, *use-sound* — rising `playbackRate` on repeats.
        - Related: [[color-monochromatic]], [[typography-humanity]], [[ai-default-tells]].
        
      • sound-playback-web.md 5.2 KB
        ---
        title: sound-playback-web
        summary: Unlock the AudioContext on the first gesture, decode once, one source node per play, a persisted mute that is off by default, and never information carried by sound alone.
        tags: [sound, web-audio, playback, accessibility, audio]
        ---
        
        # Sound playback on the web
        
        Browsers block audio until the user has interacted with the page, and they are right to. Build playback around that fact instead of fighting it: create the context lazily, resume it inside the first real gesture, preload and decode every sound before it is needed, and play each one from a fresh source node so latency is effectively zero. Sixty lines, no library — or `use-sound` if you want the hook.
        
        ## The pattern
        
        ```ts
        // sounds.ts — one context, decoded buffers, zero-latency play.
        const files = { tick: "/sfx/tick.mp3", send: "/sfx/send.mp3", error: "/sfx/error.mp3" };
        type Name = keyof typeof files;
        
        let ctx: AudioContext | null = null;
        const buffers = new Map<Name, AudioBuffer>();
        let enabled = localStorage.getItem("sound") === "on";   // off by default
        
        async function unlock() {
          ctx ??= new AudioContext();
          if (ctx.state === "suspended") await ctx.resume();     // must run inside a user gesture
          await Promise.all(Object.entries(files).map(async ([name, url]) => {
            if (buffers.has(name as Name)) return;
            const data = await fetch(url).then((r) => r.arrayBuffer());
            buffers.set(name as Name, await ctx!.decodeAudioData(data));
          }));
        }
        // Unlock on the first pointer or key event, once, before any sound is wanted.
        addEventListener("pointerdown", unlock, { once: true });
        addEventListener("keydown", unlock, { once: true });
        
        export function play(name: Name, { rate = 1, gain = 1 } = {}) {
          if (!enabled || !ctx || ctx.state !== "running") return;
          const buf = buffers.get(name); if (!buf) return;
          const src = ctx.createBufferSource(); src.buffer = buf; src.playbackRate.value = rate;
          const g = ctx.createGain(); g.gain.value = gain;
          src.connect(g).connect(ctx.destination); src.start();  // new node per play — they are cheap
        }
        
        export function setSound(on: boolean) {
          enabled = on; localStorage.setItem("sound", on ? "on" : "off");
          if (on) play("tick");                                   // confirm the toggle with itself
        }
        ```
        
        `AudioBufferSourceNode` is the only web primitive with no start-up latency; `<audio>` elements and `HTMLMediaElement.play()` add tens of milliseconds and fail the sync rule in [[sound-motion-sync]]. If you prefer a hook, `use-sound` (≈1 KB over Howler) exposes `volume`, `playbackRate`, `interrupt`, `soundEnabled`, and `sprite` — the same shape as above.
        
        ## The five rules
        
        1. **Off by default, with a toggle the user can find.** A visible control in settings *and* reachable by keyboard; state persisted across sessions. Josh Comeau's rule, and the reason sound on the web is trusted at all.
        2. **No sound-only information.** Every sound has a visual twin — the state change, the toast, the shake. Muted users, screen-reader users, and everyone in an open office are the majority.
        3. **No autoplay, no sound on page load.** Even after unlock, a sound the user did not cause is a notification, and notifications need permission.
        4. **Respect the platform's silence.** On iOS the Ring/Silent switch mutes "nonessential" audio — keyboard clicks, effects, feedback — and your sounds are nonessential. Native: use the `ambient` audio session category so you mix with, and yield to, the user's music. Web: don't fight `document.hidden`; a hidden tab's UI sounds should not play.
        5. **Treat the mute toggle as the `prefers-reduced-motion` of audio.** CSS has no audio media query. The closest signals are the user's toggle, OS focus modes on native, and — as a weak hint — `prefers-reduced-motion: reduce`, which correlates with users who want less stimulus. When it is set, default the toggle off *and* skip the onboarding "turn on sound?" prompt.
        
        ## Screen readers
        
        Sounds fired on focus or on every list item collide with the reader's own speech. Never attach sound to `focus`, `focusin`, or roving tab-index movement. Confirmation sounds on explicit actions (send, delete) are fine, and should be shorter than the reader's announcement of the same event.
        
        ## When to apply
        
        Any PR that adds an audio file or an `AudioContext`. Check for the unlock, the toggle, the visual twin, and the absence of `focus` handlers — [[review-checklist]] rows 12 and 13.
        
        ## Gotcha
        
        Calling `new AudioContext()` at module load "works" in Chrome — it just starts suspended and every `play()` silently drops until something resumes it. The symptom is "sound works on the second click." Create and resume inside the gesture, or resume in the handler before playing.
        
        ## Sources
        
        - Chrome for Developers, *Autoplay policy* and *Web Audio, autoplay policy and games* — suspended contexts, `resume()` after user activation.
        - MDN, Web Audio API — `decodeAudioData`, `AudioBufferSourceNode` semantics.
        - Josh Comeau, *use-sound* — mute toggle, persistence, never sound-only, the hook API.
        - Apple Human Interface Guidelines, *Playing audio* — silent switch, audio session categories; *Feedback* — provide feedback through color, text, sound, and haptics so people can receive it in the way that works for them.
        - Related: [[accessibility-baseline]], [[prefers-reduced-motion]].
        
      • sound-spec.md 4.1 KB
        ---
        title: sound-spec
        summary: The numbers for a shipped UI sound — duration by category, loudness in LUFS, peak headroom, mono, 44.1 kHz, zero leading silence, file format per target.
        tags: [sound, spec, loudness, audio, format]
        ---
        
        # Sound spec
        
        A UI sound is a tiny file with strict numbers. Most "this sound feels wrong" complaints are a spec miss — too long for its animation, too loud next to speech, or ten milliseconds of silence before the hit — not a taste miss. Fix the spec first.
        
        ## Duration
        
        | Category | Duration | Rule |
        |---|---|---|
        | Tick / toggle / snap | 40–80 ms | Finishes before the eye has registered the state change |
        | Tap / press | 80–150 ms | Only in tactile products — see [[sound-decision-framework]] |
        | Hover | 0 ms | Hover is silent. If a design insists, 120–200 ms and off by default |
        | Send / save / confirm | 150–250 ms | One or two notes |
        | Notification / receive | 200–600 ms | Longer because attention is being *requested* |
        | Error | 100–200 ms | Short and low; the shake carries the rest |
        | Transition (video / marketing) | 300–1000 ms | Product UI never gets these |
        | Brand stinger | 1–3 s | Launch video, onboarding hero, once |
        
        The Toptal rule ties duration to motion: **a sound should not last more than ~300 ms longer than the animation it accompanies.** A 120 ms press with an 800 ms tail is a bell, not feedback. Match the [[duration-table]].
        
        ## Loudness
        
        | Sound | Integrated loudness | Notes |
        |---|---|---|
        | Taps, ticks, toggles | −18 to −14 LUFS | Below speech, above silence |
        | Notifications | ≈ −12 LUFS | The one class allowed to be louder |
        | Launch-video master | −14 LUFS, −1 dBTP | Social platforms normalize here |
        
        Peak every file at **−3 dBFS** (−1 dBTP for delivered video). Set the *design* level in the file and the *user* level in the mixer — never bake "a bit quieter" into the asset. Interface sounds stay subordinate to speech and media; convey urgency with timbre and rhythm, not volume.
        
        Integrated LUFS is unstable on files under ~400 ms — for ticks and taps, match by ear against a reference tick at −16 LUFS and check the peak, rather than trusting the meter.
        
        ## Format
        
        - **Mono.** Phones, laptops, and Bluetooth speakers sum to mono anyway; stereo UI sounds phase-smear and double the bytes.
        - **44.1 kHz, 16-bit WAV** as the source of truth. Ship **MP3 (128 kbps)** for universal playback or **Opus/OGG** for smaller web bundles; keep WAV for native and for video timelines.
        - **Zero leading silence.** Trim to the first sample above −60 dBFS. Five milliseconds of pre-roll is a visible sync error — see [[sound-motion-sync]].
        - **Short tail, then cut.** Fade the last 10 ms to avoid a click; do not let reverb ride 500 ms past the event.
        - **Sprite when there are many.** One decoded buffer, offsets per sound; `use-sound` and Howler support `sprite` maps.
        
        ## Frequency shaping
        
        - High-pass at 150–200 Hz. Nothing in that band survives a laptop speaker; it only eats headroom.
        - Tame 3–6 kHz by a few dB if the sound is "needly" — that is where small speakers resonate.
        - Keep the body in the mid-range (500 Hz–4 kHz), where every device is honest and where the voice lives.
        
        ## When to apply
        
        Post-processing every generated or downloaded file, and reviewing any PR that adds an audio asset. `scripts/sound-family.mjs` applies the trim and peak steps automatically; the LUFS check is yours.
        
        ## Gotcha
        
        Loudness normalization tools (`loudnorm`, streaming platforms) measure *integrated* loudness across the whole file. A sound that is 60 ms of hit and 600 ms of quiet tail will be normalized *up* until the hit clips. Trim the tail first, normalize second.
        
        ## Sources
        
        - Envato, *UI sound design with AI* — LUFS targets by class, duration bands, mono, the 0–5 ms trim.
        - Toptal, *A Quick Guide to Designing UX Sounds* — the 300 ms-over-animation rule; mid-range guidance.
        - uisfx.com, *UI Sound Design* — subordinate to speech, urgency by timbre not volume, one-shots vs loops.
        - EBU R 128 / streaming practice — −14 LUFS, −1 dBTP for delivered social video.
        - Related: [[duration-table]], [[sound-palette]].
        
    • surface
      • border-radius.md 2.4 KB
        ---
        title: border-radius
        summary: Uniform radius everywhere is lazy. Capsule buttons need full package. Nested-radius rule.
        tags: [surface, border-radius]
        ---
        
        # Border radius
        
        Border radius is a personality dial. A uniform radius across an entire UI is lazy; an intentional scale signals craft.
        
        ## The scale
        
        ```css
        --radius-sm: 4px;   /* badges, micro-elements */
        --radius-md: 8px;   /* inputs, small buttons */
        --radius-lg: 12px;  /* cards, larger buttons */
        --radius-xl: 16px;  /* modals, large surfaces */
        --radius-2xl: 24px; /* hero cards, marketing */
        --radius-full: 9999px; /* pills, avatars */
        ```
        
        Pick one as your **default body radius** (usually `lg` for product UI) and use the others deliberately.
        
        ## The capsule-button rule
        
        If you use `--radius-full` on a button (pill button), the **inset elements must also be pill-radius**, not square. Common mistake:
        
        ```css
        /* Wrong — pill button with rectangular inset icon */
        .button { border-radius: 9999px; }
        .button-icon { border-radius: 0; }
        
        /* Right — pill commits to capsule everywhere */
        .button { border-radius: 9999px; padding: 8px 16px; }
        .button-icon { border-radius: 9999px; }
        ```
        
        ## The nested-radius rule
        
        When a rounded element contains another rounded element, the inner radius should be **smaller** than the outer radius, ideally by the gap between them:
        
        ```
        outer_radius - gap = inner_radius
        ```
        
        ```css
        .card {
          border-radius: 16px;
          padding: 8px;
        }
        .card-content {
          border-radius: 8px; /* 16 - 8 = 8 */
        }
        ```
        
        Visually, this makes the two radii feel parallel. Equal radii (both 16px) looks subtly wrong; the inner radius reads as overlapping the outer one.
        
        ## Mixing radii
        
        Mixing `sm` and `xl` in the same screen is fine — even encouraged for hierarchy. But mixing `sm` (4px) and `md` (8px) on adjacent elements looks like a mistake. Use either same or noticeably different.
        
        ## When to apply
        
        - Building a tokens file: define the scale up front.
        - Reviewing a button or card design: check the nested-radius math.
        - Reviewing a pill-button design: check the inset icons.
        
        ## Gotcha
        
        `border-radius` larger than half the element's smaller dimension behaves the same as `border-radius: 50%`. So `border-radius: 9999px` on a small button is functionally a capsule. Useful, but be intentional about it.
        
        ## Sources
        
        - guidelines.sh — uniform radius is lazy, nested-radius rules.
        - Linear, Vercel — capsule-button discipline.
        - Tailwind — radius scale conventions.
        
      • color-monochromatic.md 2.5 KB
        ---
        title: color-monochromatic
        summary: Monochromatic with one or two accents beats a five-color palette. Why bright/purple gradients scream AI.
        tags: [color, surface]
        ---
        
        # Monochromatic palettes
        
        A monochromatic palette — one hue across a wide range of lightness values, plus one or two true accent colors — almost always looks more considered than a five-color palette assembled from "what feels good."
        
        ## What to do
        
        1. Pick **one neutral hue** as your base. Pure gray is fine. A subtle warm gray (slight yellow undertone) or cool gray (slight blue undertone) is better.
        2. Generate **8–12 steps** of that hue from near-white to near-black. Tailwind's `slate`, `zinc`, `stone`, `gray`, and `neutral` are good starting points. Linear, Vercel, Notion all use this approach.
        3. Pick **one true accent** for primary actions. Saturated, intentional. Don't dilute it across the UI — earn its use.
        4. Optionally pick **one second accent** for warnings or success states. These should be subdued, not loud.
        
        That's it. The whole palette is ~14 tokens. Most "design systems" with 60 color tokens are overcomplicating.
        
        ## What to avoid
        
        - **Bright purple gradients** (any of the `purple → indigo → fuchsia` variants). Universal AI tell.
        - **Five-color brand palettes** with no hierarchy. Every color competes for attention; the eye finds nothing to land on.
        - **Adjacent hues without intent.** Red + orange + yellow with no narrative reason is muddy.
        - **High saturation everywhere.** Monochrome at 5–10% saturation reads as restrained and confident.
        
        ## When to apply
        
        - Designing a new product UI from scratch.
        - Refactoring a tokens file that has accumulated 60+ colors.
        - Reviewing a landing page or PR where the team has added a new accent for "emphasis."
        
        ## Why this works
        
        The eye can only track 2–3 colors at once. A monochrome palette gives the user a single tonal language to read; the one accent stands out by *being* the deviation. A multi-color palette dilutes attention — there's nothing to deviate *from*.
        
        ## Gotcha
        
        "Monochromatic" doesn't mean "boring." A monochrome palette with rich texture, considered typography (see [[typography-humanity]]), and tight motion (see [[duration-table]]) feels luxurious. A multi-color palette with bad type and bad motion feels cluttered.
        
        See also [[dark-mode]] for the dark-mode variant of these tokens, [[ai-default-tells]] for what to remove.
        
        ## Sources
        
        - guidelines.sh — "Monochromatic with subtle accents; avoid bright/purple gradients."
        - Linear, Vercel, Notion — examples of monochrome-with-one-accent in production.
        
      • color-scales-oklch.md 4.2 KB
        ---
        title: color-scales-oklch
        summary: Author in OKLCH. One hue per ramp, L anchored 0.97→0.22 and spaced evenly, chroma as a curve clamped per step, siblings at the same fraction of their own ceiling, dark mode by remapping steps, contrast repaired by moving L only.
        tags: [surface, color, oklch, palette, contrast, dark-mode]
        ---
        
        # Color scales in OKLCH
        
        An `oklch()` value is a measurement, not a recipe: L is how bright the color actually looks, C how vivid, H where it sits. Because the numbers track perception, palette math, contrast repair, and dark mode become arithmetic. Everything new is authored in OKLCH; hex survives only where a third-party consumer parses hex. This node builds the ramps that [[color-monochromatic]] asks for and feeds the checks in [[contrast-and-color-scheme]].
        
        ## Reading a value
        
        `oklch(0.586 0.222 17.6)` — L 0–1, C from 0 (gray) to ~0.4 at the extreme, H 0–360 (≈20 red, 90 yellow, 145 green, 195 cyan, 260 blue, 330 pink). Alpha goes after a slash, never as a fourth comma argument. Three decimals is plenty.
        
        ## Building a ramp
        
        1. **Anchor L and space evenly.** Step 50 ≈ L 0.97, step 950 ≈ L 0.22, linear between. Even L spacing is the point: every adjacent pair looks equally far apart. Never run to L 0 or 1; chroma collapses there and the ends stop reading as the hue.
        2. **Shape chroma as a curve.** Express each step's C as a fraction of that step's sRGB ceiling: ~30% at the extremes rising to ~85% around 400–500. Tints stay airy, shades stay rich, nothing leaves the gamut.
        3. **Clamp per step.** The ceiling `maxC(L, H)` moves with lightness, so a saturated base necessarily loses absolute chroma toward both ends. That is the gamut's geometry, not a defect.
        4. **Hue is fixed for all eleven steps.** Converting an inherited scale and finding more than ~10° of hue spread end to end means it drifts visibly; rebuild it on the mid-tone hue.
        
        ```css
        --berry-50:  oklch(0.97 0.007 330);
        --berry-400: oklch(0.67 0.251 330);   /* chroma peaks where this hue's gamut is widest */
        --berry-950: oklch(0.22 0.03 330);
        ```
        
        ## Families of hues
        
        Brand + success + warning feel like one system when each `-500` sits at the **same L** and each hue takes the **same fraction of its own ceiling**, never the same absolute C. Cyan is cramped at every lightness; violet is roomy. One copied C value over-saturates the roomy hues and flattens the tight ones.
        
        ## Dark mode is a remap
        
        Keep one scale and swap which steps the roles point at: `--surface` 50 ↔ 950, `--ink` 950 ↔ 50, 200 ↔ 800, 300 ↔ 700. Perceptually linear L keeps the pairs as distinct after the flip as before. Never hand-pick a second palette. Desaturate brand accents 20–30% if they vibrate on dark, and keep the top canvas the lightest dark surface — see [[dark-mode]].
        
        ## Contrast lives in the L gap
        
        Chroma and hue barely move contrast. To repair a failing pair, push the foreground's L away from the background's L and leave C and H alone; raising saturation "to make it pop" does nothing. Fast triage: a surface with L > 0.6 wants dark text; near-white grounds (L ≥ 0.85) want foreground L ≤ 0.45; near-black grounds (L ≤ 0.25) want L ≥ 0.75. Then run the real check: APCA |Lc| ≥ 60 for body (75 comfortable), ≥ 45 large, ≥ 30 non-text; WCAG 2 4.5:1 body, 3:1 large, when compliance language names it.
        
        ## Gamut and P3
        
        Syntactically valid values can exceed what sRGB shows. Clamp C holding L and H, and layer wide-gamut chroma behind `@media (color-gamut: p3)` on top of the sRGB-safe base. Tailwind v4 is authored in OKLCH; custom `@theme` scales should be too, and `/50` opacity suffixes compose out of the box.
        
        ## When to apply
        
        Any new palette, any hex → OKLCH migration (swap values only; keep keywords, gradient shape, and hex-expecting config), any "is this readable" question, any dark theme.
        
        ## Gotcha
        
        HSL's lightness is decorative — `hsl(55 100% 50%)` is nearly white-bright and `hsl(255 100% 50%)` is dark. A ramp spaced on HSL's L inherits that lie and its tints slide toward violet. Never generate scales in HSL.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on color, distilled by HKTITAN.
        - APCA (Myndex) and WCAG 2 contrast thresholds; CSS Color Level 4 `oklch()`.
        - Related: [[color-monochromatic]], [[contrast-and-color-scheme]], [[dark-mode]].
        
      • contrast-and-color-scheme.md 4.1 KB
        ---
        title: contrast-and-color-scheme
        summary: APCA over WCAG 2 for contrast math. theme-color and color-scheme meta. Interactions raise contrast. Avoid gradient banding.
        tags: [surface, color, contrast, dark-mode, accessibility]
        ---
        
        # Contrast and color-scheme
        
        The contrast and theme-integration choices that decide whether dark mode actually works and whether browser chrome blends in.
        
        Distinct from [[color-monochromatic]] (which defines the *palette*) and [[dark-mode]] (which defines *specific colors*) — this node is about contrast math and browser integration.
        
        ## APCA over WCAG 2
        
        The WCAG 2 contrast formula (the 4.5:1 / 3:1 ratios) is a 1980s model and badly approximates real perception. **APCA** (Accessible Perceptual Contrast Algorithm, the WCAG 3 candidate) is the modern alternative.
        
        ### APCA thresholds
        
        | Use | Lc (lightness contrast) |
        |---|---|
        | Body text | 75+ |
        | Large text / UI text | 60+ |
        | Non-text UI / icons | 45+ |
        | Disabled states | 30+ (floor, not target) |
        
        APCA is signed (positive for dark-on-light, negative for light-on-dark) and the threshold magnitudes are the same in both directions. Tools: [APCA Contrast Calculator](https://www.myndex.com/APCA/), `apca-w3` npm package.
        
        WCAG 2 still gets you legal compliance in most jurisdictions. APCA gets you UI that actually reads. Run both; pass both.
        
        ## Interactions raise contrast, not lower
        
        A hover state should make the element *more* contrasty, not less. A pressed state should be *more* contrasty than hover. Disabled is the only state that lowers contrast.
        
        ```css
        .button {
          background: var(--bg-elevated);     /* baseline */
          border-color: var(--border-default);
        }
        .button:hover {
          background: var(--bg-elevated-hover);  /* slightly more contrast */
          border-color: var(--border-strong);
        }
        .button:active {
          background: var(--bg-elevated-active); /* even more */
        }
        .button:disabled {
          opacity: 0.5;                          /* drops contrast — expected */
        }
        ```
        
        ## Browser chrome integration
        
        ### `<meta name="theme-color">`
        
        Sets the address-bar / browser-chrome color on mobile (iOS Safari, Android Chrome). Without it, the chrome stays its default white/grey and your app's background ends abruptly at the bezel.
        
        ```html
        <meta name="theme-color" content="#18181b" media="(prefers-color-scheme: dark)">
        <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
        ```
        
        ### `color-scheme` CSS property
        
        Tells the browser whether scrollbars, form controls, and `<input>` defaults should render in their light or dark variant.
        
        ```css
        :root {
          color-scheme: light dark;  /* respect user preference */
        }
        .dark {
          color-scheme: dark;  /* force dark */
        }
        ```
        
        Without `color-scheme`, dark-mode pages get *light* scrollbars and *light* native `<select>` dropdowns — instantly disorienting.
        
        ## Gradient banding
        
        A gradient between two close colors (e.g. `#1a1a1a → #1c1c1c`) shows visible "banding" — concentric rings instead of smooth gradient. The fix: add a `background-image` of subtle noise.
        
        ```css
        .surface {
          background:
            linear-gradient(to bottom, #1a1a1a, #1c1c1c),
            url("data:image/svg+xml,...noise.svg");
          background-blend-mode: overlay;
        }
        ```
        
        Or use a `<svg>` filter with `<feTurbulence>` for procedural noise.
        
        ## When to apply
        
        - Setting up a new design tokens file: include APCA contrast checks in your design-system docs.
        - Implementing dark mode: add `<meta name="theme-color">` and `color-scheme: dark` from day 1.
        - Reviewing a hover state: confirm it *increases* contrast, doesn't decrease it.
        - Designing a hero gradient: test on macOS with night-shift on — banding shows up.
        
        ## Gotcha
        
        `color-scheme: dark` is *per element*, not per page. If you set it on `.dark` and a child element doesn't inherit, native form controls inside that child render light. Set on `:root` if you want global effect.
        
        ## Sources
        
        - Vercel Web Interface Guidelines — Design section, contrast and chrome rules.
        - Andrew Somers — APCA documentation, [git.apcacontrast.com](https://git.apcacontrast.com).
        - Related: [[color-monochromatic]] (the palette), [[dark-mode]] (specific colors), [[accessibility-baseline]] (a11y context), [[shadows-whisper]] (sibling surface concern).
        
      • dark-mode.md 2.7 KB
        ---
        title: dark-mode
        summary: Never pure #000. Use #18181b or #1a1a1a with 90-93% opacity white text.
        tags: [color, dark-mode, surface]
        ---
        
        # Dark mode
        
        The single most common dark mode mistake is using pure black (`#000000`) as the background and pure white (`#ffffff`) as the text. The contrast is too high, the surfaces feel cheap, and on OLED screens you get smearing on scrolling text.
        
        ## The rules
        
        ### Background
        
        Use a near-black with a subtle hue, not pure `#000`.
        
        ```css
        --bg-base: #18181b;        /* Zinc 950 — most products */
        --bg-elevated: #1f1f23;    /* slightly lighter for cards */
        --bg-overlay: #2a2a2f;     /* further elevated, modals */
        ```
        
        Alternatives by mood:
        - **Warm neutral:** `#1a1a1a`, `#222220`
        - **Cool neutral:** `#0f172a` (Slate 950)
        - **Deep but soft:** `#0a0a0a`
        
        Pure `#000000` is reserved for: photo backgrounds where the goal is invisibility, video player chrome, OLED-specific themes that opt in.
        
        ### Text
        
        Use white at **90–93% opacity**, not 100%.
        
        ```css
        --text-primary: rgba(255, 255, 255, 0.92);
        --text-secondary: rgba(255, 255, 255, 0.62);
        --text-muted: rgba(255, 255, 255, 0.42);
        ```
        
        Full white on dark backgrounds reads as too aggressive and the chromatic aberration on most monitors (especially LCD) makes edges shimmer. ~92% softens it without losing legibility.
        
        ### Borders
        
        In dark mode, borders are subtle:
        
        ```css
        --border-default: rgba(255, 255, 255, 0.08);
        --border-strong: rgba(255, 255, 255, 0.14);
        ```
        
        In dark mode you can also use *lighter* borders to suggest elevation, where in light mode you'd use shadows. See [[shadows-whisper]] for the light-mode equivalent.
        
        ## Elevation in dark mode
        
        In light mode, elevation = shadow.
        In dark mode, elevation = **lighter surface color**.
        
        ```css
        --bg-base: #18181b;       /* sea level */
        --bg-elevated: #1f1f23;   /* +1 */
        --bg-overlay: #2a2a2f;    /* +2 */
        --bg-floating: #35353a;   /* +3 (rarely needed) */
        ```
        
        This is the Material Design dark-mode principle, and it works.
        
        ## Color tokens flip
        
        Your single accent color from [[color-monochromatic]] should usually be slightly *desaturated* in dark mode. A vibrant indigo on white can feel garish on dark — dial saturation down 10–15%.
        
        ## When to apply
        
        Any product that supports a theme toggle. Most products do or will. Dark mode is no longer optional.
        
        ## Gotcha
        
        Don't just invert your light-mode colors. Some colors that work on light don't work on dark (warm yellows often turn muddy). Build dark mode as its own first-class theme with its own tokens, not as `--text: var(--white-or-black)`.
        
        ## Sources
        
        - guidelines.sh — "No pure #000 dark mode (use #1a1a1a or #18181b, 90-93% opacity white text)."
        - Material Design 3 — dark theme elevation by surface tint.
        - Vercel, Linear, Anthropic — concrete dark-mode tokens.
        
      • depth-and-nesting.md 4 KB
        ---
        title: depth-and-nesting
        summary: Inner radius = outer − padding. Shadows elevate, borders separate. Dark mode collapses shadows to a ring. Hairlines at 0.5px on retina, image outlines pure black or white at 10%, fades by mask, themes by flipping variables.
        tags: [surface, radius, shadows, borders, dark-mode, gradients]
        ---
        
        # Depth and nesting
        
        Depth comes from how surfaces meet: the radius where they nest, the shadow or border that separates them, the gradient that fades them out. Most interfaces that feel "off" fail here, and every fix is cheap. This node is the mechanical layer under [[shadows-whisper]] (the *weight* of shadows) and [[border-radius]] (the *scale* of radii).
        
        ## Derive nested radii
        
        A rounded child inside a rounded parent gets its radius by subtraction: **inner = outer − padding**. Copying the parent's radius pinches the corner gap, and it is the most frequent single giveaway of an unpolished UI. Past a ~24px gap the surfaces read as independent and each radius is picked on its own.
        
        ```css
        .dialog { border-radius: 16px; padding: 6px; }
        .dialog-body { border-radius: 10px; }   /* 16 − 6 */
        ```
        
        ## Shadows elevate, borders separate
        
        Pick by the job the line is doing:
        
        - **Raised** (cards, menus, popovers, dialogs, hover lift, anything crossing mixed backgrounds) → a stacked translucent shadow. Transparency composites with whatever is underneath, so one token works everywhere; a solid border color was tuned for exactly one background.
        - **Separated** (row dividers, table gridlines, form-field edges, dense hairline rules) → keep the border. A visible input edge is an accessibility feature; converting it to a shadow costs users and gains nothing.
        
        ```css
        :root {
          --elevation-raised: 0 0 0 1px rgb(0 0 0 / .05), 0 1px 3px rgb(0 0 0 / .05), 0 4px 10px -4px rgb(0 0 0 / .05);
        }
        .dark { --elevation-raised: 0 0 0 1px rgb(255 255 255 / .09); }   /* shadows vanish on dark; a ring does the job */
        .panel { box-shadow: var(--elevation-raised); transition: box-shadow 150ms ease-out; }
        ```
        
        Three ingredients: a 1px spread standing in for the border, a tight crisp layer, a wide soft ambient layer. On hover transition `box-shadow` only. In dark mode every stack collapses to one low-opacity white ring, because there is nothing dark enough for a shadow to fall on.
        
        ## Hairlines, outlines, fades
        
        - **Hairlines at 0.5px on ≥2× displays** via a media-query variable, 1px fallback. Retina dividers at 1px read heavy.
        - **Every image gets a 1px inset outline** — `outline: 1px solid rgb(0 0 0 / .1); outline-offset: -1px`, white at 10% in dark. Never a tinted neutral or the accent: a tinted line picks up the surface behind it and reads as grime.
        - **Eased gradients**, never two-stop linear ones between solids (they band). **Fades by `mask-image`**, not a gradient overlay: a mask composites with any background. Never fade scrollable content; it hides what the user is scrolling toward.
        - **One depth cue per surface.** Border *and* shadow *and* ring *and* gradient on one card is hedging.
        
        ## Themes flip variables, not classes
        
        Define a numbered scale as CSS variables and swap the values under the dark selector. Per-component `dark:` overrides scatter the theme across the codebase and rot on the first palette change.
        
        ## When to apply
        
        Styling any card, container, dialog, dropdown, or image; any nested rounded surface; any dark-mode pass. Review symptom → fix: pinched corners → derive the radius; card looks stamped on → shadow token; cards vanish in dark → ring; stripes in a gradient → eased stops; dirty image edges → pure black or white outline; `dark:` everywhere → flip variables.
        
        ## Gotcha
        
        The shadow-over-border rule is about *elevation*, not every line. Agents that read it as "delete all borders" strip the edges off inputs and table cells, which is a regression, not polish.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on surfaces, distilled by HKTITAN.
        - Related: [[shadows-whisper]], [[border-radius]], [[dark-mode]], [[color-scales-oklch]].
        
      • MOC-surface.md 1.7 KB
        ---
        title: MOC-surface
        summary: Color, shadow, border-radius, dark mode. The "background" choices that decide feel.
        tags: [moc, surface, color, shadow]
        ---
        
        # MOC — Surface
        
        The decisions that look invisible but determine whether the UI feels considered or generated. Monochromatic with restraint usually beats colorful.
        
        ## Nodes
        
        - [[color-monochromatic]] — Monochromatic with one or two accents beats a five-color palette. Why bright/purple gradients scream AI.
        - [[dark-mode]] — Never pure `#000`. Use `#18181b` or `#1a1a1a` with 90–93% opacity white text. Why pure-black breaks.
        - [[shadows-whisper]] — Shadows should whisper, not announce. The default `0 2px 8px rgba(0,0,0,0.1)` is a tell. Concrete spec.
        - [[border-radius]] — Uniform radius everywhere is lazy. Capsule buttons need full package. Nested-radius rules.
        - [[visual-imperfection]] — Asymmetry in marketing only. Optical over mathematical alignment. Subtle texture/noise. Imperfect shapes — perfect geometry feels cold.
        - [[contrast-and-color-scheme]] — APCA over WCAG 2 for contrast math. `<meta name="theme-color">` + `color-scheme: dark`. Interactions raise contrast. Avoid gradient banding.
        - [[color-scales-oklch]] — Author in OKLCH: one hue per ramp, L anchored and evenly spaced, chroma as a clamped curve, siblings at the same fraction of their own ceiling, dark mode by remapping steps, contrast repaired by moving L only.
        - [[depth-and-nesting]] — Inner radius = outer − padding; shadows elevate, borders separate; dark mode collapses shadows to a ring; 0.5px hairlines, pure black/white image outlines, mask fades, themes flip variables.
        
        ## Cross-cluster
        
        - See [[ai-default-tells]] in [[MOC-anti-patterns]] for surface tells (purple gradients, neon glows on dark mode).
        
      • shadows-whisper.md 2.7 KB
        ---
        title: shadows-whisper
        summary: Shadows should whisper, not announce. The default 0 2px 8px is a tell. Concrete spec.
        tags: [shadow, surface]
        ---
        
        # Shadows whisper
        
        The Bootstrap-default shadow (`0 2px 8px rgba(0, 0, 0, 0.1)`) is the most overused shadow on the web. It is a tell.
        
        ## The principle
        
        A good shadow is barely visible. Its purpose is to suggest elevation and depth, not to draw attention to the elevated element. If you can clearly *see* the shadow as a soft gray blur, it is too strong.
        
        ## The spec
        
        For light mode, layered shadows that whisper:
        
        ```css
        /* +1 elevation: cards, buttons */
        --shadow-sm:
          0 1px 1px rgba(17, 17, 17, 0.04),
          0 2px 4px rgba(17, 17, 17, 0.04);
        
        /* +2 elevation: dropdowns, popovers */
        --shadow-md:
          0 1px 2px rgba(17, 17, 17, 0.05),
          0 4px 8px rgba(17, 17, 17, 0.05),
          0 8px 24px rgba(17, 17, 17, 0.04);
        
        /* +3 elevation: modals */
        --shadow-lg:
          0 2px 4px rgba(17, 17, 17, 0.06),
          0 8px 16px rgba(17, 17, 17, 0.06),
          0 16px 48px rgba(17, 17, 17, 0.06);
        ```
        
        Key moves:
        - **Layer multiple shadows** at different blur radii. A single shadow with 16px blur looks flat. Three shadows at 2px, 8px, 24px blur read as physical depth.
        - **Use a dark base color** (`#111`, not pure black). Slightly warmer if your palette is warm.
        - **Opacity at 4–6%, not 10%.** This is the part most people get wrong.
        - **No `y-offset` larger than blur radius.** A 2px y-offset with 8px blur is fine; 8px y-offset with 8px blur is not.
        
        ## Dark mode shadows
        
        In dark mode, shadows alone often disappear. Pair with the surface-tint elevation pattern from [[dark-mode]] — use a lighter surface color *and* a much subtler shadow:
        
        ```css
        .dark {
          --shadow-md:
            0 4px 12px rgba(0, 0, 0, 0.4),
            0 0 0 1px rgba(255, 255, 255, 0.06); /* hairline border for definition */
        }
        ```
        
        The 1px hairline border at low white opacity does most of the elevation work in dark mode.
        
        ## Inner shadows for surfaces
        
        ```css
        /* Subtle inner shadow on top edge for a card */
        box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
        ```
        
        Used by Apple, Stripe, Linear. Adds a hairline highlight to suggest the surface is reflecting light from above.
        
        ## When to apply
        
        - Any time you write a `box-shadow`. Reach for the layered token, not a single-shadow blur.
        - Reviewing a design system that has one "shadow" value — push for at least three layered tokens.
        
        ## Gotcha
        
        Don't add `box-shadow` to text. Use `text-shadow` for that — and almost never. Text shadows are an effect from 2008 and they still look like 2008.
        
        ## Sources
        
        - guidelines.sh — "Shadows whisper (2px blur, 1px Y, 5% opacity #111)."
        - Tailwind v3 shadow scale + improvements from Linear and Vercel.
        - Apple HIG — material/elevation tokens.
        
      • visual-imperfection.md 4.7 KB
        ---
        title: visual-imperfection
        summary: Optical alignment beats mathematical. Texture and noise beat flat. Imperfect shapes beat perfect geometry — in marketing.
        tags: [surface, imperfection, asymmetry, texture]
        ---
        
        # Visual imperfection
        
        Mathematically perfect design reads as cold and computer-generated. A small amount of deliberate imperfection — optical adjustments, subtle texture, slightly irregular shapes — is what makes work feel human.
        
        Important: this is mostly a *marketing* / *brand* concern. In-app product UI should stay disciplined ([[data-is-content]], [[states-are-the-work]]). The imperfection live on the marketing surface.
        
        ## 1. Optical vs. mathematical alignment
        
        A circle next to a square at the same height does *not* look the same height. The square fills its bounding box; the circle doesn't. To look the same, the circle needs to be ~5% larger.
        
        Same with:
        
        - A triangle (icon) next to a rectangle (label) — the triangle's bounding box is mostly negative space; it reads smaller.
        - A capital letter next to a lowercase — visual weight differs.
        - A pill button next to a rectangle button — pill ends are visually lighter.
        
        The fix is *optical adjustment*: nudge sizes/positions until they *look* aligned, even if the numbers say they're off.
        
        Example — icon next to text:
        
        ```css
        /* Mathematical: same line-height, same vertical-align */
        .icon { vertical-align: middle; }
        
        /* Optical: nudge icon up by 1px because text baseline reads lower */
        .icon { vertical-align: middle; transform: translateY(-1px); }
        ```
        
        Trust your eye over the numbers. If it looks wrong, it is wrong.
        
        ## 2. Texture and noise
        
        Flat color fills can feel sterile. Adding subtle texture warms a surface without anyone noticing why.
        
        ```css
        .surface {
          background-color: var(--bg);
          background-image: url('data:image/svg+xml;utf8,<svg ...noise pattern...></svg>');
          /* or */
          background-image: radial-gradient(circle, rgba(0,0,0,0.012) 1px, transparent 1px);
          background-size: 4px 4px;
        }
        ```
        
        Common patterns:
        - **Paper grain** — subtle 4–8px noise overlay at ~1% opacity.
        - **Film grain** — slightly more visible noise for editorial / brand surfaces.
        - **Gradient mesh** — instead of a flat hero color, use a 2–3 stop gradient that's barely perceptible.
        
        The noise should be felt, not seen. If a user notices the texture explicitly, it's too strong.
        
        ## 3. Imperfect shapes
        
        For brand-led work, geometric perfection is a tell. Slight irregularity adds character:
        
        - **Slightly irregular borders** — not perfectly straight lines; hand-drawn feel.
        - **Hand-drawn strokes** — for marketing illustrations, accents, dividers.
        - **Asymmetric blob shapes** — for hero backgrounds, instead of perfect circles.
        - **Wobbly bullets, slightly off-grid icons** — when the brand is playful.
        
        These read as "made by a person." Perfectly round circles and perfectly straight lines read as "made by a machine" — fine for utility UI, wrong for personality.
        
        Tools: hand-drawn SVG libraries (Open Doodles, Croodles via DiceBear), or just sketch in Procreate / iPad and import.
        
        ## 4. Asymmetric layouts (marketing only)
        
        Symmetric layouts feel safe. Asymmetric layouts feel intentional.
        
        ```
        Symmetric (default):    Asymmetric (marketing):
           [hero]                       [hero]
           [3-card grid]            [big card] [stack of 2 small]
           [CTA]                    [pull-quote that escapes the grid]
        ```
        
        Asymmetric is a marketing-page tool. It signals craft and confidence. But it requires real layout work — you can't just shuffle modules randomly. See [[viewport-custom-design]].
        
        ## When to apply
        
        - Marketing pages, brand-led surfaces, hero sections, blog index, about pages.
        - Editorial content (long-form articles, design portfolios).
        - Onboarding moments where personality earns the bandwidth.
        - Brand-driven products from inception (a "fun" app where imperfection is core to identity).
        
        ## When NOT to apply
        
        - Data-heavy product UI ([[data-is-content]]).
        - Forms, tables, dashboards.
        - Repeated UI patterns (lists, settings, etc.) — consistency wins inside the product.
        - Anywhere users need to *parse* the layout quickly. Imperfection slows scanning.
        
        ## Gotcha
        
        Don't confuse "imperfection" with "lazy alignment." A blog post with random margins on each paragraph is not imperfect-on-purpose; it's broken. The imperfection has to be deliberate, applied at specific decorative moments, and consistent in its inconsistency.
        
        A test: would the imperfection survive being shown to a senior designer? If they'd flag it as a bug, it's a bug. If they'd nod, it's intentional.
        
        ## Sources
        
        - guidelines.sh — Visual Imperfection category (optical alignment, texture/noise, imperfect shapes, asymmetric layouts).
        - Related: [[viewport-custom-design]], [[typography-humanity]], [[marketing-vs-product-ui]], [[feeling-right]].
        
    • svg
      • MOC-svg.md 2.9 KB
        ---
        title: MOC-svg
        summary: Authoring clean, editable, token-aware SVG; animating it with the right engine for where it lives; morphing paths that actually interpolate; and turning AI video or frame sequences into one editable animated SVG. The vector layer under icons, mascots, illustrations, and launch motion.
        tags: [moc, svg, vector, animation, illustration]
        ---
        
        # MOC — SVG
        
        Every SVG element is a DOM node you can style, animate, and script, which makes SVG the only image format that is also an interface. This cluster covers making it (clean, editable, optimized, on the token system), moving it (CSS, SMIL, WAAPI, or a library, chosen by where the file lives), morphing it (the command-count rule and how to cheat it), and the video-to-vector pipeline that produces app mascots and launch motion from flat AI-generated clips.
        
        Read [[svg-creation]] before drawing anything and [[svg-animation]] before animating anything; the other two are the specialised jobs.
        
        ## Make it
        
        - [[svg-creation]] — viewBox always, paths over primitives when it will animate, `currentColor` and CSS variables so the token system reaches inside, `<defs>`/`<symbol>`/`<use>`, named layers, optimization with SVGO, `<title>`/`<desc>`, and what generated SVG gets wrong.
        
        ## Move it
        
        - [[svg-animation]] — the engine decision (inline + CSS/WAAPI vs `<img>` + SMIL vs library), stroke drawing with `dasharray`/`dashoffset`, `transform-origin` (SVG defaults to 0,0), `transform-box: fill-box`, composite-only properties, group choreography, reduced motion, and the size discipline that keeps an animated SVG smaller than the Lottie it replaces.
        - [[svg-path-morphing]] — native `d` interpolation needs the same command count and order; how to author matching paths, when to reach for flubber or a library, and why icon swaps usually beat morphs.
        
        ## Produce it
        
        - [[video-to-vector-pipeline]] — flat 2D clip → frames → vectorize → clean and match paths → one single-file animated SVG (a vector flipbook), then optional Lottie or Rive; the constraints that make it work (flat, no gradients, simple characters) and the artifacts that break it. Ships with `scripts/svg-flipbook.mjs`.
        
        ## Subagents
        
        - [[svg-creator]] — authors or refactors an SVG asset: icon, illustration, mascot pose, generative art from a [[vibe-to-generator]] config. Returns an optimized, token-aware, labelled file.
        - [[svg-animator]] — animates an existing SVG or builds a flipbook from frames: picks the engine for the target, writes the animation, verifies size and reduced motion.
        
        ## Cross-cluster
        
        - [[icon-systems]] and [[morphing-icons]] own icon *systems*; this cluster owns the vector mechanics beneath them.
        - [[launch-video-sound]] pairs with [[video-to-vector-pipeline]] for a mascot reveal: the flipbook's frame timing is where the sound transients land.
        - [[performance-discipline]] applies unchanged: animated `d`, `points`, and filters repaint; `transform` and `opacity` composite.
        
      • svg-animation.md 4.1 KB
        ---
        title: svg-animation
        summary: Pick the engine by where the file lives — inline + CSS/WAAPI for interactive UI, SMIL or embedded CSS keyframes for a self-contained file loaded as an image, a library only for gesture or timeline work. Stroke drawing, transform-origin, composite-only properties, group choreography, reduced motion, size.
        tags: [svg, animation, smil, css, waapi, performance]
        ---
        
        # Animating SVG
        
        The first decision is not the easing; it is **where the file lives**, because that fixes which engine can reach it. Everything in [[animation-decision-framework]], [[easing-curves]], and [[duration-table]] still applies — an SVG is just DOM with a coordinate system.
        
        ## Engine decision
        
        ```text
        Where does the SVG render?
        ├── Inline in the page (React, HTML)
        │   ├── State-driven, interruptible, reacts to input → CSS transitions or WAAPI
        │   └── Gesture-driven, spring, timeline choreography  → Motion / GSAP (match the project's stack)
        ├── Loaded as <img>, background-image, email, social upload, Figma
        │   → self-contained: CSS @keyframes inside the file (portable, GPU-friendly), SMIL for
        │     motion paths, attribute animation, or begin="click" / begin="other.end" sequencing
        └── Needs a runtime anyway (rich after-effects export) → Lottie/Rive, and only then
        ```
        
        CSS keyframes baked into the file are the most portable option in 2026; SMIL still wins for `<animateMotion>` along a path and for animating attributes CSS can't. Native SVG with CSS or SMIL is usually 5–10× smaller than the Lottie export of the same thing and carries `<title>`/`<desc>` that Lottie JSON cannot.
        
        ## The techniques
        
        - **Stroke drawing.** `stroke-dasharray` and `stroke-dashoffset` set to the path length, animate offset to 0. Read the exact length with `path.getTotalLength()` or use `pathLength="1"` so the CSS is `stroke-dasharray: 1; stroke-dashoffset: 1`. `stroke-linecap: round` for polished ends.
        - **`transform-origin` is (0,0) in SVG.** Set `transform-box: fill-box; transform-origin: center` (or the pivot you mean — a shoulder, a hinge) or every rotation orbits the corner.
        - **Composite-only.** `transform` and `opacity` composite; animating `d`, `points`, `r`, `stroke-width`, or filters repaints every frame — fine for one small icon, ruinous for a scene. `will-change: transform` only after a first-frame hitch ([[performance-discipline]]).
        - **Choreograph with groups.** Animate `<g>` layers (arm, eyes, body), not hundreds of paths; stagger 30–80ms; paired parts share duration and easing.
        - **Motion paths.** SMIL `<animateMotion><mpath href="#route"/></animateMotion>` with `rotate="auto"`, or CSS `offset-path: path("…")` when inline.
        - **Reduced motion.** Inline: the usual media query. Self-contained: `@media (prefers-reduced-motion: reduce)` inside the file's `<style>` still works when loaded as an image; SMIL cannot read it, so a SMIL-only file needs a static fallback asset.
        
        ## Size discipline
        
        An animated SVG is a text file that ships on every load. Budget ~10–30 KB for a hero mascot, ~2 KB for an icon. Reuse geometry with `<use>`, animate transforms instead of redrawing, round coordinates, and run SVGO with animation-safe flags ([[svg-creation]]). A flipbook of 60 vectorized frames is the case where size explodes — see [[video-to-vector-pipeline]] for de-duplication and frame budgets.
        
        ## When to apply
        
        Animating any icon, illustration, mascot, logo reveal, chart transition, or loader that is vector; deciding between Lottie, Rive, and native SVG; reviewing an animated SVG someone dropped in.
        
        ## Gotcha
        
        Inline SVG inherits the page's `prefers-reduced-motion` handling only if you wrote it. A copied animated icon with its own `<style>` keyframes and no media query is the most common reduced-motion miss in generated UI, and [[review-checklist]] row 6 catches it.
        
        ## Sources
        
        - supermemoryai/skills `svg-animations` — engine choice, stroke drawing, SMIL timing, transform-origin.
        - MDN Web Animations API, SMIL Animation; svg.dog engine comparison.
        - Related: [[animation-decision-framework]], [[morphing-icons]], [[svg-path-morphing]], [[performance-discipline]], [[prefers-reduced-motion]].
        
      • svg-creation.md 4 KB
        ---
        title: svg-creation
        summary: Author SVG that is clean, editable, and on the token system — viewBox always, paths when it will animate, currentColor and CSS variables, defs and symbols, named layers, SVGO with the right flags off, title and desc — and the tells of generated SVG.
        tags: [svg, vector, icons, illustration, tokens, accessibility]
        ---
        
        # Creating SVG
        
        An SVG is markup, not a picture, and the quality of the markup decides everything downstream: whether it recolors with the theme, whether it can be animated without rewriting it, whether it is 2 KB or 200 KB, whether a screen reader can name it. Draw for the DOM you will have to live with.
        
        ## Structure
        
        - **`viewBox` always, no fixed width/height on the root.** `viewBox="0 0 24 24"` defines the coordinate system; size comes from CSS. Hardcoded dimensions are how icons refuse to scale.
        - **Whole-number coordinates on a sensible grid** (24 for icons, 100 or 1000 for illustrations). Half-pixel coordinates blur on 1× screens; a 16px icon is redrawn, not scaled down — see [[icon-systems]].
        - **Primitives for static shapes, `<path>` for anything that will animate or morph.** `<rect>` and `<circle>` are readable and small; a morph needs paths with matching commands ([[svg-path-morphing]]).
        - **Group by meaning, name the layers.** `<g id="arm-left">`, `<g data-layer="eyes">`. A mascot with unnamed groups cannot be animated by anyone, including you next week. Order groups back-to-front like a compositor.
        - **`<defs>` for gradients, filters, masks; `<symbol>` + `<use>` for repeats.** One definition, many instances; the file stays small and one edit changes every instance.
        - **Never embed rasters.** A base64 PNG inside an SVG is a PNG with worse compression.
        
        ## Color that follows the system
        
        - Set `fill="currentColor"` on monochrome icons and color them from CSS. The token system reaches inside the file for free.
        - Multi-color illustrations expose **CSS custom properties with fallbacks**: `fill="var(--mascot-skin, #f2c9a0)"`. Dark mode becomes a variable flip, not a second file ([[depth-and-nesting]]).
        - Keep the palette to the tokens — three colors for a spot illustration ([[icon-systems]] owns the illustration rules). Gradients only from `<defs>` and only when the brand has one.
        
        ## Optimize, carefully
        
        Run SVGO (`npx svgo file.svg --multipass`), but keep `viewBox` (`removeViewBox: false`), keep the ids and `data-*` you animate against (`cleanupIds` off for those), and keep `<title>`/`<desc>`. Round path precision to 1–2 decimals; strip editor metadata, empty groups, and default attributes. Check the result renders identically before committing.
        
        ## Accessibility
        
        Meaningful graphics: `role="img"` plus `<title>` (and `<desc>` for detail) as the first child, and `aria-labelledby` pointing at them. Decorative graphics: `aria-hidden="true"`, `focusable="false"`. Never an SVG with no accessible name that carries meaning, and never a labelled one that is decoration.
        
        ## Tells of generated SVG
        
        Fixed `width`/`height` and no `viewBox`; every color a hardcoded hex; a hundred unnamed `<g>`s; primitives converted to 8-decimal paths; an embedded PNG; the same gradient defined four times; a `<style>` block with global selectors that leak when inlined. Each is cheap to fix and expensive to leave.
        
        ## When to apply
        
        Drawing or refactoring any icon, illustration, mascot pose, logo, chart glyph, or generative art output; any SVG a [[svg-animator]] will animate; any SVG dropped in by a generator.
        
        ## Gotcha
        
        Inline `<style>` inside an SVG becomes global CSS the moment the SVG is inlined into a page. Scope selectors under a root id, or move the styles to the page. And `<use>` cannot reach into a symbol's internals from the outside — expose what must be styled as CSS variables instead.
        
        ## Sources
        
        - W3C SVG 2; SVGO documentation.
        - supermemoryai/skills `svg-animations` — viewBox, defs, and grouping practices; HKTITAN — token-aware authoring and the generated-SVG tells.
        - Related: [[icon-systems]], [[svg-animation]], [[svg-path-morphing]], [[vibe-to-generator]].
        
      • svg-path-morphing.md 3.5 KB
        ---
        title: svg-path-morphing
        summary: A native morph interpolates only between paths with the same number and order of commands — author both shapes on one point budget, or hand the mismatch to flubber. Most icon "morphs" should be a deep scale-and-blur swap instead.
        tags: [svg, morphing, paths, icons, animation]
        ---
        
        # Path morphing
        
        Browsers interpolate the `d` attribute (CSS `d: path()` or SMIL `<animate attributeName="d">`) with one unbreakable rule: **both paths must have the same number of commands, in the same order, of the same type.** Four cubic curves morph into four cubic curves; a rectangle of four lines cannot morph into a circle of four arcs without help. Everything about morphing follows from that rule.
        
        ## Author to the rule
        
        - **Draw both states on one point budget.** Decide the command list first (say `M` + 8 `C` + `Z`), then place the points for shape A and shape B. Extra detail in one state is a mismatch; add degenerate points to the simpler shape instead.
        - **Keep the start point and winding direction the same** or the morph rotates through itself.
        - **Convert primitives to paths** with the same structure — a circle becomes four cubic arcs, a rect four lines plus close, and the two won't match each other.
        - **Match subpaths.** A shape with two holes needs two holes in the other state; collapse an unused hole to a zero-area subpath rather than deleting it.
        
        ```css
        .shape { d: path("M4 12 C4 7 8 4 12 4 C16 4 20 7 20 12 C20 17 16 20 12 20 C8 20 4 17 4 12 Z"); transition: d 240ms cubic-bezier(0.25, 1, 0.5, 1); }
        .shape[data-state="square"] { d: path("M4 4 C4 4 12 4 12 4 C12 4 20 4 20 4 C20 4 20 20 20 20 C20 20 4 20 4 20 Z"); }
        ```
        
        ## When the paths don't match
        
        Use **flubber** (`interpolate(pathA, pathB)`) — it resamples both shapes to matching point counts and returns an interpolator you drive from any engine. GSAP's MorphSVG and KUTE do the same inside their runtimes. The cost is a dependency and a JS loop; per [[dependency-discipline]], reach for it only when the morph is the product (a logo transformation, a shape-shifting hero), never for an icon toggle.
        
        ## Usually you don't want a morph
        
        Play → pause, copy → check, menu → close: the honest version is a **swap** — the outgoing icon scales to 0.3, fades, and blurs 3px while the incoming does the reverse, spring with zero bounce, both icons rendered at once so the reverse direction animates too. It reads as a real change; a timid morph between unrelated shapes reads as a glitch. [[morphing-icons]] covers the constrained system where morphing *is* right (same-shape icons rotate; different shapes interpolate coordinates on a fixed line budget); [[fly-not-teleport]] covers the continuity principle.
        
        ## Performance
        
        Animating `d` repaints each frame. Acceptable for one icon or a hero shape; combine with `transform`/`opacity` for everything else on screen, and never morph many paths at once ([[performance-discipline]]).
        
        ## When to apply
        
        Logo transformations, shape-shifting heroes, chart shape transitions, and the icon systems that were designed to morph. Not for every state change.
        
        ## Gotcha
        
        A morph that "works in Chrome" and jumps in Safari almost always has a command-type mismatch (`S` vs `C`, `l` vs `L`) that one engine normalises and the other doesn't. Normalise both paths to absolute cubics before shipping.
        
        ## Sources
        
        - SVG 2 / CSS `d` property interpolation rules; veltman/flubber.
        - supermemoryai/skills `svg-animations` — the command-matching rule.
        - Related: [[morphing-icons]], [[fly-not-teleport]], [[svg-animation]], [[dependency-discipline]].
        
      • video-to-vector-pipeline.md 4.5 KB
        ---
        title: video-to-vector-pipeline
        summary: Turn a flat 2D clip (AI-generated or hand-animated) into one editable, single-file animated SVG — frames → vectorize → clean and match layers → vector flipbook with stepped keyframes — and the constraints that make it work: flat art, few colors, no gradients, a frame budget.
        tags: [svg, video, vectorize, mascot, flipbook, pipeline]
        ---
        
        # Video to vector
        
        A flat mascot clip — a rooster at a laptop, a Duolingo-style character waving — can become a vector asset that is editable, recolorable, tiny, and crisp at any size. The loop is now productized (Anim8 converts MP4 and AI-generated video into editable vector animation with single-file animated SVG, Lottie, and MP4 export), and it is also reproducible with open tools. The hard part was never the vectorizing; it is matching paths across frames, reducing artifacts, keeping the file usable, and leaving the result editable.
        
        ## The constraint that makes it work
        
        This works for **simple flat 2D art**: solid fills, two to eight colors, clean silhouettes, no gradients, no busy patterns, no photographic texture. Complex illustration falls apart into thousands of slivers. Generate the source clip to that brief (prompt the image or video model for "flat vector style, solid colors, no gradients, plain background"), or accept that the output will need hand cleanup.
        
        ## The pipeline
        
        1. **Extract frames.** `ffmpeg -i clip.mp4 -vf "fps=12,scale=512:-1" frames/f_%03d.png`. Twelve frames a second reads as animation for a mascot; 24 doubles the file for little gain. Trim to the loop.
        2. **Quantize colors first.** Reduce each frame to the palette (`-vf palettegen/paletteuse`, or posterize) so the vectorizer sees a handful of flat regions instead of anti-aliased gradients.
        3. **Vectorize.** `vtracer` (open source, color-capable, O(n)) per frame: `--colormode color --mode polygon --filter_speckle 8 --color_precision 6`. Potrace for pure black-and-white line art.
        4. **Clean.** Remove specks below a few pixels, merge near-identical colors, simplify with SVGO (precision 1), and delete the background region. This is where artifacts die or survive.
        5. **Match layers across frames.** Give each color region a stable name (`body`, `beak`, `eye-l`) by matching centroid and color frame to frame. Matched layers are what make the file *editable* — recolor the beak in one place — and what later enables true keyframe interpolation instead of a flipbook.
        6. **Assemble a vector flipbook.** One `<svg>`, one `<g class="frame">` per frame, a stepped CSS keyframe animation toggling visibility, colors lifted to CSS variables so the whole character recolors from outside. `scripts/svg-flipbook.mjs` (next to this skill's `SKILL.md`) does this step: `node scripts/svg-flipbook.mjs frames/ --fps 12 --out mascot.svg`.
        7. **Budget and dedupe.** Identical consecutive frames collapse into one with a longer hold; a 24-frame loop at 12 fps of a simple character lands around 20–60 KB. If it is larger, the vectorizer left slivers — go back to step 4.
        8. **Export onward when needed.** Lottie for native runtimes, Rive for interactive state machines, MP4 for social. The SVG stays the editable source.
        
        ## Where it lands
        
        - **App mascot** on an onboarding or empty state: inline, CSS variables tied to the theme, `prefers-reduced-motion` freezing on the resting frame.
        - **Launch motion**: the flipbook's frame timing is a beat sheet — [[launch-video-sound]] places transients on the frames where the character makes contact.
        - **Marketing hero**: loaded as `<img>` with keyframes inside the file; see [[svg-animation]] for the self-contained rules.
        
        ## When to apply
        
        A product wants a mascot or character motion, a launch reveal, or a sticker-like illustration that must be editable and recolorable — and the source is a flat clip rather than hand-drawn paths. Not for photoreal video, gradients, or anything with texture; that stays raster.
        
        ## Gotcha
        
        Vectorized frames inherit the clip's jitter: outlines wobble by a pixel because each frame was traced independently. Quantize and trace at a consistent resolution, and for a hold frame reuse the previous frame's paths rather than re-tracing. Boiling outlines read as an artifact, not a style, unless the brief is "hand-drawn".
        
        ## Sources
        
        - Adrian Abelarde's Anim8 (tryanim8.com) — the productized MP4 → editable vector pipeline and its stated constraints; his Clucky mascot workflow shared publicly on X.
        - visioncortex/vtracer; Potrace; ffmpeg; SVGO.
        - Related: [[svg-creation]], [[svg-animation]], [[launch-video-sound]], [[icon-systems]].
        
    • typography
      • line-behavior.md 4.4 KB
        ---
        title: line-behavior
        summary: Cap the measure near 65ch, pick a wrap mode per role, guard overflow both ways, never truncate without a route to the full text, case with CSS, type real punctuation, tune underlines, write direction-neutral CSS, and hold the size and contrast floors.
        tags: [typography, measure, wrapping, truncation, underline, accessibility]
        ---
        
        # Line behavior
        
        How lines get their length, where they break, how they end when space runs out, and which characters belong in them. [[line-length-tracking]] argues the measure; this node is the full set of mechanical rules.
        
        ## Measure and wrap
        
        - **Cap reading columns near 65ch** (45–75). `max-width: 65ch` counts characters natively; Tailwind's `max-w-prose` is exactly that. Re-check when the family changes — the same width holds a different count in a different font.
        - **Wrap mode per role.** `text-wrap: balance` on headings; `text-wrap: pretty` on standfirsts, card blurbs, empty-state copy (kills the stranded last word). Neither on long-form paragraphs: browsers cap `balance` at a few lines and a fully balanced paragraph spends width reading needs. A three-line headline can still come out with a short first line — sometimes a manual break wins.
        - **Overflow guards, both ways.** `overflow-wrap: break-word` wherever a URL, hash, or user token could punch out of its box; `white-space: nowrap` on badges, buttons, and key-value labels where a mid-phrase break reads as a bug. `hyphens: auto` needs a correct `lang`.
        - **Left-aligned by default.** The ragged right edge is the eye's landmark; justification without hyphenation opens rivers. Centering works for short display text and fails past two wrapped lines.
        
        ## Cutting text off
        
        One line: `nowrap` + `overflow: hidden` + `text-overflow: ellipsis`. Several: `line-clamp`. Truncation deletes information, so it is allowed only with a route to the whole string — tooltip, `title`, expand, detail view. A clipped ID with no escape hatch is data loss wearing CSS.
        
        ## Casing and characters
        
        - **Casing is presentation.** Store copy in sentence case and shout with `text-transform`; ALL-CAPS strings in source get rewritten at the next redesign and confuse screen readers.
        - **Type real punctuation.** Curly quotes in prose, an en dash for spans (`Mon–Fri`), an em dash for asides, the single `…` character, `&nbsp;` between a value and its unit, `&shy;` inside long words. Code keeps straight quotes.
        
        ## Finish
        
        - **Underlines** take their metrics from the font (`text-decoration-thickness: from-font`, `text-underline-position: from-font`) or are tuned by hand in `em` with `text-decoration-skip-ink: auto`. Dotted means "there's more here" (abbreviations, defined terms). Only the *color* of a native underline animates reliably; a growing or sliding underline is its own element. Never underline non-links.
        - **Selection is a design surface.** A tinted `::selection` that stays legible; `user-select: none` on control labels and chrome; check what select-all actually grabs.
        - **Smooth once at the root**: `-webkit-font-smoothing: antialiased` and `-moz-osx-font-smoothing: grayscale`, never per component.
        - **Direction-neutral CSS.** Logical properties (`padding-inline-start`, `text-align: start`), `lang` on the document, `dir="rtl"` where it applies.
        
        ## The floors
        
        - Inputs never below **16px** on phones — Safari zooms the page. Never patch it with `maximum-scale=1`, which strips zoom for everyone else (a WCAG 1.4.4 failure).
        - Body **16px**; dense controls 14px; captions 13px; below 12px needs an unusual excuse. Layouts must survive user zoom and enlarged root sizes.
        - Contrast **4.5:1** body, **3:1** from ~24px (or bold ~18.5px) under WCAG AA — checked in both themes; see [[color-scales-oklch]] for the OKLCH repair.
        
        ## When to apply
        
        Any component that renders text a user reads; any truncation; any link styling; any i18n or RTL work; every review of a text-heavy diff. Fix with the smallest diff — a typography fix never restructures markup unless an animated underline or an expandable truncation needs the element.
        
        ## Gotcha
        
        `text-wrap: balance` on body paragraphs is the most common misuse: it looks tidy in a two-line demo and quietly narrows every real paragraph. Headings balance; paragraphs don't.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on typography, distilled by HKTITAN.
        - CSS Text Level 4 (`text-wrap`), WCAG 2.2 1.4.3 / 1.4.4.
        - Related: [[line-length-tracking]], [[type-scale-and-rhythm]], [[accessibility-baseline]].
        
      • line-length-tracking.md 2.8 KB
        ---
        title: line-length-tracking
        summary: 45-75 chars per line. Tighten tracking as size increases. text-wrap: pretty. Uppercase rules.
        tags: [typography, layout]
        ---
        
        # Line length, tracking, and text wrap
        
        ## Line length
        
        Aim for **45–75 characters per line** in body text. This is from the *Elements of Typographic Style* (Bringhurst). Outside this range, the eye works harder than it should — short lines feel choppy, long lines lose the start of the next line.
        
        For prose: 60–70 characters per line is the sweet spot.
        For UI labels and form fields: shorter is fine; you control them.
        
        CSS:
        
        ```css
        .prose {
          max-width: 65ch;
        }
        ```
        
        The `ch` unit measures the width of the "0" character in the current font, which approximates character count well enough.
        
        ## Tracking (letter-spacing)
        
        Tracking and font size are inversely coupled:
        
        | Size | Tracking |
        |---|---|
        | 10–12px | +0.5px to +1px (open up tiny text) |
        | 14–16px (body) | 0 (default) |
        | 20–28px | -0.01em to -0.02em (slight tighten) |
        | 32–48px (headings) | -0.02em to -0.04em |
        | 64px+ (display) | -0.04em to -0.06em |
        
        Larger sizes need negative tracking because the natural spacing of a typeface is calibrated for body sizes. Forgetting to tighten display text is one of the most common amateur tells.
        
        ```css
        h1 { font-size: 48px; letter-spacing: -0.03em; }
        .body { font-size: 16px; letter-spacing: 0; }
        .caption { font-size: 11px; letter-spacing: 0.04em; }
        ```
        
        ## `text-wrap: pretty` and `text-wrap: balance`
        
        ```css
        h1, h2, h3 { text-wrap: balance; }
        p { text-wrap: pretty; }
        ```
        
        - **`balance`** — distributes words evenly across lines. Best for headings (avoids the last line being one word).
        - **`pretty`** — avoids orphans and improves line breaks in prose. Best for body text.
        
        Browser support is now broad enough (Chrome 114+, Safari 17.4+) to ship without fallback. The improvement is large for headings.
        
        ## Uppercase
        
        Uppercase text is harder to read because all letters are the same height. Rules:
        
        - **OK at small sizes** (10–12px) with **wide tracking** (+0.05em to +0.1em). Common for eyebrow labels, navigation, badges.
        - **OK for short phrases** (under 4 words) at any size.
        - **Avoid** uppercase for body prose, multi-sentence content, or anything users need to skim.
        
        ## When to apply
        
        Every time you build a typography system. Build these into your token set:
        - `--max-prose: 65ch;`
        - `--tracking-display: -0.03em;`
        - `--tracking-body: 0;`
        - `--tracking-caps: 0.06em;`
        
        ## Gotcha
        
        `text-wrap: balance` has a node-count limit (Chrome caps at ~6 lines for performance). It silently does nothing past that — so don't apply it to long paragraphs and assume it's working.
        
        ## Sources
        
        - Bringhurst, *Elements of Typographic Style*.
        - guidelines.sh — tracking rules, line length, `text-wrap: pretty`.
        - web.dev — text-wrap balance/pretty.
        
      • MOC-typography.md 1.2 KB
        ---
        title: MOC-typography
        summary: Humanizing text on screen. Font choice, line length, tracking.
        tags: [moc, typography]
        ---
        
        # MOC — Typography
        
        Typography is where AI-generated UIs give themselves away first. Inter and SF Pro are tells.
        
        ## Nodes
        
        - [[typography-humanity]] — Why Inter/SF Pro feel default. Indie foundries to know. Buying a single weight is fine.
        - [[line-length-tracking]] — 45–75 characters per line. Tighten tracking as size increases. `text-wrap: pretty`. When uppercase is allowed.
        - [[type-scale-and-rhythm]] — A closed scale named by role; unitless leading (~1.1 display, 1.5–1.6 body); tracking by size; woff2 only, `font-synthesis: none`, properties over raw tags, tabular figures on anything that updates.
        - [[line-behavior]] — Measure near 65ch, wrap mode per role, overflow guards both ways, truncation with an escape hatch, casing by CSS, real punctuation, tuned underlines, logical properties, the 16px and contrast floors.
        
        ## Cross-cluster
        
        - See [[ai-default-tells]] in [[MOC-anti-patterns]] for typography tells (default sans on landing pages, gradient text on hero).
        - For the precise word behind a vague critique (kerning vs tracking, widow vs orphan, leading), see [[design-vocabulary]].
        
      • type-scale-and-rhythm.md 4 KB
        ---
        title: type-scale-and-rhythm
        summary: Every size comes from a closed scale named by role. Leading unitless — ~1.1 display, 1.5–1.6 body. Tracking follows size. woff2 only, font-synthesis none, high-level properties over raw tags, tabular figures on anything that updates.
        tags: [typography, scale, leading, tracking, fonts]
        ---
        
        # Type scale and rhythm
        
        Size, leading, and tracking carry more of typography's quality than any flourish; get the three right and most text problems never appear. This node is the mechanics beneath [[typography-humanity]] (which face) and beside [[line-length-tracking]] (how lines run).
        
        ## Files and features
        
        - **Ship `woff2` and nothing else.** Brotli-compressed and universally supported; `ttf`/`otf` in `public/fonts` is a bug. Load only the weights you use and subset to the characters you use.
        - **`font-synthesis: none` at the root.** Ask for an unloaded bold or italic and the browser fakes one by thickening or slanting glyphs. Turn it off so a missing file fails visibly.
        - **High-level property over raw tag.** `font-weight: 620`, not `font-variation-settings: "wght" 620`; `font-optical-sizing: auto`, not a hand-set `"opsz"`; `font-variant-numeric: tabular-nums`, not `"tnum" 1`. Properties still do something sensible on a fallback font; raw tags silently stop. Reserve `font-variation-settings` / `font-feature-settings` for custom axes and numbered stylistic sets, and comment what the numbered slot does in *this* font.
        - **Tabular figures on anything that updates.** Timers, prices, counters nudge their neighbours on every tick without them.
        
        ## Build a scale, then obey it
        
        A short closed list of sizes; departures need justification. Ratio-derived (1.2 on a 16px root) or Tailwind's stock steps are both fine. On a team, name steps by **role** — `text-caption`, `text-card-title` — because a role polices its own usage in a way `text-lg` cannot. Sizes in `rem` so the reader's settings hold; `px` is for hairlines, not text.
        
        ## Leading follows role
        
        Body 1.5–1.6; display and headings around 1.1 (large sizes carry their own optical spacing). Always **unitless**: `line-height: 24px` detonates on the first size tweak or user zoom. Paragraph spacing ≈ 1× line-height, not an arbitrary px.
        
        ## Tracking follows size
        
        Display sizes slightly negative (≈ −0.015em); small uppercase labels slightly positive (≈ +0.06em) so capitals get daylight; body zero. Track in `em` so it scales with the text. Kerning is the font's own pair data and stays on; `font-kerning: none` is a deliberate, rare act.
        
        ## Crop the built-in space
        
        Every font reserves headroom above the cap height and legroom below the baseline, which is why a label never sits dead-centre in its button. `text-box: trim-both cap alphabetic` crops it. Ship as progressive enhancement; the layout must survive where the trim never happens.
        
        ## Pairing and count
        
        Two faces cover most products; three is the ceiling. Pair across categories (serif display over sans text), never within. Two lookalike grotesques read as a version-control accident. Weights and sizes obey the same economy: each exists to mark hierarchy, and stacking too many erases the distinctions they were meant to draw. Two families at the same `font-size` rarely look the same size — x-height decides — so re-tune sizes when swapping faces and match the fallback stack's x-height to avoid a jolt on load.
        
        ## When to apply
        
        Setting up a project's type; any "text feels cramped here, airy there" complaint (one-off sizes are the cause); any live number; any font-loading diff.
        
        ## Gotcha
        
        Never change font weight on hover or selection. The text reflows by a pixel and the layout shifts. Hold weight constant and signal state with color; if emphasis must move, use a variable axis at the same advance width.
        
        ## Sources
        
        - Emil Kowalski's design-engineering practice on typography, distilled by HKTITAN.
        - CSS Fonts Level 4 (`font-synthesis`, `font-optical-sizing`), CSS Inline Layout (`text-box`).
        - Related: [[typography-humanity]], [[line-length-tracking]], [[line-behavior]].
        
      • typography-humanity.md 2.3 KB
        ---
        title: typography-humanity
        summary: Why Inter and SF Pro feel default. Indie foundries to know. The single-weight rule.
        tags: [typography, fonts]
        ---
        
        # Typography humanity
        
        Inter and SF Pro are the AI defaults. They are well-designed, neutral, and so widely used that they have become invisible. That's not a virtue — it's a tell.
        
        ## What "default" looks like
        
        If a landing page uses Inter Variable from Google Fonts at the standard weights with the standard tracking, the page is competing with every other AI-generated page that did the same thing. Even if the layout is otherwise good, the typeface signals "default."
        
        ## Better choices
        
        - **PangramPangram** — Object Sans, Pangram Sans, Pangram Sans Rounded. Free for personal use.
        - **Displaay** — Grato, Migra, Neulis. Editorial energy.
        - **Grilli Type** — GT Walsheim, GT America, GT Maru. Swiss, considered.
        - **Søren Sprogø** — open-runde (free). Geometric with personality.
        - **Vercel Geist** — Geist Sans, Geist Mono. Now widely used, but still better than Inter.
        - **GitHub Mona** — well-tuned, less common.
        
        ## The single-weight rule
        
        For a project's first iteration, buy or download **one weight** of a paid font (the variable family if you need ranges). Use it for headings, body, everything. Pair with the system default for fallback.
        
        This is faster, cheaper, and produces better hierarchy through size and spacing alone. Add weights only when a real need arises.
        
        ## When to apply
        
        Anytime you reach for Inter, SF Pro, Roboto, or "system-ui" on a marketing page — stop and pick something else. For in-app product UI, Inter / SF Pro are *fine* (they're optimized for screen UI), but if you do use them, you've used your one default. Don't also use the default colors and the default Lucide icons.
        
        ## Gotcha
        
        Custom typography on tiny UI text (10–12px) often loses legibility. Geometric sans like Walsheim or Pangram look great at 14px+ but mush at 10px. Test at every size you actually use. Inter exists because it's hyper-legible at 12px — sometimes that wins.
        
        See also [[line-length-tracking]] for the rules around how to *use* whichever font you pick.
        
        ## Sources
        
        - guidelines.sh — "Inter/SF Pro are AI defaults; explore PangramPangram, Displaay, Grillitype."
        - Emil Kowalski / Vercel Geist — single-weight workflows.
        
  • scripts
    • sound-family.example.json 1.4 KB
      {
        "material": "felt mallet on a small wooden block, dry, no reverb, no tail, clean transient",
        "sounds": {
          "tick": {
            "prompt": "single very short tick, one-shot UI feedback, bright",
            "seconds": 0.5,
            "synth": { "type": "tick", "freq": 3200, "seconds": 0.07 }
          },
          "tap": {
            "prompt": "soft muted tap, short body, one-shot",
            "seconds": 0.5,
            "synth": { "type": "tap", "freq": 1400, "seconds": 0.12 }
          },
          "send": {
            "prompt": "two very quick rising notes, damped, muted, very short decay, stops immediately, confirmation",
            "seconds": 0.5,
            "synth": { "type": "chime", "notes": [523.25, 659.25], "decay": 0.08, "stagger": 0.07 }
          },
          "receive": {
            "prompt": "two soft quick descending notes, damped, muted, very short decay, stops immediately, incoming message",
            "seconds": 0.5,
            "synth": { "type": "chime", "notes": [493.88, 392.0], "decay": 0.1, "stagger": 0.09 }
          },
          "error": {
            "prompt": "short low dull thud with a slight downward pitch, rubber on wood",
            "seconds": 0.5,
            "synth": { "type": "thud", "from": 220, "to": 140, "seconds": 0.16 }
          },
          "success": {
            "prompt": "three ascending mellow notes, major, warm, short decay, completion",
            "seconds": 1.0,
            "synth": { "type": "chime", "notes": [523.25, 659.25, 783.99], "decay": 0.12, "stagger": 0.08 }
          }
        }
      }
      
    • sound-family.mjs 12.9 KB · in bundle
    • sound-sheet.mjs 20.9 KB · in bundle
    • svg-flipbook.mjs 5.7 KB · in bundle
  • SKILL.md 12.6 KB
    ---
    name: design-engineering
    description: "Load when reviewing UI code, designing a component or page layout, picking an easing curve or transition pattern, deciding whether something should animate at all, choosing an avatar/typography/color system, building an OKLCH palette or fixing contrast, writing UI copy or error messages, auditing for AI-default tells, code tells, or a11y misses, making a screen look less generated, consuming a project's DESIGN.md or .design tokens, writing design-system docs for agents, giving feedback through Agentation or a similar annotation tool, asking why a UI feels flat or unfinished, judging when delight earns its weight, deciding whether an interaction should make a sound, designing or generating UI sound effects (ElevenLabs or open-source), syncing sound to animation, scoring a product launch video or deriving its sound stem from the motion, cutting a multi-scene launch video so it reads as one camera move, creating or animating SVG (icons, mascots, logo reveals, video-to-vector), prototyping several directions behind a picker, building a tool instead of re-prompting for an artifact, or choosing which design skill should own a job. Distills Emil Kowalski, Benji Taylor, Jakub Antalik (transitions.dev), guidelines.sh, Vercel design guidelines, Ben DC, DiceBear, lucide-animated, Google Labs design.md, AgentsORG .design, Agentation, Index (index.how — Emil Kowalski and Glenn Carstens-Peters), Apple's audio-haptic principles, Josh Comeau's use-sound, bruno (@tvnxty) on launch-video sound, and Adrian Abelarde's video-to-vector pipeline."
    license: MIT
    compatibility: Agent-agnostic. Pairs with Obsidian as a vault, Agentation for click-to-annotate review, and any coding agent that reads SKILL.md (Claude Code, Cursor, Codex, Windsurf, Aider, Cline, Gemini, 18+ via skills.sh).
    metadata:
      author: HKTITAN
      version: "2.4.0"
      graph: true
      subagents: agents/
      soul: SOUL.md
    ---
    
    # design-engineering
    
    > This skill is a graph, not a file. The body below is the **Map of Content**. Follow the `[[wikilinks]]` only as needed — that's the whole point of progressive disclosure.
    
    ## How to use this skill
    
    0. `/design-engineering` is a router before it is a library: `[[skill-router]]` resolves the design contract, classifies the phase (undecided → build → refine → check → name), and hands the job to one owner — a node here, a subagent, or an installed companion skill (AgentsORG `design`, impeccable, HyperFrames, ElevenLabs, transitions-dev, the shadcn CLI). One or two owners, never five.
    1. Route first: `[[routing-table]]` maps intent → entry node for most single questions. If two intents blur, `[[disambiguation]]` names the tiebreaker. Multi-cluster jobs follow `[[stacking-chains]]`.
    2. Otherwise scan the MOCs below, pick the cluster, and open its `MOC-*.md` for the atomic nodes under it.
    3. Read only the atomic nodes that match. Each node is standalone — you don't need siblings.
    4. Before reviewing UI code, also load `[[gotchas]]` and `[[pov]]`.
    
    If the user asks for a UI code review, use the format defined in `[[review-format]]` and scan against `[[review-checklist]]`.
    
    ## Philosophy — when, where, and why polish matters
    
    How to think about taste, delight, and the difference between marketing and product UI.
    
    - `[[MOC-philosophy]]` → [[taste-is-trained]], [[unseen-details-compound]], [[beauty-is-leverage]], [[delight-impact-curve]], [[feeling-right]], [[marketing-vs-product-ui]], [[states-are-the-work]], [[data-is-content]], [[dependency-discipline]], [[pointing-beats-describing]], [[articulate-precisely]]
    
    ## Motion — when something should move, and how
    
    The largest cluster. Animation is the most overused tool in modern UI; this cluster tells you when *not* to animate as much as when to.
    
    - `[[MOC-motion]]` → [[animation-decision-framework]], [[easing-curves]], [[duration-table]], [[spring-animations]], [[transform-opacity-only]], [[performance-discipline]], [[transform-mastery]], [[clip-path-tricks]], [[never-scale-from-zero]], [[gesture-momentum]], [[stagger-choreography]], [[prefers-reduced-motion]], [[fly-not-teleport]], [[responsive-feedback]], [[sonner-principles]], [[debugging-animations]], [[lerp-breathing]], [[morphing-icons]], [[shared-letter-morph]], [[hover-default-imperative]], [[tray-rules]], [[css-conventions]], [[launch-video-seams]]
    
    ## Transition techniques — Jakub Antalik's catalog
    
    A subset of motion focused on **canonical** transitions for common UI archetypes. Where the Motion MOC teaches principles, these nodes teach implementation. From transitions.dev.
    
    - [[cross-blur-transitions]] — Pair opacity 0↔1 with `filter: blur(2px) ↔ 0` to mask imperfect crossfades.
    - [[compose-subtract-asymmetry]] — Enter with more properties than exit. Disappearance feels soft, not reversed.
    - [[distance-falloff-propagation]] — Per-element lift via `lift * pow(falloff, distance)` for grouped hover.
    - [[multi-segment-shake]] — Form-error shake at 0%, 28.57%, 57.14%, 78.57%, 100% over 280ms.
    
    ## Sound — when an interface should be heard, and how
    
    The sense the web forgot. Product UI is silent by default; a launch video is the reverse. This cluster decides which, designs one material family, syncs it to motion, and generates the files — ElevenLabs on demand, or open-weight / procedural / CC0 without a key.
    
    - `[[MOC-sound]]` → [[sound-decision-framework]], [[sound-motion-sync]], [[sound-palette]], [[sound-spec]], [[sound-playback-web]], [[sound-generation-elevenlabs]], [[sound-generation-open-source]], [[launch-video-sound]], [[sound-from-motion]]
    
    ## SVG — creating, animating, morphing, and vectorizing
    
    The only image format that is also an interface. Author it on the token system, move it with the engine its home allows, morph it by the command-count rule, and turn flat clips into editable mascots.
    
    - `[[MOC-svg]]` → [[svg-creation]], [[svg-animation]], [[svg-path-morphing]], [[video-to-vector-pipeline]]
    
    ## Typography — humanizing text
    
    The font defaults of the AI era are tells. Better choices and the rules around them.
    
    - `[[MOC-typography]]` → [[typography-humanity]], [[line-length-tracking]], [[type-scale-and-rhythm]], [[line-behavior]]
    
    ## Surface — color, shadow, radius, dark mode, imperfection
    
    The "background" choices that most decks of guidelines skip. They're load-bearing for feel.
    
    - `[[MOC-surface]]` → [[color-monochromatic]], [[color-scales-oklch]], [[dark-mode]], [[shadows-whisper]], [[depth-and-nesting]], [[border-radius]], [[visual-imperfection]], [[contrast-and-color-scheme]]
    
    ## Layout — page-level grids, viewports, sticky chrome, URL-as-state, marketing surfaces
    
    The container, not the component. Marketing especially benefits from custom-per-viewport thinking; product apps benefit from URL-driven state.
    
    - `[[MOC-layout]]` → [[viewport-custom-design]], [[sticky-and-scroll-tells]], [[url-as-state]], [[marketing-surface-rules]]
    
    ## Components — buttons, hovers, states, cards, forms, avatars, icons, a11y, copy, APIs
    
    The atoms users actually touch. Their behavior is where craft lives.
    
    - `[[MOC-components]]` → [[hover-states-subtle]], [[empty-loading-states]], [[icon-systems]], [[cards-design]], [[forms-validation]], [[forms-behavior]], [[touch-and-focus]], [[ui-polish-pass]], [[component-api-design]], [[avatar-systems]], [[interaction-personality]], [[accessibility-baseline]], [[optimistic-updates]], [[copy-voice]]
    
    ## Anti-patterns — what AI-generated UI gives away
    
    What looks "AI default" and what to do instead. High-value because it's about deletion, not addition.
    
    - `[[MOC-anti-patterns]]` → [[ai-default-tells]], [[unslop-pass]], [[content-authenticity]], [[copy-tells]], [[code-tells]]
    
    ## Meta — review format, design.md, Agentation, evals, per-installer files
    
    Procedural rules and growing files. `[[review-format]]` is mandatory when doing UI code reviews.
    
    - [[skill-router]] — What `/design-engineering` does first: resolve the contract, classify the phase, hand off to one owner — a node, a subagent, or an installed companion (AgentsORG `design`, impeccable, HyperFrames, ElevenLabs, transitions-dev, shadcn CLI).
    - [[routing-table]] — Intent → entry-node router with the four postures (build / judge / decide / name). Open this before anything else on a single question.
    - [[prototype-picker]] — Undecided? Three to five genuinely different versions behind a live picker, numbers on controls, then write the decision and delete the harness.
    - [[build-a-tool]] — "Closer, but not quite" twice? Stop re-prompting; diagnose the gap and build the small tool that produces the artifact.
    - [[vibe-to-generator]] — Only a mood, no spec? Research the visual language into rules, then a seeded generator with knobs and a save button.
    - [[design-system-docs]] — Writing a DESIGN.md or docs folder an agent can follow: inventory the code, when/alternative, closed variant sets, correct/incorrect examples, verify by prompting.
    - [[skill-writing-rules]] — Encode process, write the why, be strict, earn every line, one aspect per skill, test by running. Governs every node here and [[pov]].
    - [[design-benchmarks]] — DesignBench's generation / edit / repair tasks and Design Arena's pairwise votes, and this skill's own eval suite that borrows both.
    - [[disambiguation]] — The questions that blur together (four motion questions, three dark-mode owners, two layout-shift owners) and which node owns each.
    - [[stacking-chains]] — Ordered node chains for multi-step jobs (new screen, feel-better pass, design system, marketing site, a11y pass).
    - [[review-format]] — Required output format for UI critiques (Before | After | Why markdown table).
    - [[review-checklist]] — Thirteen-row audit to scan against before signing off on a UI review.
    - [[design-vocabulary]] — The precise word for each design phenomenon and where the skill goes deeper. The lexicon behind [[articulate-precisely]]; seeded by Index (Emil Kowalski & Glenn Carstens-Peters).
    - [[using-design-file]] — How to consume a project's `.design` contract (schema `design.v1`). Machine-readable tokens, committed intent, executable policy — and it outranks this skill's defaults.
    - [[using-design-md]] — How to consume a project's DESIGN.md (Google Labs spec). Tokens are normative; prose is context.
    - [[agentation-workflow]] — Install and use [Agentation](https://www.agentation.com) for click-to-annotate design review. Two-session critique-then-fix workflow.
    - [[cross-model-testing]] — How to test description / routing changes across GPT, Claude Opus, and Claude Sonnet.
    - [[gotchas]] — lived failures, appended as the agent trips up.
    - [[pov]] — author/installer's opinions and taste calls that override defaults. Edit this when you fork.
    - [[animations-dev-curriculum]] — external pointer to Emil's course; don't duplicate.
    
    ## Workflow subagents — sibling `agents/` directory
    
    Nine narrow-purpose subagents live in `agents/` next to this file. Spawn one when its specific workflow matches the user's ask. Each subagent loads its own slice of the graph and returns to the main agent with a structured result.
    
    - [[ui-reviewer]] — Runs the `[[review-format]]` table + `[[review-checklist]]` audit on a UI snippet.
    - [[motion-auditor]] — Animation/transition critique against the motion cluster + transitions.dev nodes.
    - [[anti-pattern-scanner]] — Scans for AI-default tells and content-authenticity misses.
    - [[agentation-fix-loop]] — Session-2 fix side of `[[agentation-workflow]]`. Reads MCP annotations and applies fixes.
    - [[design-md-consumer]] — Reads a project's DESIGN.md and threads its tokens through generated UI per `[[using-design-md]]`.
    - [[pov-curator]] — Helps the installer fork `[[pov]]` and append to `[[gotchas]]` after a real failure.
    - [[sound-designer]] — Runs the sound cluster end to end: decide, palette, generate (`scripts/sound-family.mjs` for a product family, `scripts/sound-sheet.mjs` for a video stem derived from the motion), spec, wire up. Returns a sound map.
    - [[svg-creator]] — Authors or refactors an SVG asset: grid, named groups, token colors, SVGO with the right flags, accessible name.
    - [[svg-animator]] — Animates an SVG or builds a vector flipbook from frames (`scripts/svg-flipbook.mjs`), engine chosen by where the file lives.
    
    See `agents/README.md` for the full directory and selection guide.
    
    ## Identity — SOUL.md
    
    The repo also ships a [`SOUL.md`](../../SOUL.md) at root — voice, stance, and taste lineage for any agent operating in this skill. AGENTS.md is the "what/how"; SOUL.md is the "who/why". Read it once per session if your harness doesn't auto-inject it.
    
    ## Tax check
    
    Every sentence above costs tokens in every session. If you find yourself adding a sentence, ask: *would the agent get this wrong without it?* If no, delete it. If yes, it belongs in an atomic node, not here.
    
    — HKTITAN, 2026.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related