From c7703562e6c110a4173e786d95965127c233640d Mon Sep 17 00:00:00 2001 From: xNet Test Date: Wed, 17 Jun 2026 16:21:48 -0700 Subject: [PATCH 1/8] docs(exploration): explore elegant composable motion system Co-Authored-By: Claude Opus 4.8 --- ...98_[_]_ELEGANT_COMPOSABLE_MOTION_SYSTEM.md | 737 ++++++++++++++++++ 1 file changed, 737 insertions(+) create mode 100644 docs/explorations/0198_[_]_ELEGANT_COMPOSABLE_MOTION_SYSTEM.md diff --git a/docs/explorations/0198_[_]_ELEGANT_COMPOSABLE_MOTION_SYSTEM.md b/docs/explorations/0198_[_]_ELEGANT_COMPOSABLE_MOTION_SYSTEM.md new file mode 100644 index 000000000..e068e33a5 --- /dev/null +++ b/docs/explorations/0198_[_]_ELEGANT_COMPOSABLE_MOTION_SYSTEM.md @@ -0,0 +1,737 @@ +# Elegant, Composable Motion System — A Constrained, AI‑Legible Animation Vocabulary + +## Problem Statement + +We want more animation throughout the xNet UI, but every adjective in the +ask is a constraint: + +- **Super fast** — motion must never make the product *feel* slower. Sub‑200ms + for anything interactive; compositor‑only properties; no jank. +- **Super clean / elegant / minimal** — a coherent house style, not a zoo of + bespoke effects. Restraint is the aesthetic. +- **Compose really well** — primitives that stack (enter + stagger, hover + + press) without fighting each other or re‑implementing the same easing. +- **Easy for AI to write** — an LLM (and the BYO‑agent bridge that now drives + this repo, see `0194`) should reach for the *same* small vocabulary every + time, and be unable to spell a wrong animation. +- **Consistent style guide** — one source of truth for durations, easings, and + named motions, enforced rather than merely documented. + +The danger is the opposite of "no animations": **animation drift** — every +component inventing its own `transition-all duration-200`, each editor package +redefining its own keyframes, AI sprinkling `ease-bounce` where `ease-out` +belongs. Drift is what makes a UI feel cheap and slow. This exploration asks +how to get *more* motion while getting *more* consistency at the same time. + +## Executive Summary + +**We are ~80% there at the token layer and ~20% there at the discipline layer.** +`@xnetjs/ui` already ships a genuinely good CSS‑first motion system — +`packages/ui/src/theme/motion.css` defines six easings, a six‑stop duration +scale, fourteen keyframes, and reduced‑motion handling; `base-ui-animations.css` +wires enter/exit to Base UI's `data-open` / `data-ending-style` attributes; the +Tailwind config maps all of it to utilities. There is **no external animation +library** and we don't need one for 95% of the UI. + +The gap is **not capability, it's consistency and reach**: + +1. **Drift is already happening.** `packages/editor/tailwind.config.js` + redefines its *own* `menu-appear` keyframe with a raw `150ms ease-out` + instead of the shared tokens. Across the web app there are **307 + `transition-colors`, 26 `transition-all`** (a compositor footgun), and raw + `duration-200` literals living next to `duration-normal` tokens. +2. **No coverage for React mount/unmount outside Base UI.** Toasts, tab + add/remove, explorer list reorder, and surface/route swaps animate + inconsistently or not at all, because the elegant `data-ending-style` + trick only exists *inside* Base UI components. +3. **Nothing makes AI emit the right thing.** The tokens exist but are + optional. There is no lint rule, no ``/preset layer, and no + one‑page style guide an agent can load. + +**Recommendation:** Don't add Motion/Framer by default. Instead, **harden the +existing CSS‑first system into a single canonical "motion vocabulary"** — +(a) freeze a small token set + ~10 named primitives, (b) add a thin React +`` helper and a `useViewTransition()` wrapper to extend the +`data-ending-style` elegance to *all* React mount/unmount and surface swaps, +(c) write a `MOTION.md` style guide that doubles as the AI prompt, and +(d) enforce it with an ESLint rule that bans `transition-all`, raw duration +literals, and off‑vocabulary easings. Keep `motion/react` (LazyMotion + `m`, +~4.6KB shell) as a **lazy‑loaded escape hatch** for the rare drag/FLIP case, +never on the default path. + +This is cheaper, faster at runtime, smaller in the bundle, and *more* +AI‑legible than adopting a JS animation library — because a constrained +vocabulary is exactly what both designers and LLMs need. + +--- + +## Current State In The Repository + +### The foundation that already exists (and is good) + +| Layer | File | What it provides | +|---|---|---| +| Tokens + keyframes | `packages/ui/src/theme/motion.css` | 6 easings, 6 durations, 14 keyframes, 5 transition utilities, reduced‑motion blanket | +| Base UI enter/exit | `packages/ui/src/theme/base-ui-animations.css` | `data-open` / `data-ending-style` driven dialog, popover, tooltip, menu, select, accordion, collapsible, switch, checkbox | +| Utility mapping | `packages/ui/tailwind.config.js` | `transitionTimingFunction`, `transitionDuration`, `keyframes`, `animation` all mapped to CSS vars; `tailwindcss-animate` plugin | +| JS hook | `packages/ui/src/hooks/useMediaQuery.ts:103` | `usePrefersReducedMotion()` (exported from `packages/ui/src/index.ts:363`) | +| Global wiring | `apps/web/src/styles/globals.css` | imports `@xnetjs/ui/motion.css`, `base-ui-animations.css`, `responsive.css` | + +The token scale, verbatim from `motion.css`: + +```css +/* easings */ +--ease-in: cubic-bezier(0.4, 0, 1, 1); +--ease-out: cubic-bezier(0, 0, 0.2, 1); +--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); +--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* overshoot */ +--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55); /* anticipate + overshoot */ +--ease-subtle: cubic-bezier(0.25, 0.1, 0.25, 1); + +/* durations */ +--duration-instant: 0ms; +--duration-fast: 100ms; /* micro / exit */ +--duration-normal: 150ms; /* standard enter */ +--duration-slow: 200ms; /* emphasis enter */ +--duration-slower: 300ms; +--duration-slowest: 400ms; +``` + +The Base UI enter/exit pattern — the elegant bit worth generalizing — looks +like this (`base-ui-animations.css:24‑43`): + +```css +.dialog-popup { opacity: 0; transform: scale(0.95); + transition: opacity var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out); } +.dialog-popup[data-open] { opacity: 1; transform: scale(1); } +.dialog-popup[data-ending-style] { opacity: 0; transform: scale(0.95); + transition: …var(--duration-fast) var(--ease-in); } +``` + +Note the *house style* already encoded here: **enter is slower + ease‑out, +exit is faster + ease‑in.** That is the correct cross‑industry default +(Material, Atlassian, Carbon all agree) and we should make it law. + +### Where it's actually used + +``` +transition-colors 307 ← overwhelmingly the most common +transition-opacity 41 +transition-all 26 ← footgun: animates layout props too +transition-transform 17 +transition-base 6 ← the shared utility, barely adopted +animate-spin 23 +animate-pulse 20 +animate-in 5 ← tailwindcss-animate, ad-hoc +animate-menu-appear 3 ← DRIFT: editor's own keyframe +``` + +51 files in `apps/web/src` touch motion classes. The system is *reached for* +constantly — which is exactly why drift compounds. + +### The drift, concretely + +- **Editor redefines its own motion.** `packages/editor/tailwind.config.js:44` + defines a private `menu-appear` keyframe and `:56` maps it as + `menu-appear 150ms ease-out forwards` — a hardcoded duration and a *raw* + `ease-out` keyword (not `var(--ease-out)`), used by `SlashMenu`, + `TaskMentionMenu`, `LinkTargetMenu`. Same intent as `.menu-popup` in the + shared system, reimplemented and subtly off‑spec. +- **`transition-all` × 26.** Animates `width`/`height`/`top`/`left` whenever + they change — the D‑tier layout‑thrashing properties — instead of the + compositor‑only `transform`/`opacity`. +- **Raw duration literals.** `duration-200` (a Tailwind default) appears + alongside `duration-normal` (our token). They're *different values* + (200ms vs 150ms), so the same "standard transition" renders at two speeds. +- **Two reduced‑motion strategies.** `motion.css:273` nukes *all* animation + with `!important` (including harmless opacity), while + `base-ui-animations.css:178` does a more surgical per‑component reset. The + blanket version is a sledgehammer that also kills tasteful, vestibular‑safe + fades. + +### Surfaces that want motion but lack it + +From the component inventory (real paths): + +| Surface | File | Wants | +|---|---|---| +| Tab add/remove/reorder | `apps/web/src/workbench/TabBar.tsx` | enter/exit + FLIP reorder | +| Explorer list reflow | `apps/web/src/workbench/views/Explorer.tsx` | reorder on sort change | +| Folder expand/collapse | `apps/web/src/workbench/views/ExplorerFolderTree.tsx` | height + chevron (has chevron only) | +| Undo toast | `apps/web/src/components/UndoToast.tsx` | enter/exit (currently pops in, no exit) | +| Storage banner | `apps/web/src/components/StorageWarningBanner.tsx` | slide‑down enter/exit | +| Surface swap | `apps/web/src/workbench/EditorArea.tsx` | cross‑fade between CRM/finance/tasks | +| Rail active indicator | `apps/web/src/workbench/Rail.tsx` | indicator slide (has color only) | +| Mobile sheets | `apps/web/src/workbench/MobileShell.tsx` | already via `Sheet` (good) | + +Every one of these is a *React mount/unmount or list mutation* — precisely the +two cases the current CSS‑only system can't reach, because nothing keeps the +exiting node in the DOM long enough to play `data-ending-style`. + +--- + +## External Research + +### The library landscape (and why we mostly skip it) + +| Library | Bundle (min+gz) | Exit anim | Layout/FLIP | Engine | Verdict for us | +|---|---|---|---|---|---| +| **CSS‑first** (our stack) | **0 KB** | via `data-ending-style` + `@starting-style` | manual / View Transitions | compositor | **Default** | +| Motion `m` + LazyMotion | 4.6 KB shell (+15 `domAnimation` / +25 `domMax`) | `AnimatePresence` | `layout` prop | WAAPI (compositor) | **Lazy escape hatch** | +| Motion full `motion/react` | ~34 KB | yes | yes | WAAPI | Too heavy for default | +| React Spring | ~18 KB | `useTransition` | manual | rAF | No | +| `@formkit/auto-animate` | **3.3 KB** | limited | auto (MutationObserver) | CSS | **Maybe** for lists | +| react-transition-group | ~5 KB | yes | no | CSS classes | Unmaintained, skip | + +Key sources: Motion bundle‑size docs (`motion.dev/docs/react-reduce-bundle-size`), +`motion.dev/docs/react-lazy-motion`, `npmjs.com/package/@formkit/auto-animate`. + +The renaming note: **Framer Motion → `motion`** (package `motion`, import +`motion/react`) as of late 2024. The tree‑shakeable path is `motion/react-m` +with a `` wrapper — a 4.6KB shell that +loads features on demand. This is the *only* JS option worth keeping in reserve. + +### CSS has quietly become enough (2026 baseline) + +The reason "no library" is now viable is that the browser caught up: + +- **`@starting-style`** — defines the *from* state so transitions fire on first + mount. Baseline Newly Available (Chrome 117+, Firefox 129+, Safari 17.5+). + This is the native answer to "animate something appearing." +- **`transition-behavior: allow-discrete`** — lets `display`/`overlay` + transition, so an element can fade out *and then* `display:none`. Same + baseline. Together with `@starting-style` this gives **CSS‑only enter *and* + exit without keeping a JS library resident.** +- **View Transitions API** — `document.startViewTransition(cb)` cross‑fades a + DOM mutation; `view-transition-name` animates shared elements between states. + Same‑document is Baseline (Chrome, Safari 18+, Firefox in progress). This is + the clean answer to surface/route swaps in `EditorArea.tsx`. +- **Scroll‑driven animations** (`animation-timeline: scroll()/view()`) — + Chrome/Edge only as of 2026, *not* baseline. Treat as progressive + enhancement, never a dependency. + +Sources: `web.dev/blog/baseline-entry-animations`, +`developer.chrome.com/blog/entry-exit-animations`, MDN `@starting-style`, +MDN `transition-behavior`, MDN View Transition API. + +Our build targets (from `apps/web/vite.config.ts`: Safari 16.4+, Chrome 102+, +Firefox 111+) are *slightly* below the `@starting-style` baseline. So +`@starting-style` is a **progressive enhancement** (the element simply appears +without the enter tween on the oldest engines) — acceptable, because the +fallback is "no animation," never "broken." + +### How the design systems standardize motion + +There is remarkable cross‑industry agreement, which is the empirical backbone +for a constrained vocabulary: + +| System | Micro | Standard enter/exit | Large/panel | Enter ease | Exit ease | +|---|---|---|---|---|---| +| Material 3 | 50–200ms | 200–300ms | 350–500ms | emphasized‑decelerate | emphasized‑accelerate | +| Atlassian | 50–150ms | 150–400ms | — | ease‑out bold | ease‑in practical | +| Carbon | (dynamic) | standard | (dynamic) | `0,0,0.25,1` | `0.25,0,1,1` | +| Apple HIG | — | spring (bounce ≤ 0.15) | spring | spring | spring | +| **xNet today** | **100ms** | **150ms enter / 100ms exit** | **200ms** | ease‑out | ease‑in | + +Our existing scale already sits inside the consensus band — it just isn't +enforced. Two cross‑system rules we should adopt as law: + +1. **Enter slow + decelerate (ease‑out); exit fast + accelerate (ease‑in).** +2. **Springs only for direct‑manipulation feedback** (toggle thumb, drag + pickup), never for ambient enters — Apple's guidance and the reason our + `--ease-spring` is currently (correctly) used only on `switch-thumb` and + `checkbox-indicator`. + +Sources: `m3.material.io/styles/motion/easing-and-duration/tokens-specs`, +`atlassian.design/foundations/motion`, +`carbondesignsystem.com/elements/motion/overview/`, +`developer.apple.com/design/human-interface-guidelines/motion`. + +### Performance tiers (the "super fast" constraint, made concrete) + +Motion's performance tier list and the FLIP literature converge on: + +- **S‑tier (compositor, GPU):** `transform`, `opacity`, `filter`, `clip-path` — + animate these and *only* these for 60fps under main‑thread load. +- **C‑tier (paint):** `background-color`, `color`, `box-shadow` — fine for + short hovers, our 307 `transition-colors` are mostly OK. +- **D‑tier (layout):** `width`, `height`, `top/left`, `margin` — the + `transition-all` trap. Use FLIP (animate a `transform` that *fakes* the + layout change) instead. + +Sources: `motion.dev/magazine/web-animation-performance-tier-list`, +`css-tricks.com/animating-layouts-with-the-flip-technique/`. + +### AI‑legible motion is a real, named idea + +This isn't hypothetical. Motion shipped an **AI Kit** (`motion.dev/docs/ai-kit`) +with an MCP server exposing motion docs + a "generate a CSS spring" tool that +emits a `linear()` easing usable with *zero* runtime. Smashing Magazine's +**"Keyframes as Tokens"** (Nov 2025) proposes a `kf-` keyframe‑token convention +with CSS‑variable knobs (`--kf-slide-from`) so one keyframe covers all +directions. The throughline: **LLMs are reliable when the vocabulary is small, +named, and declarative.** A constrained token set isn't a limitation for AI — +it's the enabling condition. + +Sources: `motion.dev/docs/ai-kit`, +`smashingmagazine.com/2025/11/keyframes-tokens-standardizing-animation-across-projects/`. + +--- + +## Key Findings + +1. **The expensive part is already built.** Tokens, keyframes, Base UI + enter/exit, reduced‑motion, and Tailwind mapping all exist and are sound. + Adopting a JS library would *duplicate* this and add 4–34KB. +2. **The cheap part is missing: discipline + reach.** No enforcement → drift + (editor's private keyframe, `transition-all`, raw `duration-200`). No + React‑mount coverage → toasts/tabs/lists/surfaces animate ad‑hoc or not at + all. +3. **A constrained vocabulary serves both "elegant" and "AI‑friendly" + simultaneously.** The same restraint that makes a UI feel designed makes an + LLM reliable. These goals are not in tension — they're the same goal. +4. **CSS in 2026 covers our needs natively.** `@starting-style` + + `allow-discrete` + View Transitions handle enter/exit/route. JS is only + needed for drag and complex FLIP — a small minority. +5. **Our house style is already implicitly correct** (enter‑slow‑out, + exit‑fast‑in). We just need to *name it, document it, and enforce it.* + +--- + +## Options And Tradeoffs + +### Option A — Adopt Motion (`motion/react`) as the primary system + +Rewrite animated components with ``, `AnimatePresence`, `layout`. + +- **+** Best‑in‑class exit + FLIP + orchestration; variants are very AI‑legible. +- **+** Solves tab reorder / list FLIP for free. +- **−** 4.6–34KB added; a second motion paradigm coexisting with the CSS system + → *more* drift, not less. Throws away the existing investment. JS on the + animation hot path. Overkill for 95% of our fades/slides. + +### Option B — Status quo + ad‑hoc fixes + +Keep adding `transition-*` classes per component as needed. + +- **+** Zero upfront work. +- **−** Drift compounds; "consistent style guide" never happens; AI keeps + guessing. This is the path that produces a cheap‑feeling UI. + +### Option C — Harden the CSS‑first system into an enforced vocabulary ✅ + +Freeze a minimal token set + named primitives; add a thin React `` +and `useViewTransition()` to extend `data-ending-style` elegance to all React +mount/unmount and surface swaps; write `MOTION.md`; enforce with ESLint. Keep +`motion/react` lazy as an escape hatch. + +- **+** Builds on the 80% already shipped; ~0KB on the default path; one + paradigm; *fixes* drift; produces the demanded style guide; maximally + AI‑legible; native + future‑proof. +- **−** Requires writing the lint rule and the `` helper; team must + accept constraint (the point). FLIP/drag still needs the escape hatch. + +### Option D — `@formkit/auto-animate` for lists only + +Add the 3.3KB hook to Explorer/TabBar for automatic reorder. + +- **+** Tiny; one‑liner (`useAutoAnimate()`); solves the one thing CSS can't + (list reorder) without full Motion. +- **−** MutationObserver‑driven (less control); overlap glitches on remove; + needs SSR guard. **Best treated as a sub‑decision *inside* Option C**, not a + strategy on its own. + +### Comparison + +```mermaid +graph LR + subgraph Goals + F[Fast] + E[Elegant/Minimal] + C[Composable] + AI[AI-legible] + SG[Style guide] + end + A[A: Motion primary] -->|adds 4-34KB| F + A -->|2 paradigms| E + B[B: Status quo] -->|drift| E + B -->|no guardrails| AI + OptC[C: Hardened CSS vocab] ==>|0KB default| F + OptC ==>|one paradigm| E + OptC ==>|primitives stack| C + OptC ==>|constrained set| AI + OptC ==>|MOTION.md + lint| SG + style OptC fill:#bbf,stroke:#333,stroke-width:3px +``` + +--- + +## Recommendation + +**Adopt Option C: harden the existing CSS‑first system into a single, +enforced, AI‑legible motion vocabulary, with `motion/react` reserved as a +lazy‑loaded escape hatch and `auto-animate` as an optional list‑reorder +sub‑tool.** + +### The architecture in four layers + +```mermaid +flowchart TD + subgraph L1["1 · Tokens (motion.css)"] + D[durations: instant/fast/normal/slow/slower/slowest] + EZ[easings: in/out/in-out/spring/subtle] + end + subgraph L2["2 · Primitives (~10 named keyframes + utilities)"] + K[fade · scale · slide-x/y · collapse · pop · shimmer · spin · pulse] + end + subgraph L3["3 · Recipes (React helpers)"] + P[" — enter/exit for any React child"] + VT["useViewTransition() — surface/route swap"] + AA["useAutoAnimate() — list reorder (opt-in)"] + RM["usePrefersReducedMotion() — already exists"] + end + subgraph L4["4 · Governance"] + MD["MOTION.md — the style guide = the AI prompt"] + LINT["ESLint rule: no transition-all, no raw durations, no off-vocab easings"] + ESC["Escape hatch: lazy motion/react for drag/FLIP"] + end + L1 --> L2 --> L3 --> L4 + style OptC fill:#bbf +``` + +### The frozen vocabulary (what AI is allowed to emit) + +A deliberately tiny set. The rule for the agent: *"Compose from this list. If +you can't, you're probably over‑animating."* + +**Durations** (keep the existing 6, but bless 3 as the everyday set): +`fast` (100ms, hover/press/exit) · `normal` (150ms, standard enter) · +`slow` (200ms, panels/dialogs). `instant`/`slower`/`slowest` exist for edge +cases. + +**Easings** (4 + 1): `ease-out` (enter) · `ease-in` (exit) · `ease-in-out` +(move/morph) · `ease-spring` (direct‑manipulation only) · `linear` (loops). +**Retire `--ease-bounce`** — its negative `-0.55` anticipation is the opposite +of minimal; no component uses it. + +**Primitives** (~10, all compositor‑only): `fade` · `scale` (0.95→1) · +`slide-up/down/left/right` (8–16px) · `collapse` (height) · `pop` (scale + +spring) · `shimmer` · `spin` · `pulse-subtle`. + +**The two laws:** enter = slower + `ease-out`; exit = faster + `ease-in`. + +### Why this satisfies every adjective + +- **Fast** — compositor‑only primitives; ≤200ms; 0KB on the default path. +- **Clean/elegant/minimal** — one house style; `ease-bounce` retired; + `transition-all` banned by lint. +- **Composable** — primitives are independent `transform`/`opacity` channels + + a parent‑driven stagger; `` wraps any of them. +- **AI‑easy** — `MOTION.md` is short enough to paste into a system prompt; the + lint rule turns "wrong animation" into a build error the agent self‑corrects. +- **Consistent style guide** — `MOTION.md` + the lint rule *are* the style + guide, and they're enforced, not aspirational. + +--- + +## Example Code + +### 1. The `` helper — extend `data-ending-style` to any React child + +The elegant trick in `base-ui-animations.css` is that Base UI keeps the node +mounted and flips `data-ending-style` before unmounting. We generalize that to +*any* React conditional with a ~40‑line hook + a CSS‑variable‑driven keyframe. +No library. + +```tsx +// packages/ui/src/motion/Presence.tsx +import { useEffect, useRef, useState } from 'react' +import { cn } from '../utils/cn' + +type MotionName = 'fade' | 'scale' | 'slide-up' | 'slide-down' | 'pop' + +export function Presence({ + show, + motion = 'fade', + children, + className, +}: { + show: boolean + motion?: MotionName + children: React.ReactNode + className?: string +}) { + const [mounted, setMounted] = useState(show) + const ref = useRef(null) + + useEffect(() => { + if (show) setMounted(true) + }, [show]) + + // When hiding, wait for the exit animation to finish before unmount. + const onAnimationEnd = () => { + if (!show) setMounted(false) + } + + if (!mounted) return null + return ( +
+ {children} +
+ ) +} +``` + +```css +/* packages/ui/src/theme/motion.css — the matching CSS, fully token-driven */ +.motion-presence[data-state='open'][data-motion='fade'] { animation: fade-in var(--duration-normal) var(--ease-out); } +.motion-presence[data-state='closed'][data-motion='fade'] { animation: fade-out var(--duration-fast) var(--ease-in); } +.motion-presence[data-state='open'][data-motion='scale'] { animation: scale-in var(--duration-normal) var(--ease-out); } +.motion-presence[data-state='closed'][data-motion='scale'] { animation: scale-out var(--duration-fast) var(--ease-in); } +/* slide-up / slide-down / pop follow the same two-line pattern */ +``` + +Usage — fixes `UndoToast.tsx` (which currently has no exit) in one line: + +```tsx + + + +``` + +### 2. `useViewTransition()` — clean surface/route swaps in `EditorArea.tsx` + +```tsx +// packages/ui/src/motion/useViewTransition.ts +export function useViewTransition() { + const reduced = usePrefersReducedMotion() + return (mutate: () => void) => { + if (reduced || !('startViewTransition' in document)) return mutate() // graceful fallback + document.startViewTransition(mutate) + } +} +``` + +```tsx +// EditorArea.tsx — cross-fade when switching CRM ↔ finance ↔ tasks +const withTransition = useViewTransition() +const switchSurface = (next: SurfaceId) => withTransition(() => setSurface(next)) +``` + +`::view-transition-old/new` get their default cross‑fade for free; opt specific +elements into shared‑element motion with `view-transition-name`. + +### 3. Stagger — composition without a library (one CSS var) + +```css +/* a list whose children fade-slide in sequence */ +.stagger > * { + animation: slide-in-bottom var(--duration-slow) var(--ease-out) both; + animation-delay: calc(var(--i, 0) * 40ms); +} +``` + +```tsx +{items.map((item, i) => ( +
  • {item.label}
  • +))} +``` + +### 4. The ESLint guardrail (the enforcement that prevents drift) + +```js +// eslint rule sketch: no-offvocab-motion +const BANNED = [ + { re: /\btransition-all\b/, msg: 'Use transition-base / -transform / -colors-fast (compositor-only).' }, + { re: /\bduration-(75|100|150|200|300|500|700|1000)\b/, msg: 'Use duration tokens: fast/normal/slow.' }, + { re: /\bease-bounce\b/, msg: 'ease-bounce is retired. Use ease-out, or ease-spring for direct manipulation.' }, + { re: /\banimate-\[/, msg: 'No arbitrary keyframes. Add a named primitive to motion.css instead.' }, +] +// flag string literals in className that match BANNED +``` + +### 5. The escape hatch — lazy, never on the default path + +```tsx +// Only loaded for the rare drag/FLIP screen, code-split so the base bundle stays 0KB heavier. +const DragCanvas = lazy(() => import('./DragCanvas')) // internally imports motion/react-m + LazyMotion +``` + +### How a request flows once the system exists + +```mermaid +sequenceDiagram + participant Dev as Dev / AI agent + participant MD as MOTION.md (vocabulary) + participant Code as Component + participant Lint as ESLint motion rule + participant CSS as motion.css (compositor) + Dev->>MD: read the ~10 primitives + 2 laws + Dev->>Code: / className="transition-base" + Code->>Lint: build + alt off-vocabulary (transition-all, duration-200, ease-bounce) + Lint-->>Dev: error + the right token to use + Dev->>Code: self-correct + else in-vocabulary + Lint-->>Code: pass + Code->>CSS: data-state / data-motion → keyframe + CSS-->>Dev: 60fps compositor animation + end +``` + +### Enter/exit lifecycle (what `` automates) + +```mermaid +stateDiagram-v2 + [*] --> Hidden + Hidden --> Entering: show=true (mount + data-state=open) + Entering --> Shown: animationend + Shown --> Exiting: show=false (data-state=closed, STILL mounted) + Exiting --> Hidden: animationend → unmount + note right of Exiting + The key trick: node stays in + the DOM during exit, exactly + like Base UI data-ending-style + end note +``` + +--- + +## Risks And Open Questions + +- **`@starting-style` is below our oldest build target** (Safari 16.4 vs 17.5 + baseline). Mitigation: it degrades to "appears without enter tween," never + breaks. The `` helper sidesteps this by using JS‑driven `mount` + state + a normal keyframe, so it works everywhere; reserve `@starting-style` + for pure‑CSS cases where the fallback is acceptable. +- **View Transitions in Firefox** is still in progress in 2026. + `useViewTransition()` already feature‑detects and falls back to an instant + swap — correct behavior, just no cross‑fade on older Firefox. +- **Lint false positives.** Banning `transition-all` may flag legitimate one‑off + cases. Mitigation: allow an `// eslint-disable-next-line` with a required + justification comment; track exceptions. +- **Reduced‑motion double‑handling.** We have both a blanket `!important` reset + (`motion.css:273`) and surgical resets. Decide: keep the blanket as a safety + net but *narrow* it to spatial/transform motion, preserving tasteful opacity + fades (which are vestibular‑safe). Open question: does the team want + "reduce" to mean "no motion" or "no *large spatial* motion"? Industry + consensus is the latter. +- **`auto-animate` vs manual FLIP for lists.** Do we want the 3.3KB dependency + for Explorer/TabBar reorder, or a hand‑rolled FLIP hook? Recommend starting + manual (View Transitions can even handle simple reorders), add `auto-animate` + only if reorder UX demands it. +- **Editor package alignment.** `packages/editor/tailwind.config.js` must drop + its private `menu-appear` and consume the shared `.menu-popup` / + `scale-in` vocabulary — a small but symbolically important de‑drift. +- **Does motion belong in `@xnetjs/ui` or a new `@xnetjs/motion`?** Tokens + + `` + `useViewTransition` are tiny and UI‑coupled; recommend a + `packages/ui/src/motion/` subfolder, not a new package, to avoid the + workspace‑package CI overhead noted in prior explorations. + +--- + +## Implementation Checklist + +- [ ] **Freeze the vocabulary.** In `packages/ui/src/theme/motion.css`, add a + header comment declaring the canonical set (3 everyday durations, 4+1 + easings, ~10 primitives, the two laws). Retire `--ease-bounce` (no consumers). +- [ ] **Add `packages/ui/src/motion/Presence.tsx`** with the + `data-state`/`data-motion` keyframe pattern; export from + `packages/ui/src/index.ts`. +- [ ] **Add the `.motion-presence[...]` keyframe rules** to `motion.css` + (fade/scale/slide‑up/slide‑down/pop, two lines each, token‑driven). +- [ ] **Add `packages/ui/src/motion/useViewTransition.ts`** with reduced‑motion + + feature‑detect fallback; export it. +- [ ] **Add a `.stagger`** utility to `motion.css` (single `--i` delay var). +- [ ] **Write `docs/MOTION.md`** — the one‑page style guide: the vocabulary + table, the two laws, do/don't examples, and a "for AI agents" section that + can be pasted into a system prompt. Link it from `CLAUDE.md`/contributor docs. +- [ ] **Write the ESLint rule** (`no-offvocab-motion`): ban `transition-all`, + raw `duration-N` literals, `ease-bounce`, arbitrary `animate-[…]`. Wire into + the existing lint job (a required check per `0193`). +- [ ] **De‑drift the editor.** Delete `menu-appear` from + `packages/editor/tailwind.config.js`; switch `SlashMenu`, `TaskMentionMenu`, + `LinkTargetMenu` to the shared `scale-in` / `.menu-popup` vocabulary. +- [ ] **Codemod the easy wins.** Replace `transition-all`→`transition-base`, + `duration-200`→`duration-slow` (or `-normal`) across `apps/web/src` where + semantics match (mechanical, reviewable). +- [ ] **Apply `` to the gaps:** `UndoToast.tsx` (slide‑up), + `StorageWarningBanner.tsx` (slide‑down), `TabBar.tsx` tab enter/exit. +- [ ] **Apply `useViewTransition()`** to surface swaps in + `EditorArea.tsx` and folder navigation in `TabBreadcrumb.tsx`. +- [ ] **Narrow the reduced‑motion blanket** in `motion.css:273` to spatial + motion; preserve opacity fades; confirm against + `usePrefersReducedMotion()` parity. +- [ ] **(Optional) Decide on `auto-animate`** for Explorer/TabBar reorder; + if adopted, gate behind the list‑reorder use case only. +- [ ] **(Optional) Wire the lazy `motion/react` escape hatch** behind a + code‑split boundary for any future drag/FLIP canvas; document that it must + never be imported on the default path. + +## Validation Checklist + +- [ ] **Bundle:** default `apps/web` bundle grows by **0 KB** (no new runtime + dep on the main path); any `motion/react` use is in a separate lazy chunk. +- [ ] **Perf:** record a Performance trace of toast enter/exit, surface swap, + and tab open — all animations stay on the compositor (no purple "Layout" + bars); 60fps under a 4× CPU throttle. +- [ ] **Lint:** the `no-offvocab-motion` rule fails CI on a planted + `transition-all` / `duration-200` / `ease-bounce`, and passes on the + vocabulary. +- [ ] **Drift count:** `grep -r "transition-all"` and `grep -r "menu-appear"` + return **0** in `apps/web/src` and `packages/editor` after the codemod. +- [ ] **Reduced motion:** with `prefers-reduced-motion: reduce`, spatial + motion is gone but opacity fades remain; nothing flashes or jumps; Base UI + components still open/close instantly. +- [ ] **Exit animations actually play:** `UndoToast` and `StorageWarningBanner` + visibly animate *out* (not just in) — verify in the Playwright/preview + harness with a screenshot or short capture. +- [ ] **Surface swap:** switching CRM↔finance↔tasks cross‑fades on Chrome/Safari + and instantly swaps (no error) on Firefox. +- [ ] **AI legibility spot‑check:** give an agent only `MOTION.md` and ask it to + "animate this dropdown's appearance" — it emits in‑vocabulary classes / + `` without inventing keyframes. +- [ ] **No regressions:** existing Base UI dialog/popover/menu/accordion + animations unchanged; `editor-ux` e2e green. + +--- + +## References + +### Repository +- `packages/ui/src/theme/motion.css` — tokens, keyframes, transition utilities, reduced‑motion +- `packages/ui/src/theme/base-ui-animations.css` — `data-open`/`data-ending-style` enter/exit pattern +- `packages/ui/tailwind.config.js` — easing/duration/keyframe/animation utility mapping +- `packages/ui/src/hooks/useMediaQuery.ts:103` — `usePrefersReducedMotion()` +- `packages/editor/tailwind.config.js:44` — the private `menu-appear` drift to remove +- `apps/web/src/styles/globals.css` — motion CSS imports +- `apps/web/src/components/UndoToast.tsx`, `StorageWarningBanner.tsx` — missing exit animations +- `apps/web/src/workbench/{TabBar,EditorArea,Rail}.tsx`, `views/{Explorer,ExplorerFolderTree}.tsx` — gap surfaces + +### External +- Motion — reduce bundle size / LazyMotion: https://motion.dev/docs/react-reduce-bundle-size · https://motion.dev/docs/react-lazy-motion +- Motion AI Kit (AI‑legible animation, MCP, CSS spring gen): https://motion.dev/docs/ai-kit +- Motion — Web Animation Performance Tier List: https://motion.dev/magazine/web-animation-performance-tier-list +- `@formkit/auto-animate`: https://www.npmjs.com/package/@formkit/auto-animate · https://auto-animate.formkit.com/ +- CSS entry animations now Baseline (`@starting-style`): https://web.dev/blog/baseline-entry-animations +- Chrome — four new CSS entry/exit features (`@starting-style`, `allow-discrete`): https://developer.chrome.com/blog/entry-exit-animations +- MDN — `@starting-style`: https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style +- MDN — `transition-behavior`: https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior +- MDN — View Transition API: https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API +- Chrome — View Transitions in 2025: https://developer.chrome.com/blog/view-transitions-in-2025 +- Material Design 3 motion tokens: https://m3.material.io/styles/motion/easing-and-duration/tokens-specs +- Atlassian motion foundations: https://atlassian.design/foundations/motion +- IBM Carbon motion: https://carbondesignsystem.com/elements/motion/overview/ +- Apple HIG — Motion: https://developer.apple.com/design/human-interface-guidelines/motion +- Smashing — "Keyframes as Tokens": https://www.smashingmagazine.com/2025/11/keyframes-tokens-standardizing-animation-across-projects/ +- CSS‑Tricks — FLIP technique: https://css-tricks.com/animating-layouts-with-the-flip-technique/ +- Josh W. Comeau — `prefers-reduced-motion` in React: https://www.joshwcomeau.com/react/prefers-reduced-motion/ +- Radix UI — animation guide (CSS‑first pattern): https://www.radix-ui.com/primitives/docs/guides/animation +- tw-animate-css (shadcn's Tailwind v4 successor to tailwindcss-animate): https://github.com/Wombosvideo/tw-animate-css From 6ab25b38c8e571323c486324bafbb60fc3b91ab6 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Wed, 17 Jun 2026 16:30:08 -0700 Subject: [PATCH 2/8] feat(ui): freeze motion vocabulary, add Presence/stagger primitives, retire ease-bounce The canonical motion system gains a documented vocabulary header (the two laws + the allowed durations/easings/primitives), a enter/exit keyframe layer (.motion-presence[data-state][data-motion]), a .stagger list-entrance utility, and a pop-in primitive. --ease-bounce is retired (no consumers; its negative anticipation is the opposite of minimal) and dropped from the Tailwind config. Refs docs/explorations/0198. Co-Authored-By: Claude Opus 4.8 --- packages/ui/src/theme/motion.css | 132 +++++++++++++++++++++++++++---- packages/ui/tailwind.config.js | 9 ++- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/theme/motion.css b/packages/ui/src/theme/motion.css index 3b83aa8f8..860a04ff1 100644 --- a/packages/ui/src/theme/motion.css +++ b/packages/ui/src/theme/motion.css @@ -1,33 +1,52 @@ /** - * @xnetjs/ui - Motion System + * @xnetjs/ui — Motion System (the canonical motion vocabulary) * - * Comprehensive animation system with easing functions, durations, - * keyframes, and reduced motion support. + * This file is the single source of truth for animation in xNet. The full + * style guide lives in docs/MOTION.md; the short version (and the only thing + * an author — human or AI — needs) is: + * + * THE TWO LAWS + * 1. Enter is slower + decelerates → --ease-out, --duration-normal + * 2. Exit is faster + accelerates → --ease-in, --duration-fast + * + * THE VOCABULARY (compose from this; if you can't, you're over-animating) + * durations fast 100 · normal 150 · slow 200 (instant/slower/slowest = edge cases) + * easings ease-out (enter) · ease-in (exit) · ease-in-out (move/morph) + * ease-spring (direct manipulation only) · linear (loops) + * primitives fade · scale · slide-{up,down,left,right} · collapse + * pop · shimmer · spin · pulse-subtle + * + * ENFORCEMENT scripts/check-motion-vocab.mjs bans the footguns in + * packages/ui + apps/web: `transition-all`, raw `duration-` literals, + * `ease-bounce`, and arbitrary `animate-[…]`. Use the tokens instead. + * + * spring is for things the user is directly pushing (a toggle thumb, a drag + * pickup), never for ambient enters. `--ease-bounce` was retired (its + * negative anticipation is the opposite of "minimal"). */ @layer base { :root { /* ─── Easing Functions ──────────────────────────────────────── */ - /* Standard easings */ - --ease-in: cubic-bezier(0.4, 0, 1, 1); - --ease-out: cubic-bezier(0, 0, 0.2, 1); - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + /* Standard easings — the everyday set */ + --ease-in: cubic-bezier(0.4, 0, 1, 1); /* exits: accelerate away */ + --ease-out: cubic-bezier(0, 0, 0.2, 1); /* enters: decelerate in */ + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); /* moves/morphs */ - /* Spring-like easings for more natural feel */ + /* Spring — direct-manipulation feedback ONLY (toggle thumb, checkbox pop) */ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); - --ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55); /* Subtle easing for micro-interactions */ --ease-subtle: cubic-bezier(0.25, 0.1, 0.25, 1); /* ─── Duration Scale ────────────────────────────────────────── */ --duration-instant: 0ms; - --duration-fast: 100ms; - --duration-normal: 150ms; - --duration-slow: 200ms; - --duration-slower: 300ms; - --duration-slowest: 400ms; + --duration-fast: 100ms; /* micro-interaction / exit */ + --duration-normal: 150ms; /* standard enter */ + --duration-slow: 200ms; /* emphasis enter (panels, dialogs) */ + --duration-slower: 300ms; /* background / large moves */ + --duration-slowest: 400ms; /* rare, dramatic */ } } @@ -189,6 +208,19 @@ } } +/* pop — scale-in with a touch of overshoot (pairs with --ease-spring). + For direct-manipulation affordances, not ambient enters. */ +@keyframes pop-in { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + /* Accordion-specific (for Base UI compatibility) */ /* Base UI uses --accordion-panel-height CSS variable */ @keyframes accordion-down { @@ -268,7 +300,77 @@ } } -/* ─── Reduced Motion Support ──────────────────────────────────────── */ +/* ─── enter/exit ─────────────────────────────────────────── + * + * Extends the Base UI `data-ending-style` trick to ANY React child. The + * component (packages/ui/src/motion/Presence.tsx) keeps the node + * mounted during exit and flips data-state to "closed"; these rules play the + * matching keyframe. Obeys the two laws: enter slower+ease-out, exit + * faster+ease-in. Add a new `motion` by adding two lines here. + */ + +.motion-presence[data-state='open'][data-motion='fade'] { + animation: fade-in var(--duration-normal) var(--ease-out); +} +.motion-presence[data-state='closed'][data-motion='fade'] { + animation: fade-out var(--duration-fast) var(--ease-in); +} + +.motion-presence[data-state='open'][data-motion='scale'] { + animation: scale-in var(--duration-normal) var(--ease-out); +} +.motion-presence[data-state='closed'][data-motion='scale'] { + animation: scale-out var(--duration-fast) var(--ease-in); +} + +/* slide-up: rises into view from 8px below (toasts, bottom sheets) */ +.motion-presence[data-state='open'][data-motion='slide-up'] { + animation: slide-in-bottom var(--duration-slow) var(--ease-out); +} +.motion-presence[data-state='closed'][data-motion='slide-up'] { + animation: slide-out-bottom var(--duration-normal) var(--ease-in); +} + +/* slide-down: descends into view from 8px above (top banners) */ +.motion-presence[data-state='open'][data-motion='slide-down'] { + animation: slide-in-top var(--duration-slow) var(--ease-out); +} +.motion-presence[data-state='closed'][data-motion='slide-down'] { + animation: slide-out-top var(--duration-normal) var(--ease-in); +} + +/* pop: spring-flavored scale-in for direct-manipulation affordances */ +.motion-presence[data-state='open'][data-motion='pop'] { + animation: pop-in var(--duration-normal) var(--ease-spring); +} +.motion-presence[data-state='closed'][data-motion='pop'] { + animation: scale-out var(--duration-fast) var(--ease-in); +} + +/* ─── Stagger ───────────────────────────────────────────────────────── + * Compose list entrances without a library: each child carries `--i` (its + * index) and inherits a sequential delay. Pair with any enter primitive. + * + *
      {items.map((it,i)=>( + *
    • ))}
    + */ +@layer utilities { + .stagger > * { + animation: slide-in-bottom var(--duration-slow) var(--ease-out) both; + animation-delay: calc(var(--i, 0) * 40ms); + } +} + +/* ─── Reduced Motion Support ────────────────────────────────────────── + * + * The single, primary reduced-motion strategy (the canonical "reduce to + * instant" pattern). Everything — including the keyframes above, the + * primitives, and `.stagger` — collapses to an imperceptible + * 0.01ms so nothing moves, while state still changes (and animationend + * still fires, so unmounts correctly). base-ui-animations.css + * additionally pins component end-states to `transform: none` so no residual + * offset survives; that file defers to this one for timing. + */ @media (prefers-reduced-motion: reduce) { *, diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js index 15d4054d2..426b17644 100644 --- a/packages/ui/tailwind.config.js +++ b/packages/ui/tailwind.config.js @@ -219,12 +219,14 @@ export default { }, // ─── Transition Timing Functions ───────────────────────────── + // Canonical easings (see packages/ui/src/theme/motion.css). `bounce` + // was retired — spring covers direct-manipulation feedback; ambient + // motion uses ease-out/ease-in. transitionTimingFunction: { 'ease-in': 'var(--ease-in)', 'ease-out': 'var(--ease-out)', 'ease-in-out': 'var(--ease-in-out)', spring: 'var(--ease-spring)', - bounce: 'var(--ease-bounce)', subtle: 'var(--ease-subtle)' }, @@ -292,6 +294,10 @@ export default { '0%, 100%': { opacity: '1' }, '50%': { opacity: '0.7' } }, + 'pop-in': { + from: { opacity: '0', transform: 'scale(0.9)' }, + to: { opacity: '1', transform: 'scale(1)' } + }, shimmer: { '0%': { backgroundPosition: '-200% 0' }, '100%': { backgroundPosition: '200% 0' } @@ -329,6 +335,7 @@ export default { 'slide-in-left': 'slide-in-left var(--duration-slow) var(--ease-out)', 'slide-out-left': 'slide-out-left var(--duration-normal) var(--ease-in)', 'pulse-subtle': 'pulse-subtle 2s var(--ease-in-out) infinite', + 'pop-in': 'pop-in var(--duration-normal) var(--ease-spring)', shimmer: 'shimmer 1.5s linear infinite', 'accordion-down': 'accordion-down var(--duration-slow) var(--ease-out)', 'accordion-up': 'accordion-up var(--duration-normal) var(--ease-in)', From 4247150f73df36970e9d0c3a3bba870a0c9bb893 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Wed, 17 Jun 2026 16:34:58 -0700 Subject: [PATCH 3/8] feat(ui): add and useViewTransition motion helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extends the Base UI data-ending-style enter/exit trick to any React child (keep mounted through exit, unmount on animationend) — no JS animation library. useViewTransition() wraps the native View Transitions API with reduced-motion + feature-detect fallbacks for discrete surface/ list swaps. Both exported from @xnetjs/ui; 11 unit tests green. Refs docs/explorations/0198. Co-Authored-By: Claude Opus 4.8 --- packages/ui/src/index.ts | 10 +++ packages/ui/src/motion/Presence.test.tsx | 67 ++++++++++++++ packages/ui/src/motion/Presence.tsx | 78 +++++++++++++++++ .../ui/src/motion/useViewTransition.test.tsx | 87 +++++++++++++++++++ packages/ui/src/motion/useViewTransition.ts | 65 ++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 packages/ui/src/motion/Presence.test.tsx create mode 100644 packages/ui/src/motion/Presence.tsx create mode 100644 packages/ui/src/motion/useViewTransition.test.tsx create mode 100644 packages/ui/src/motion/useViewTransition.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index de25eb6cc..76cc1e942 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -294,6 +294,16 @@ export { // ─── Theme ──────────────────────────────────────────────────────── export { ThemeProvider, useTheme, type Theme, type ThemeVariant } from './theme/ThemeProvider' +// ─── Motion (exploration 0198) ───────────────────────────────────── +// The canonical motion vocabulary's React surface. CSS tokens/keyframes +// live in ./theme/motion.css; see docs/MOTION.md for the style guide. +export { Presence, type PresenceProps, type PresenceMotion } from './motion/Presence' +export { + useViewTransition, + startViewTransition, + supportsViewTransitions +} from './motion/useViewTransition' + // ─── Responsive Components ───────────────────────────────────────── export { ResponsiveSidebar, diff --git a/packages/ui/src/motion/Presence.test.tsx b/packages/ui/src/motion/Presence.test.tsx new file mode 100644 index 000000000..b68c0baa8 --- /dev/null +++ b/packages/ui/src/motion/Presence.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { describe, expect, it } from 'vitest' +import { Presence } from './Presence' + +describe('Presence', () => { + it('renders nothing when initially hidden', () => { + const { container } = render( + +

    toast

    +
    + ) + expect(container.textContent).toBe('') + expect(screen.queryByText('toast')).toBeNull() + }) + + it('renders the child with open state and the chosen motion when shown', () => { + render( + +

    toast

    +
    + ) + const node = screen.getByText('toast').parentElement! + expect(node.getAttribute('data-state')).toBe('open') + expect(node.getAttribute('data-motion')).toBe('slide-up') + expect(node.className).toContain('motion-presence') + }) + + it('keeps the child mounted during exit, then unmounts on animationend', () => { + const { rerender, container } = render( + +

    toast

    +
    + ) + // Flip to hidden — the node must remain in the DOM to play its exit. + rerender( + +

    toast

    +
    + ) + const node = screen.getByText('toast').parentElement! + expect(node.getAttribute('data-state')).toBe('closed') + + // Exit animation finishes → unmount. + fireEvent.animationEnd(node) + expect(container.textContent).toBe('') + }) + + it('cancels a pending unmount if shown again before the exit finishes', () => { + const { rerender } = render(content) + rerender(content) + rerender(content) + // Re-shown: state is open again and the child is still present. + expect(screen.getByText('content').getAttribute('data-state')).toBe('open') + }) + + it('honors the `as` tag and forwards wrapperProps', () => { + render( + + banner + + ) + const node = screen.getByRole('status') + expect(node.tagName).toBe('SECTION') + expect(node.getAttribute('aria-live')).toBe('polite') + }) +}) diff --git a/packages/ui/src/motion/Presence.tsx b/packages/ui/src/motion/Presence.tsx new file mode 100644 index 000000000..1d795c6ee --- /dev/null +++ b/packages/ui/src/motion/Presence.tsx @@ -0,0 +1,78 @@ +/** + * — enter/exit animation for any React child. + * + * Base UI components animate on close because the library keeps the node + * mounted and flips a `data-ending-style` attribute before unmounting. Plain + * React conditionals (`{open && }`) can't do that — the node is gone + * the instant `open` flips false, so there's nothing left to animate out. + * + * generalizes the trick with ~zero runtime: when `show` goes false + * it keeps the child mounted, sets `data-state="closed"` (which plays the exit + * keyframe defined in motion.css), and unmounts only after `animationend`. + * + * + * + * + * + * The matching keyframes live in packages/ui/src/theme/motion.css + * (`.motion-presence[data-state][data-motion]`). To add a motion, add two + * lines there and a name here — no JS animation library involved. Under + * `prefers-reduced-motion` the keyframes collapse to ~instant (handled + * globally in motion.css) and animationend still fires, so unmount is correct. + */ +import * as React from 'react' +import { cn } from '../utils' + +/** The named motions with matching `.motion-presence` rules in motion.css. */ +export type PresenceMotion = 'fade' | 'scale' | 'slide-up' | 'slide-down' | 'pop' + +export interface PresenceProps { + /** When true the child is shown (enter); when false it animates out, then unmounts. */ + show: boolean + /** Which enter/exit keyframe pair to play. Defaults to `fade`. */ + motion?: PresenceMotion + /** Render a plain wrapper `
    ` (default) or merge onto the child via a render prop. */ + children: React.ReactNode + /** Extra classes for the wrapper element. */ + className?: string + /** Wrapper element tag. Defaults to `div`. */ + as?: keyof React.JSX.IntrinsicElements + /** Forwarded to the wrapper (e.g. role, aria-live). */ + wrapperProps?: React.HTMLAttributes +} + +export function Presence({ + show, + motion = 'fade', + children, + className, + as = 'div', + wrapperProps +}: PresenceProps): React.ReactElement | null { + // `mounted` lags `show` on the way out: it stays true through the exit + // animation and only drops to false on animationend. + const [mounted, setMounted] = React.useState(show) + + React.useEffect(() => { + if (show) setMounted(true) + }, [show]) + + const handleAnimationEnd = React.useCallback(() => { + if (!show) setMounted(false) + }, [show]) + + if (!mounted) return null + + const Tag = as as React.ElementType + return ( + + {children} + + ) +} diff --git a/packages/ui/src/motion/useViewTransition.test.tsx b/packages/ui/src/motion/useViewTransition.test.tsx new file mode 100644 index 000000000..b88822c28 --- /dev/null +++ b/packages/ui/src/motion/useViewTransition.test.tsx @@ -0,0 +1,87 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + startViewTransition, + supportsViewTransitions, + useViewTransition +} from './useViewTransition' + +type DocWithVT = { startViewTransition?: (cb: () => void) => unknown } +const vtDoc = document as unknown as DocWithVT + +function setMatchMedia(reducedMotion: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('prefers-reduced-motion') ? reducedMotion : false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn() + })) +} + +afterEach(() => { + delete vtDoc.startViewTransition +}) + +describe('supportsViewTransitions', () => { + it('is false when the API is absent', () => { + expect(supportsViewTransitions()).toBe(false) + }) + it('is true when document.startViewTransition exists', () => { + vtDoc.startViewTransition = (cb) => cb() + expect(supportsViewTransitions()).toBe(true) + }) +}) + +describe('startViewTransition', () => { + it('runs the mutation directly when unsupported', () => { + const mutate = vi.fn() + startViewTransition(mutate) + expect(mutate).toHaveBeenCalledTimes(1) + }) + + it('routes the mutation through the API when supported', () => { + const api = vi.fn((cb: () => void) => cb()) + vtDoc.startViewTransition = api + const mutate = vi.fn() + startViewTransition(mutate) + expect(api).toHaveBeenCalledTimes(1) + expect(mutate).toHaveBeenCalledTimes(1) + }) +}) + +describe('useViewTransition', () => { + function Harness({ onReady }: { onReady: (fn: (m: () => void) => void) => void }) { + const withTransition = useViewTransition() + onReady(withTransition) + return null + } + + it('applies the mutation instantly under reduced motion (skips the API)', () => { + setMatchMedia(true) + const api = vi.fn((cb: () => void) => cb()) + vtDoc.startViewTransition = api + let withTransition!: (m: () => void) => void + render( (withTransition = fn)} />) + const mutate = vi.fn() + withTransition(mutate) + expect(mutate).toHaveBeenCalledTimes(1) + expect(api).not.toHaveBeenCalled() + }) + + it('uses a view transition when motion is allowed and supported', () => { + setMatchMedia(false) + const api = vi.fn((cb: () => void) => cb()) + vtDoc.startViewTransition = api + let withTransition!: (m: () => void) => void + render( (withTransition = fn)} />) + const mutate = vi.fn() + withTransition(mutate) + expect(api).toHaveBeenCalledTimes(1) + expect(mutate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/ui/src/motion/useViewTransition.ts b/packages/ui/src/motion/useViewTransition.ts new file mode 100644 index 000000000..c4492288d --- /dev/null +++ b/packages/ui/src/motion/useViewTransition.ts @@ -0,0 +1,65 @@ +/** + * useViewTransition — clean cross-fades for discrete UI swaps, zero library. + * + * Wraps the native View Transitions API (`document.startViewTransition`). The + * browser snapshots the page before and after your DOM mutation and cross- + * fades between them; opt individual elements into shared-element motion with + * `view-transition-name`. Where the API is missing (older Firefox) or the user + * prefers reduced motion, it degrades to an instant, un-animated mutation — + * never an error. + * + * const withTransition = useViewTransition() + * const reScope = (id: string) => withTransition(() => setCurrentSpace(id)) + * + * Use it for discrete, user-initiated swaps (re-scoping a list, switching a + * surface), not high-frequency updates. + */ +import { usePrefersReducedMotion } from '../hooks/useMediaQuery' + +/** + * Minimal structural view of the API. Standalone (does not extend `Document`) + * so it never collides with whichever lib.dom version is in play, and keeps + * `startViewTransition` optional so feature-detection and test teardown + * (`delete`) typecheck cleanly. + */ +type ViewTransitionCapable = { startViewTransition?: (callback: () => void) => unknown } + +function viewTransitionDoc(): ViewTransitionCapable | null { + if (typeof document === 'undefined') return null + return document as unknown as ViewTransitionCapable +} + +/** True when the running browser supports the View Transitions API. */ +export function supportsViewTransitions(): boolean { + return typeof viewTransitionDoc()?.startViewTransition === 'function' +} + +/** + * Run `mutate` inside a view transition when supported, else run it directly. + * Reduced-motion-unaware (no hook context) — prefer {@link useViewTransition} + * inside components. + */ +export function startViewTransition(mutate: () => void): void { + const doc = viewTransitionDoc() + if (!doc?.startViewTransition) { + mutate() + return + } + doc.startViewTransition(mutate) +} + +/** + * Returns a `withTransition(mutate)` function that cross-fades the DOM mutation + * when the browser supports it AND the user has not requested reduced motion; + * otherwise applies the mutation instantly. + */ +export function useViewTransition(): (mutate: () => void) => void { + const reduced = usePrefersReducedMotion() + return (mutate: () => void) => { + if (reduced) { + mutate() + return + } + startViewTransition(mutate) + } +} From 1bcf483d17aa0f2deeb88aab2883b107be7abfe6 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Wed, 17 Jun 2026 16:37:41 -0700 Subject: [PATCH 4/8] feat(ui): MOTION.md style guide + check:motion-vocab CI gate, de-drift transition-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/MOTION.md (the one-page vocabulary that doubles as the AI prompt) and scripts/check-motion-vocab.mjs, wired into the CI lint job. The gate bans transition-all, raw duration- literals, ease-bounce, and arbitrary animate-[…] across packages/ui/src + apps/web/src (the token-bearing scope). Codemods the 18 transition-all + 3 raw-duration sites in scope to explicit, compositor-only forms (transition-[opacity,transform], transition-[width], duration tokens) so the gate is green. 370 files scanned, 0 violations. Refs docs/explorations/0198. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 3 + apps/web/src/components/DataWorkspaceView.tsx | 2 +- apps/web/src/routes/social-import.tsx | 2 +- apps/web/src/workbench/views/TodayPanel.tsx | 2 +- docs/MOTION.md | 140 ++++++++++++++++++ package.json | 1 + packages/ui/src/composed/CommandPalette.tsx | 2 +- packages/ui/src/composed/ThemeToggle.tsx | 4 +- .../src/composed/comments/CommentPopover.tsx | 2 +- packages/ui/src/primitives/Checkbox.tsx | 2 +- packages/ui/src/primitives/Menu.tsx | 6 +- packages/ui/src/primitives/Modal.tsx | 4 +- packages/ui/src/primitives/Popover.tsx | 6 +- packages/ui/src/primitives/Select.tsx | 4 +- packages/ui/src/primitives/Tooltip.tsx | 6 +- scripts/check-motion-vocab.mjs | 109 ++++++++++++++ 16 files changed, 274 insertions(+), 21 deletions(-) create mode 100644 docs/MOTION.md create mode 100644 scripts/check-motion-vocab.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 980dcd62a..e2b0b8d1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - name: Plugin license policy (0196) run: pnpm check:plugin-licenses + - name: Motion vocabulary (0198) + run: pnpm check:motion-vocab + # The heavy half of the old lint job: build the workspace and typecheck it. # typecheck genuinely needs `^build` (turbo builds dependencies first), so it # stays coupled to the build. Runs in parallel with `lint`, `test`, and diff --git a/apps/web/src/components/DataWorkspaceView.tsx b/apps/web/src/components/DataWorkspaceView.tsx index 69aa33760..1dfe37518 100644 --- a/apps/web/src/components/DataWorkspaceView.tsx +++ b/apps/web/src/components/DataWorkspaceView.tsx @@ -701,7 +701,7 @@ function SocialImportJobsPanel({ jobs }: { jobs: SocialImportJobProgress[] }): J aria-label={`Import progress for ${job.archiveName}`} >
    diff --git a/apps/web/src/routes/social-import.tsx b/apps/web/src/routes/social-import.tsx index b7a63cf12..d88dd87c8 100644 --- a/apps/web/src/routes/social-import.tsx +++ b/apps/web/src/routes/social-import.tsx @@ -1032,7 +1032,7 @@ function CommitProgressPanel({ progress }: { progress: CommitProgress }): React. aria-label="Commit progress" >
    diff --git a/apps/web/src/workbench/views/TodayPanel.tsx b/apps/web/src/workbench/views/TodayPanel.tsx index dd6b364e8..b83a24dd4 100644 --- a/apps/web/src/workbench/views/TodayPanel.tsx +++ b/apps/web/src/workbench/views/TodayPanel.tsx @@ -25,7 +25,7 @@ function StrengthBar({ value }: { value: number }): JSX.Element { return (
    diff --git a/docs/MOTION.md b/docs/MOTION.md new file mode 100644 index 000000000..db00d929e --- /dev/null +++ b/docs/MOTION.md @@ -0,0 +1,140 @@ +# Motion Style Guide + +xNet's animation vocabulary. It is deliberately small: the same restraint that +makes the UI feel designed makes motion easy to author consistently — by a +person or an agent. If you can't express an animation with what's below, you're +probably over-animating. + +Source of truth: [`packages/ui/src/theme/motion.css`](../packages/ui/src/theme/motion.css). +Enforcement: [`scripts/check-motion-vocab.mjs`](../scripts/check-motion-vocab.mjs) +(runs in CI). Origin: [exploration 0198](explorations/0198_[_]_ELEGANT_COMPOSABLE_MOTION_SYSTEM.md). + +## The two laws + +1. **Enter is slower and decelerates** — `ease-out`, `duration-normal` (150ms). +2. **Exit is faster and accelerates** — `ease-in`, `duration-fast` (100ms). + +Motion that arrives gently and leaves briskly feels intentional. The reverse +feels broken. Every primitive below already bakes this in. + +## The vocabulary + +### Durations (everyday set in bold) + +| Token | Value | Use | +|---|---|---| +| `duration-fast` | **100ms** | hover / press feedback, **exits** | +| `duration-normal` | **150ms** | standard **enter** | +| `duration-slow` | **200ms** | emphasis enter — panels, dialogs, sheets | +| `duration-instant` | 0ms | edge case | +| `duration-slower` | 300ms | large background moves | +| `duration-slowest` | 400ms | rare, dramatic | + +### Easings + +| Token | Use | +|---|---| +| `ease-out` | **enters** (decelerate in) | +| `ease-in` | **exits** (accelerate away) | +| `ease-in-out` | moves / morphs (something already on screen relocating) | +| `ease-spring` | **direct-manipulation feedback only** — a toggle thumb, a checkbox pop, a drag pickup. Never ambient enters. | +| `linear` | continuous loops (spinner, shimmer, marquee) | + +> `ease-bounce` was retired. Its negative anticipation is the opposite of +> "minimal"; `ease-spring` covers everything that should feel springy. + +### Primitives + +All are compositor-only (`transform` + `opacity`) so they stay at 60fps: + +`fade` · `scale` (0.95→1) · `slide-up` · `slide-down` · `slide-left` · +`slide-right` · `collapse` (height — accordion/disclosure) · `pop` (spring +scale, for direct manipulation) · `shimmer` (skeletons) · `spin` (loaders) · +`pulse-subtle` (status/attention). + +## How to apply motion + +### 1. Hover / press / state — Tailwind utilities + +```tsx +// Use the shared transition utilities (NOT transition-all). +
    - +
    ) } diff --git a/apps/web/src/components/UndoToast.tsx b/apps/web/src/components/UndoToast.tsx index 498d7ee87..7aedc178f 100644 --- a/apps/web/src/components/UndoToast.tsx +++ b/apps/web/src/components/UndoToast.tsx @@ -7,6 +7,7 @@ * keyboard hint reminds users the action is reversible. */ import { useGlobalUndo } from '@xnetjs/react' +import { Presence } from '@xnetjs/ui' import { createContext, useCallback, @@ -57,28 +58,40 @@ export function UndoToastProvider({ children }: { children: ReactNode }): JSX.El await undo() }, [undo]) + // Latch the last toast so its text survives the exit animation, when + // `toast` has already flipped to null but is still animating out. + // Horizontal centering uses auto-margins (not -translate-x-1/2) so the + // slide-up keyframe's translateY animates cleanly without fighting a static + // transform. + const lastToastRef = useRef<{ id: number; message: string } | null>(null) + if (toast) lastToastRef.current = toast + const shown = toast ?? lastToastRef.current + return ( {children} - {toast ? ( -
    - {toast.message} - - - ⌘Z - -
    - ) : null} + + {shown ? ( +
    + {shown.message} + + + ⌘Z + +
    + ) : null} +
    ) } diff --git a/apps/web/src/workbench/TabBreadcrumb.tsx b/apps/web/src/workbench/TabBreadcrumb.tsx index cc192d3a8..95622faa9 100644 --- a/apps/web/src/workbench/TabBreadcrumb.tsx +++ b/apps/web/src/workbench/TabBreadcrumb.tsx @@ -11,6 +11,7 @@ */ import { FolderSchema } from '@xnetjs/data' import { useQuery } from '@xnetjs/react' +import { useViewTransition } from '@xnetjs/ui' import { FolderClosed, Users } from 'lucide-react' import { Fragment, useMemo } from 'react' import { useSpaces } from '../hooks/useSpaces' @@ -53,6 +54,7 @@ export function TabBreadcrumb({ tab }: { tab: WorkbenchTab | null }) { const { folderNames, spaceId } = useBreadcrumb(tab) const { getSpace } = useSpaces() const setCurrentSpace = useWorkbench((state) => state.setCurrentSpace) + const withTransition = useViewTransition() const space = getSpace(spaceId) if (!space && folderNames.length === 0) return null @@ -62,7 +64,7 @@ export function TabBreadcrumb({ tab }: { tab: WorkbenchTab | null }) { {space ? (