diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md index 25044b37..5288ffed 100644 --- a/.agents/skills/impeccable/SKILL.md +++ b/.agents/skills/impeccable/SKILL.md @@ -148,6 +148,8 @@ If someone could look at this interface and say "AI made that" without doubt, it | `layout [target]` | Enhance | Fix spacing, rhythm, and visual hierarchy | [reference/layout.md](reference/layout.md) | | `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) | | `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) | +| `finesse [target]` | Enhance | Refine timing, easing, and choreography of existing interactions | [reference/finesse.md](reference/finesse.md) | +| `juicy [target]` | Enhance | Add game-feel: cursor, drag, scroll, awareness, and optional sound | [reference/juicy.md](reference/juicy.md) | | `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) | | `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) | | `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) | diff --git a/.agents/skills/impeccable/reference/finesse.md b/.agents/skills/impeccable/reference/finesse.md new file mode 100644 index 00000000..59798963 --- /dev/null +++ b/.agents/skills/impeccable/reference/finesse.md @@ -0,0 +1,951 @@ +> **Additional context needed**: existing motion conventions in codebase, `prefers-reduced-motion` support state, framework (Tailwind / vanilla CSS / CSS-in-JS). + +Refine the quality of interactions that already exist. Not adding new animations — correcting bad timing, replacing accidental defaults, choreographing state changes that are currently abrupt. The Japanese concept of *kodawari*: meticulous attention to things that don't need to be there, that most users won't consciously notice, but whose absence you always feel. + +The gap this fills: `animate` adds motion where there is none. `polish` fixes visual alignment and design-system drift. `delight` adds personality. `finesse` operates on *live interactions*, making the ones already there feel intentional. + +--- + +## Register + +**Brand**: Apply finesse broadly — entrance choreography, page-level transitions, and micro-interactions contribute to the voice. Motion is part of the design identity. + +**Product**: Apply finesse at the interaction level, not the page level. Users are in a task; they don't wait for page-load choreography. Every interaction should give feedback; no interaction should feel jarring or unfinished. + +--- + +## Scope + +**Does**: timing, easing curves, entrance/exit choreography, micro-feedback, state quality (hover, focus, active, loading, empty), asymmetric enter/exit, prefers-reduced-motion coverage. + +**Does not**: layout, typography, color, visual hierarchy — delegate those to `$impeccable layout`, `$impeccable typeset`, `$impeccable colorize`. + +--- + +## Soft Dependencies + +Before starting, check if any of these outputs exist in the session or codebase: + +- **critique report**: use flagged "confusing" or "jarring" areas to prioritize patterns. +- **audit report**: use a11y gaps (missing focus-visible, no reduced-motion) to drive Phase 2 order. +- **polish notes**: avoid re-touching areas already polished; focus on what polish left. + +If none exist, proceed to Phase 1 with no prior context. + +--- + +## Hard Block: `prefers-reduced-motion` + +**Never commit a motion pattern without a reduced-motion fallback.** Before applying any pattern from Phase 2, verify the target file has or will receive: + +```css +@media (prefers-reduced-motion: reduce) { + /* fallback: typically opacity-only, no spatial movement */ +} +``` + +In Tailwind: `motion-reduce:transition-none`, `motion-reduce:scale-100`, `motion-reduce:translate-y-0`. + +Vestibular disorders affect ~35% of adults over 40. This is not optional. If the file lacks reduced-motion support and adding the patterns would introduce spatial movement, add the media query before applying the pattern. + +--- + +## Phase 1: Audit + +Catalog all state changes in the target. Do not skip this phase. Applying patterns without an audit is how you introduce motion debt instead of removing it. + +### What to look for + +| Signal | What it means | +|--------|--------------| +| `transition-all` | Grabs everything including layout — replace with explicit properties | +| `ease`, `linear`, no easing | CSS defaults that don't feel intentional | +| Instant show/hide (`display: none` toggle, conditional render) | Abrupt — needs exit/entry | +| `ease-in-out` on both enter and exit | Symmetric — should be asymmetric | +| Duration > 500ms on feedback | Laggy — users will feel the wait | +| Duration < 80ms on reveals | Too fast to track — increase to 150ms | +| Missing `active:` state on interactive elements | No press feedback | +| Missing `focus-visible:` | Keyboard users see nothing | +| Bounce or elastic easing | Dated; remove | +| Same duration on enter and exit | Exit should be ~75% of enter | +| No `prefers-reduced-motion` coverage | Accessibility violation | + +### Audit output format + +Produce a prioritized list before applying anything: + +``` +FINESSE AUDIT — [ComponentName] + +P0 (blocking): transition-all in 3 places, no prefers-reduced-motion +P1 (major): instant show/hide on dropdown, no active: state on CTA button +P2 (minor): symmetric enter/exit on modal (both 300ms ease-in-out) +P3 (polish): hover lift missing on clickable cards +``` + +Apply P0 before P1, P1 before P2. Never apply everything at once — one pattern at a time, diff stays small. + +--- + +## Phase 2: Apply + +### How to read each pattern + +Every pattern below has the same structure: +- **When**: use this pattern. +- **When not**: context where this pattern is wrong. +- Short reasoning sentence (the *why*). +- **Before / After**: worked code diff. +- **Signal**: what correct feels like. + +--- + +### Family 1: Exits + +#### Chain-of-thought: how to decide which exit pattern + +Before picking a pattern, answer in order: + +1. **Does the element occupy flow space, or float above content?** + - Occupies flow (chip, tag, list item, inline error) → choreographed exit (opacity then height collapse). + - Floats (tooltip, popover, toast, modal backdrop) → fade only (no height collapse needed). + +2. **Is the exit triggered by user action or external state change?** + - Direct action (user clicked Remove) → start immediately, aggressive ease-in (short). + - External change (timeout, server push) → gentle ease-in, give the eye time to track. + +3. **Is there a matching entry?** + - Yes → exit is ~75% of entry duration (from motion-design.md rule). + - No → use 150ms as default. + +4. **prefers-reduced-motion?** → Always provide opacity-only fallback. + +If you can't answer 1–3 without guessing, read the component context before applying. + +--- + +#### Choreographed exit (for flow-occupying elements) + +**When**: removing chips, tags, list items, inline alerts, validation messages — elements that occupy space in the document flow. + +**When not**: overlays, tooltips, popovers, toasts that float over content. + +**Reasoning**: disappearing without collapsing leaves a gap that jumps — the element vanishes but space doesn't. Content around it lurches. Opacity fades first so the user sees the intent; then height collapses so surrounding content slides smoothly. + +**Before** (typical AI output): +```tsx +// v-if / conditional render — element just vanishes +{isVisible && {label}} +``` + +**After** (Vue example): +```vue + + {{ label }} + +``` + +**Diff commentary**: +- Opacity goes first (200ms enter, 150ms exit — asymmetric). +- Height collapse via `max-height` avoids animating `height: auto` directly. +- `overflow-hidden` prevents content peeking during collapse. +- Exit is 75% of enter duration (200 → 150ms). +- `ease-out-expo` on enter (starts fast, decelerates into place). `ease-in` on exit (starts slow, accelerates away). + +**Signal**: element leaves, surrounding content shifts smoothly. User doesn't see a jump or a void. + +--- + +#### Simple fade (for floating elements) + +**When**: tooltips, popovers, dropdowns, toast notifications, modal backdrops. + +**When not**: anything that occupies document flow — use choreographed exit instead. + +**Reasoning**: floating elements don't affect surrounding layout, so height choreography adds complexity with no visual benefit. + +**Before**: +```css +.tooltip { display: none; } +.tooltip.visible { display: block; } +``` + +**After** (Tailwind + data-state pattern): +```tsx +
+``` + +**Signal**: tooltip appears and disappears. Eye tracks it. No snap-in, no snap-out. + +--- + +#### Collapse vertical (for accordions and expandable sections) + +**When**: accordion panels, filter groups, validation error messages appearing/disappearing, details sections. + +**When not**: items in a flat list that are being *removed* — use choreographed exit. This pattern is for height toggling on the same element. + +**Reasoning**: `height: auto` can't be transitioned natively. Grid trick avoids JS measurement. + +**After** (CSS-only): +```css +.expandable { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 250ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.expandable.open { + grid-template-rows: 1fr; +} + +.expandable-inner { + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .expandable { transition: none; } +} +``` + +**Signal**: section opens with content arriving from top. No sudden appearance, no measurement jank. + +--- + +#### Slide-out directional (for drawers and side panels) + +**When**: drawers, side panels, sheet overlays, off-canvas menus — elements with a clear origin edge. + +**When not**: inline content, dialogs without a directional relationship. + +**Reasoning**: direction must match the panel's origin. A right-side drawer slides right on exit. Sliding left on exit breaks spatial model. + +**After** (right panel exit): +```css +.panel-enter { + animation: slide-in-right 300ms cubic-bezier(0.16, 1, 0.3, 1); +} +.panel-exit { + animation: slide-out-right 200ms cubic-bezier(0.7, 0, 0.84, 0); +} + +@keyframes slide-in-right { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes slide-out-right { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .panel-enter, .panel-exit { animation: none; opacity: 1; } +} +``` + +**Signal**: panel arrives from its source, leaves toward its source. Spatial model is consistent. + +--- + +### Family 2: Entries + +#### Chain-of-thought: how to decide which entry pattern + +1. **Is this a list of items or a single element?** + - List of items → stagger (20–40ms per item, max 6–8 items staggered). + - Single element → fade-in or snap-in depending on context. + +2. **Does the element appear because of a user action or because data loaded?** + - User action (opened a dropdown, expanded a panel) → snap-in (scale + opacity, snappy). + - Data loaded (search results, list items rendered) → stagger fade. + - Route change → fade-in or dissolve depending on register. + +3. **Is there a corresponding exit?** + - Yes → enter is the inverse ease (exit ease-in → enter ease-out), ~33% longer than exit. + +4. **prefers-reduced-motion?** → fade only, no spatial movement. + +--- + +#### Stagger (for lists of items) + +**When**: search results, card grids, permission lists, tag collections, notification feeds. + +**When not**: tables with >10 rows (fatigue), single-item reveals, page-level content. + +**Reasoning**: stagger reveals the list as a set with direction, not a batch dump. User perceives structure. + +**Before**: +```tsx +{items.map(item => )} +``` + +**After** (Tailwind + CSS custom property): +```tsx +{items.map((item, i) => ( + +))} +``` + +```css +@keyframes fade-in-up { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.animate-fade-in-up { + animation: fade-in-up 200ms cubic-bezier(0.16, 1, 0.3, 1) both; + animation-delay: calc(var(--i, 0) * 30ms); +} +``` + +**Diff commentary**: +- `--i` custom property drives stagger delay without JS. +- 30ms per item × 8 items = 240ms total. Cap staggered count at 8; items 9+ appear simultaneously. +- `both` fill mode: element starts in `from` state (opacity 0), ends in `to` (no snap-back). +- `8px` translateY: subtle. More than 12px and it looks like a slide; less than 4px and it's imperceptible. + +**Signal**: list arrives with direction and rhythm. Not a screen of cards materializing at once. + +--- + +#### Snap-in (for triggered overlays) + +**When**: dropdowns, popovers, context menus, dialogs — elements that appear in response to a direct user action. + +**When not**: list items, page content, passive notifications. + +**Reasoning**: snap-in confirms the user's action. The element must arrive fast — if it's slower than ~200ms, users wonder if the action registered. + +**After** (Tailwind): +```tsx + +``` + +```css +@keyframes scale-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes scale-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.95); } +} + +.animate-scale-in { animation: scale-in 150ms cubic-bezier(0.16, 1, 0.3, 1); } +.animate-scale-out { animation: scale-out 100ms cubic-bezier(0.7, 0, 0.84, 0); } +``` + +**Diff commentary**: +- Scale from 0.95 (not 0.5, not 0.8) — subtle enough to feel physical, not theatrical. +- Exit is 100ms (vs 150ms enter) — faster exit = snappier feel. +- `origin-top` anchors scale to origin point. Use `origin-top-left` for right-edge dropdowns. +- No bounce, no elastic. + +**Signal**: dropdown appears. User doesn't register "an animation happened" — they register "it's there." + +--- + +### Family 3: Feedback + +#### Chain-of-thought: how to decide which feedback pattern + +1. **What is the user doing?** + - Pressing a button → press feedback. + - Toggling a switch or checkbox → toggle snap. + - Hovering a card or navigable item → hover lift (only if truly clickable). + - Completing an action (save, copy, delete) → ripple/confirmation. + +2. **How fast must feedback be?** + - Press feedback: ≤100ms (user perceives it as simultaneous with tap). + - Toggle snap: 150ms (spring feel). + - Confirmation ripple: 600ms hold (enter + hold + exit ≈ 1s total). + +3. **Is this touch or mouse?** + - Touch: hover states don't apply. Active states + haptic (where available) do. + - Mouse: hover states are the primary affordance signal. + +--- + +#### Press feedback (for all interactive buttons) + +**When**: every primary button, secondary button, IconButton, CTA, submit. + +**When not**: links in prose, dense list items, pagination controls. + +**Reasoning**: 80ms is the threshold of perceived simultaneity. Press feedback below 100ms feels like a physical button. Above 200ms, users wonder if the tap registered. + +**Before** (typical AI output): +```tsx + +``` + +**After**: +```tsx + +``` + +**Diff commentary**: +- `transition-all` → `transition-[transform,background-color]` — prevents accidental capture of layout properties (margin, padding, border-radius). +- `active:scale-[0.97]` — 3% reduction. More = theatrical; less = imperceptible. +- `duration-100` — micro-interaction. Near the 80ms threshold of perceived simultaneity. +- `active:bg-blue-700` — color darkens on press, reinforcing press state visually. +- Reduced-motion: no scale, instant color transition. + +**Signal**: user presses the button and doesn't consciously think about animation. But remove it and they notice something's missing. + +--- + +#### Toggle snap (for switches and checkboxes) + +**When**: toggle switches, checkboxes that confirm a state change (not selection in a list). + +**When not**: bulk selection checkboxes in tables (too many state changes; stagger would be overwhelming). + +**Reasoning**: a switch communicates binary state — it should *snap* to position, not drift. The motion is faster than a reveal and uses a different easing. + +**After** (CSS custom switch): +```css +.switch-thumb { + transition: transform 150ms cubic-bezier(0.65, 0, 0.35, 1); +} + +.switch:checked .switch-thumb { + transform: translateX(20px); +} + +@media (prefers-reduced-motion: reduce) { + .switch-thumb { transition: none; } +} +``` + +**Signal**: switch snaps to new position. Feels like a physical toggle, not a slow slide. + +--- + +#### Hover lift (for navigable cards) + +**When**: cards or list items that navigate somewhere on click. The lift signals "this is clickable." + +**When not**: dense list items (the lift becomes noise), non-navigable cards (misleads), text links. + +**Reasoning**: shadow + micro-translate communicates "this element rises above the surface" — a physical affordance for clickability. + +**After**: +```tsx + +``` + +**Diff commentary**: +- `2px` translateY (`-translate-y-0.5`) — visible but not dramatic. +- `shadow-md` → one step up. Don't jump to `shadow-xl`; that's for modals. +- `motion-reduce`: keep the shadow (visual affordance), remove the translate (spatial movement). + +**Signal**: user hovers a card and perceives "I can click this." Hover on non-navigable cards would be misleading — hence the "when not." + +--- + +#### Confirmation ripple (for completed actions) + +**When**: copy-to-clipboard, save, send, upload complete, delete confirmation — one-shot actions where success isn't otherwise visible. + +**When not**: high-frequency actions (typing completion, filter changes), destructive actions that need a confirmation step. + +**Reasoning**: without a ripple, the user doesn't know the action registered. With a ripple, the success state announces itself and fades — ~1s total (enter + confirm + exit) feels right: long enough to read, short enough not to linger. + +**After** (React with a temporary state): +```tsx +const [copied, setCopied] = useState(false); + +const handleCopy = () => { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 600); +}; + + +``` + +**Signal**: user clicks Copy. "✓ Copied" appears briefly and fades. No toast needed for inline confirmations. + +--- + +### Family 4: Coordinated Sequences + +#### Chain-of-thought: how to sequence multi-element transitions + +When multiple elements change together, they should not change simultaneously. + +**Rule**: lead with context, follow with content. +- Context elements (backdrop, overlay, container) appear first. +- Content elements (modal body, panel content, list items) appear second, with a 50–80ms delay. + +Why: showing content before context gives the user nothing to orient against. The container arriving first establishes *where* content will be before it appears. + +**Rule for exits**: reverse the order. +- Content exits first (the important part is leaving). +- Context (backdrop, container) exits after a 50ms delay. + +--- + +#### Backdrop before content (modal entry/exit) + +**When**: any overlay with a backdrop (modal, dialog, sheet). + +**When not**: overlays without backdrop (tooltips, popovers). + +**After**: +```tsx +// Backdrop — 150ms +
+ +// Content — 200ms, 50ms delay +
+``` + +**Signal**: backdrop arrives. Eye adjusts. Modal content arrives into that context. Feels staged, not dumped. + +--- + +#### Skeleton → content (crossfade without layout shift) + +**When**: any component that loads async data where the skeleton has a fixed shape. + +**When not**: streaming content, variable-height content where exact skeleton height is unknown. + +**Reasoning**: the skeleton and real content must have identical dimensions. If they don't, the crossfade causes layout shift. This is worse than no animation. + +**After**: +```tsx +
+ {/* Skeleton — fades out when data arrives */} +
+ +
+ + {/* Content — fades in when data arrives */} +
+ +
+
+``` + +**Critical**: `absolute inset-0` on skeleton means it never shifts layout. Both layers occupy the same space. `pointer-events-none` on the exiting skeleton prevents click interception. + +**Signal**: content loads. Users sees a smooth crossfade, not a height jump. + +--- + +#### Loading inline (button holds its width) + +**When**: any button that triggers an async action and shows a loading state. + +**When not**: buttons that navigate synchronously. + +**Reasoning**: a button changing size during loading shifts layout. If the CTA is 180px, it should be 180px whether it shows a label or a spinner. + +**After**: +```tsx + +``` + +**Signal**: user clicks. Spinner appears. Button doesn't move. The rest of the form doesn't jump. + +--- + +#### Error shake (validation feedback) + +**When**: form submission with validation errors. + +**When not**: on every keystroke (too much). Only on submission or on blur-and-resubmit. + +**Reasoning**: shake is universal language for "wrong." 3 cycles, 4px amplitude, 300ms total. + +**After**: +```css +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-4px); } + 40%, 80% { transform: translateX( 4px); } +} + +.field-error { + animation: shake 300ms ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .field-error { + animation: none; + outline: 2px solid red; + outline-offset: 2px; + } +} +``` + +**Signal**: user submits invalid form. Field shakes briefly. Error message expands. User knows exactly which field failed without reading an error banner. + +--- + +### Family 5: Data Transitions + +#### Animated number (counters and metrics) + +**When**: dashboards, counters, numeric badges that update — cases where the delta between old and new value is semantically meaningful. + +**When not**: input fields, IDs, codes, any number that isn't a meaningful quantity. + +```ts +// Minimal JS tween (no library needed) +function tweenNumber(from: number, to: number, el: HTMLElement, duration = 600) { + const start = performance.now(); + const update = (now: number) => { + const t = Math.min((now - start) / duration, 1); + const eased = 1 - Math.pow(1 - t, 4); // ease-out-quart + el.textContent = String(Math.round(from + (to - from) * eased)); + if (t < 1) requestAnimationFrame(update); + }; + requestAnimationFrame(update); +} +``` + +**Reduced-motion**: check `matchMedia('(prefers-reduced-motion: reduce)')` — if true, set value immediately. + +**Signal**: metric changes from 1,240 to 1,312. User sees the number counting up. Communicates live data, not a static snapshot. + +--- + +#### Content swap (value changes in place) + +**When**: a displayed value changes (user selects a variant, locale changes a price, status updates). + +**When not**: values changing faster than 250ms (would look flickery). + +**After**: +```css +.value-swap-leave { animation: fade-out-up 80ms cubic-bezier(0.7, 0, 0.84, 0) both; } +.value-swap-enter { animation: fade-in-up 80ms cubic-bezier(0.16, 1, 0.3, 1) both; } + +@keyframes fade-out-up { + to { opacity: 0; transform: translateY(-4px); } +} + +@keyframes fade-in-up { + from { opacity: 0; transform: translateY(4px); } +} + +@media (prefers-reduced-motion: reduce) { + .value-swap-leave, .value-swap-enter { animation: none; } +} +``` + +**Signal**: price changes from $12 to $15. Old value slides up and fades; new value slides up and arrives. User sees the change clearly. + +--- + +#### Empty state transition + +**When**: a list transitions from populated to empty (items deleted, filter returns no results). + +**When not**: initial page load into an empty state — that's onboard territory. + +**After**: +```tsx +// import { AnimatePresence, motion } from 'framer-motion'; +// import type { CSSProperties } from 'react'; + +// Items exit with stagger, then empty state enters. +// ListItem and EmptyState must be motion.* components (or wrapped in motion.div) +// with exit props so AnimatePresence can animate them out. +// CSS-only alternative: apply the stagger pattern from Family 2 and v-if the empty state. +const isEmpty = items.length === 0; + + + {items.map((item, i) => ( + + + + ))} + {isEmpty && ( + + + + )} + +``` + +**Signal**: user deletes last item. Items exit with stagger. Empty state fades in. User sees a narrative: "items gone → now empty." No jarring teleport. + +--- + +### Family 6: Easing Quality + +#### Audit existing curves + +**When**: always — this is the first thing to fix before any other pattern. + +**What to search for and replace**: + +| Replace | With | Reason | +|---------|------|--------| +| `transition-all` | `transition-[transform,opacity]` or explicit properties | Catches layout properties accidentally | +| `ease` (default) | `cubic-bezier(0.16, 1, 0.3, 1)` (expo-out) | Default ease is a compromise, not a choice | +| `ease-linear` | Remove or use only for infinite loops (spinners) | Linear motion looks mechanical | +| `ease-in-out` on enter | `ease-out` variant | Enters should decelerate into rest | +| `ease-in-out` on exit | `ease-in` variant | Exits should accelerate away | +| Same duration for enter and exit | Exit = 75% of enter | Exits should feel snappier | +| `transition: all 300ms bounce` | `cubic-bezier(0.16, 1, 0.3, 1)` | Bounce easing looks dated | + +--- + +#### Asymmetric enter/exit easing + +**When**: any element with both an enter and exit animation. + +**Reasoning**: objects entering our field of view decelerate as they settle (ease-out). Objects leaving accelerate away (ease-in). Symmetric easing (same curve on both) ignores physics and feels flat. + +**Canonical curves** (re-use from motion-design.md): +```css +--ease-enter: cubic-bezier(0.16, 1, 0.3, 1); /* expo-out: confident deceleration */ +--ease-exit: cubic-bezier(0.7, 0, 0.84, 0); /* expo-in: accelerates away */ +--ease-toggle: cubic-bezier(0.65, 0, 0.35, 1); /* in-out: for state toggles only */ +``` + +--- + +#### Duration by distance + +**When**: any element that moves spatially (slides, translates, expands). + +| Motion distance | Duration | +|----------------|----------| +| Micro (button press, icon hover) | 80–100ms | +| Short (tooltip, badge) | 150ms | +| Medium (dropdown, popover, modal enter) | 200–250ms | +| Long (drawer, full-panel) | 300–350ms | +| Page-level (route change) | 350–500ms | + +An element traveling 8px should not take 500ms. An element traveling 400px should not take 100ms. + +--- + +## Worked Example + +A full audit-to-application walkthrough on a `NotificationBanner` component. + +### Starting point + +```tsx +// NotificationBanner.tsx — before finesse +export function NotificationBanner({ message, type, onClose }: Props) { + return ( +
+ +

{message}

+ +
+ ); +} +``` + +### Phase 1 Audit + +``` +FINESSE AUDIT — NotificationBanner + +P0: transition-all — grabs border, padding, border-radius on color change; risk of layout reflow +P0: no prefers-reduced-motion coverage anywhere +P1: onClose triggers instant removal — no exit animation +P1: close button has no active: state, only opacity hover (no press feedback) +P1: focus-visible ring missing on close button +P2: ease default (not intentional) on transition +P3: banner entrance is instant (appears with no enter) +``` + +### Decision: what to apply first + +1. P0: Replace `transition-all` with `transition-[background-color,border-color]`. +2. P0: Add `prefers-reduced-motion` wrapper. +3. P1: Wrap in `` for choreographed exit (it occupies flow space). +4. P1: Add `active:scale-[0.97]` + `focus-visible:ring-2` to close button. +5. P3: Add fade-in entry. + +### After finesse + +```tsx +// import { Transition } from '@headlessui/react'; // or use from your framework +// NotificationBanner.tsx — after finesse +export function NotificationBanner({ message, type, isVisible, onClose }: Props) { + return ( + +
+ +

{message}

+ +
+
+ ); +} +``` + +### Verification checklist + +- [ ] Click dismiss: banner fades out while collapsing height. Surrounding content slides up smoothly. +- [ ] Tab to close button: focus ring visible. +- [ ] Press close button: 3% scale-down visible on active. +- [ ] Switch `type` prop while visible: color transitions, no layout jump. +- [ ] Set OS reduced-motion: no spatial movement. Opacity crossfades acceptable. Color change instant. +- [ ] Banner re-appears (new notification): fade-in entry works. + +--- + +## NEVER + +- Apply `transition-all` — always be explicit about which properties to transition. +- Use bounce or elastic easing — they draw attention to the animation, not the content. +- Animate `width`, `height`, `top`, `left`, `margin` directly — use transform, max-height, grid-template-rows, or FLIP. +- Apply all patterns at once — one diff at a time. Motion debt compounds; so does motion noise. +- Skip prefers-reduced-motion — this is an a11y violation, not a nice-to-have. +- Add hover states that also fire on touch — `@media (hover: hover)` guards hover-only affordances. +- Use the same duration on enter and exit — exit is ~75% of enter. + +When the interactions feel intentional without the user knowing why, hand off to `$impeccable polish` for the final pass. diff --git a/.agents/skills/impeccable/reference/juicy.md b/.agents/skills/impeccable/reference/juicy.md new file mode 100644 index 00000000..a814a6ca --- /dev/null +++ b/.agents/skills/impeccable/reference/juicy.md @@ -0,0 +1,754 @@ +> **Additional context needed**: brand register (consumer SaaS vs institutional), input modality (touch vs mouse primary), whether audio is appropriate for this context. + +Add game-feel to interactions that already work. *Juicy*, in game design, means every action has multiple layers of feedback — the interface is generous in what it gives back. `finesse` makes interactions intentional; `juicy` makes them satisfying. + +The distinction matters: a well-timed button press is finesse. The same button press with a cursor that acknowledges the drag, a drop zone that glows when valid, and a micro-click sound on confirm — that's juicy. It's a layer above polish, not a replacement. + +**Context gate**: before applying any pattern from this command, assess: +- Is this SaaS / consumer product or institutional / financial / medical? +- Is the primary modality touch or mouse? +- Does the brand brief mention sound or haptics? + +Institutional contexts (government portals, banking, medical records, legal software) should default to `$impeccable finesse` only. Do not add sound, custom cursors, or drag choreography without an explicit brief that invites it. + +--- + +## Register + +**Brand**: juicy is at home here — cursor customization, sound design, scroll choreography, and ambient awareness all contribute to brand voice. Apply broadly. + +**Product**: apply juicy at specific high-value moments, not globally. Completion states, drag-and-drop flows, interactive data visualizations. Productivity tools should be fast and quiet everywhere else. + +--- + +## Soft Pre-requisite + +Run `$impeccable finesse` before `$impeccable juicy`. Juicy layers *on top of* correct timing and easing — adding drag choreography to a button that still uses `transition-all` creates an incoherent experience. Fix the foundation first. + +--- + +## Hard Blocks + +1. **prefers-reduced-motion**: any pattern adding spatial motion requires a fallback (same rule as finesse). +2. **Sound**: all audio patterns require: (a) user opt-in or system sound setting check, (b) AudioContext not `