{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion",
  "type": "registry:lib",
  "title": "Motion",
  "description": "The motion engine the components share. Hooks for the instant, responsive and expressive tiers, exits, travel and stagger, and the compact and coarse device signals, all reading litt's tokens.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json"
  ],
  "files": [
    {
      "path": "registry/litt/lib/motion/index.tsx",
      "type": "registry:lib",
      "content": "\"use client\";\n// @litt/motion 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Motion for the gallery: the locked litt.md values everywhere, and a dial on\n * the Playground only.\n *\n * Three tiers (see CONTRIBUTING.md):\n * - instant     hover, press, toggles. A short ease-out tween. Invisible.\n * - responsive  menus, tabs, tooltips, the gliding highlight. A spring with no bounce.\n * - expressive  one signature moment per view: dialog, toasts, the list entrance.\n *\n * tokens.css owns every number. On every page the provider writes no CSS\n * variables at all, and the hooks below return values parsed from the same\n * tokens (tokens.ts), so CSS and JS springs agree by construction.\n *\n * On a page that sets `tune` (the Playground) the provider lets the dial\n * override the tokens: it writes inline variables on <html>, and removes them\n * again the moment the page is left. Components never write durations or\n * springs. They ask these hooks, so they follow the dial and reduced motion.\n */\nimport { createContext, useContext, useEffect, useLayoutEffect, useMemo, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { MotionConfig, MotionConfigContext, useReducedMotion, type Transition } from \"motion/react\";\nimport { toCss, type Spring } from \"./spring\";\nimport { readLocked, surfaceRadius, type Dial, type Locked } from \"./tokens\";\n\nexport { surfaceRadius, type Dial };\nexport { countText, FUN_SPRINGS, FunMode, honestCount, useFun, useFunGrab, useFunValue, type FunGrab, type FunMotion, type FunValue } from \"./fun\";\nexport {\n  DeviceProvider,\n  readDismiss,\n  useDevice,\n  useSheetOpen,\n  useSheetPresence,\n  useVisibleScreen,\n  type Device,\n  type DeviceMode,\n  type VisibleScreen,\n} from \"./device\";\n\nexport type Tier = \"instant\" | \"responsive\" | \"expressive\";\nexport type PresetName = \"locked\" | \"invisible\" | \"expressive\";\n\n/** The choices the Playground offers for the discrete controls. */\nexport const TRAVELS = [4, 8, 16, 24] as const;\nexport const RADII = [0, 2, 4, 6] as const;\n\nexport const PRESET_LABEL: Record<PresetName, string> = {\n  locked: `Locked (v${\"0.5\"})`,\n  invisible: \"Invisible\",\n  expressive: \"Expressive\",\n};\n\n/** Locked is whatever tokens.css says. The other two are trials to compare against it. */\nexport function makePresets(locked: Dial): Record<PresetName, Dial> {\n  return {\n    locked,\n    invisible: { instant: 120, responsive: { time: 0.16, bounce: 0 }, expressive: { time: 0.2, bounce: 0 }, stagger: 0, travel: 4, exit: 0.8 },\n    expressive: { instant: 120, responsive: { time: 0.28, bounce: 0.15 }, expressive: { time: 0.5, bounce: 0.25 }, stagger: 60, travel: 24, exit: 0.75 },\n  };\n}\n\ntype Ctx = {\n  /** The values the hooks use: the locked tokens, or the dial's on a tuning page. */\n  dial: Dial;\n  /** The tokens.css values, always. */\n  locked: Locked;\n  presets: Record<PresetName, Dial>;\n  preset: PresetName | \"custom\";\n  /** True on a page that sets `tune`. */\n  tuning: boolean;\n  /** True when something on the Playground differs from the tokens. */\n  modified: boolean;\n  setPreset: (p: PresetName) => void;\n  update: (patch: Partial<Dial>) => void;\n  /** Back to the tokens: dial, radius, slow motion and the reduced preview. */\n  reset: () => void;\n  slow: number;\n  setSlow: (n: number) => void;\n  reducedPreview: boolean;\n  setReducedPreview: (b: boolean) => void;\n  /** True when the system asks for reduced motion or the preview switch is on. */\n  reduced: boolean;\n  /** Control radius in px. Surfaces get this plus the 4px inset. */\n  radius: number;\n  setRadius: (r: number) => void;\n};\n\nconst DialContext = createContext<Ctx | null>(null);\n\n/* The Playground remembers a change, and only a change. An untouched visit\n   writes nothing, so a later change to tokens.css is never shadowed. */\nconst DIAL_KEY = \"litt-gallery:dial\";\nconst RADIUS_KEY = \"litt-gallery:radius\";\n\nconst isNum = (v: unknown): v is number => typeof v === \"number\" && Number.isFinite(v);\nconst isSpring = (v: unknown): v is Spring =>\n  typeof v === \"object\" && v !== null && isNum((v as Spring).time) && isNum((v as Spring).bounce) &&\n  (v as Spring).time >= 0.05 && (v as Spring).time <= 2 && (v as Spring).bounce >= 0 && (v as Spring).bounce <= 1;\n\nfunction readStoredDial(): Dial | null {\n  try {\n    const raw = JSON.parse(localStorage.getItem(DIAL_KEY) || \"null\") as Partial<Dial> | null;\n    if (raw && isNum(raw.instant) && isNum(raw.stagger) && isNum(raw.travel) && isNum(raw.exit) && isSpring(raw.responsive) && isSpring(raw.expressive)) {\n      return { instant: raw.instant, responsive: raw.responsive, expressive: raw.expressive, stagger: raw.stagger, travel: raw.travel, exit: raw.exit };\n    }\n  } catch {\n    /* storage can be unavailable or hold something else; fall back to the tokens */\n  }\n  return null;\n}\n\nfunction readStoredRadius(): number | null {\n  try {\n    const v = localStorage.getItem(RADIUS_KEY);\n    const r = v === null ? NaN : Number(v);\n    if ((RADII as readonly number[]).includes(r)) return r;\n  } catch {\n    /* ignore */\n  }\n  return null;\n}\n\nconst sameDial = (a: Dial, b: Dial) => JSON.stringify(a) === JSON.stringify(b);\n\n/** Every inline variable the Playground can write. All of them are removed on leave. */\nconst VARS = [\n  \"--litt-dur-instant\",\n  \"--litt-ease-responsive\",\n  \"--litt-dur-responsive\",\n  \"--litt-ease-expressive\",\n  \"--litt-dur-expressive\",\n  \"--litt-dur-exit-responsive\",\n  \"--litt-dur-exit-expressive\",\n  \"--litt-stagger\",\n  \"--litt-travel\",\n  \"--litt-radius-control\",\n  \"--litt-radius-surface\",\n  \"--litt-press-scale\",\n] as const;\n\nexport function MotionDialProvider({ tuning, children }: { tuning: boolean; children: ReactNode }) {\n  const system = useReducedMotion() ?? false;\n  const locked = useMemo(() => readLocked(system), [system]);\n  const presets = useMemo(() => makePresets(locked.dial), [locked]);\n\n  const [tuned, setTuned] = useState<Dial | null>(readStoredDial);\n  const [radiusTuned, setRadiusTuned] = useState<number | null>(readStoredRadius);\n  const [slow, setSlow] = useState(1);\n  const [reducedPreview, setReducedPreview] = useState(false);\n\n  const dial = tuning && tuned ? tuned : locked.dial;\n  const radius = tuning && radiusTuned !== null ? radiusTuned : locked.radius;\n  const slowNow = tuning ? slow : 1;\n  const reduced = system || (tuning && reducedPreview);\n  const modified = tuned !== null || radiusTuned !== null || slow !== 1 || reducedPreview;\n\n  const preset = (Object.keys(presets) as PresetName[]).find((k) => sameDial(presets[k], dial)) ?? \"custom\";\n\n  useEffect(() => {\n    try {\n      if (tuned) localStorage.setItem(DIAL_KEY, JSON.stringify(tuned));\n      else localStorage.removeItem(DIAL_KEY);\n      if (radiusTuned !== null) localStorage.setItem(RADIUS_KEY, String(radiusTuned));\n      else localStorage.removeItem(RADIUS_KEY);\n    } catch {\n      /* ignore */\n    }\n  }, [tuned, radiusTuned]);\n\n  /* Mirror the dial into CSS variables so CSS-only parts follow it too. Only\n     on a tuning page, only while something differs from the tokens, and always\n     cleaned up. A layout effect, so a page change never paints a stale value. */\n  useLayoutEffect(() => {\n    if (!tuning || !modified) return;\n    const s = document.documentElement.style;\n    const r = toCss(dial.responsive);\n    const e = toCss(dial.expressive);\n    const z = reduced ? 0 : 1;\n    const k = slowNow * z;\n    const exit = (sp: Spring) => Math.round(Math.round(sp.time * 1000) * dial.exit);\n    const set = (name: (typeof VARS)[number], value: string) => s.setProperty(name, value);\n    set(\"--litt-dur-instant\", `${dial.instant * k}ms`);\n    set(\"--litt-ease-responsive\", r.easing);\n    set(\"--litt-dur-responsive\", `${r.duration * k}ms`);\n    set(\"--litt-ease-expressive\", e.easing);\n    set(\"--litt-dur-expressive\", `${e.duration * k}ms`);\n    set(\"--litt-dur-exit-responsive\", `${exit(dial.responsive) * k}ms`);\n    set(\"--litt-dur-exit-expressive\", `${exit(dial.expressive) * k}ms`);\n    set(\"--litt-stagger\", `${dial.stagger * k}ms`);\n    set(\"--litt-travel\", `${dial.travel * z}px`);\n    set(\"--litt-radius-control\", `${radius}px`);\n    set(\"--litt-radius-surface\", `${surfaceRadius(radius)}px`);\n    /* Inline variables beat tokens.css's reduced-motion block, so mirror its press rule too. */\n    if (reduced) set(\"--litt-press-scale\", \"1\");\n    return () => VARS.forEach((n) => s.removeProperty(n));\n  }, [tuning, modified, dial, slowNow, reduced, radius]);\n\n  const commit = (next: Dial) => setTuned(sameDial(next, locked.dial) ? null : next);\n\n  const value: Ctx = {\n    dial,\n    locked,\n    presets,\n    preset,\n    tuning,\n    modified,\n    setPreset: (p) => commit(presets[p]),\n    update: (patch) => commit({ ...dial, ...patch }),\n    reset: () => {\n      setTuned(null);\n      setRadiusTuned(null);\n      setSlow(1);\n      setReducedPreview(false);\n    },\n    slow,\n    setSlow,\n    reducedPreview,\n    setReducedPreview,\n    reduced,\n    radius,\n    setRadius: (r) => setRadiusTuned(r === locked.radius ? null : r),\n  };\n\n  return (\n    <DialContext.Provider value={value}>\n      <MotionConfig reducedMotion={tuning && reducedPreview ? \"always\" : \"user\"}>{children}</MotionConfig>\n    </DialContext.Provider>\n  );\n}\n\n/** The locked tokens as a dial, for an app that mounts no <MotionDialProvider> (any app but the gallery). */\nfunction useStandaloneDial(enabled: boolean): Ctx | null {\n  const system = useReducedMotion() ?? false;\n  return useMemo(() => {\n    if (!enabled) return null;\n    const locked = readLocked(system);\n    const noop = () => {};\n    return {\n      dial: locked.dial,\n      locked,\n      presets: makePresets(locked.dial),\n      preset: \"locked\",\n      tuning: false,\n      modified: false,\n      setPreset: noop,\n      update: noop,\n      reset: noop,\n      slow: 1,\n      setSlow: noop,\n      reducedPreview: false,\n      setReducedPreview: noop,\n      reduced: system,\n      radius: locked.radius,\n      setRadius: noop,\n    };\n  }, [enabled, system]);\n}\n\n/** The dial the hooks read. Without a provider it is the locked tokens, so the components work in any app. */\nexport function useMotionDial(): Ctx {\n  const ctx = useContext(DialContext);\n  const standalone = useStandaloneDial(ctx === null);\n  return ctx ?? standalone!;\n}\n\n/* What tokens.css's reduced-motion block sets, for CSS transitions inside StillMotion. */\nconst STILL_TOKENS = {\n  \"--litt-dur-instant\": \"0ms\",\n  \"--litt-dur-responsive\": \"0ms\",\n  \"--litt-dur-expressive\": \"0ms\",\n  \"--litt-dur-exit-responsive\": \"0ms\",\n  \"--litt-dur-exit-expressive\": \"0ms\",\n  \"--litt-stagger\": \"0ms\",\n  \"--litt-travel\": \"0px\",\n  \"--litt-press-scale\": \"1\",\n} as CSSProperties;\n\n/**\n * Holds everything inside it still, as reduced motion does: the hooks return 0 (no duration, travel or stagger),\n * Motion skips its animations, and the CSS transitions read zeroed tier tokens from a display: contents wrapper,\n * which takes no part in layout. For a thumbnail or a preview of live components, where many entrances at once would\n * break the one expressive moment per view. Outside it, nothing changes.\n *\n * `active={false}` lets everything inside move again without remounting it, so a preview held still on arrival can\n * come alive when someone reaches for it, keeping its state and never replaying its entrance.\n */\nexport function StillMotion({ active = true, children }: { active?: boolean; children: ReactNode }) {\n  const ctx = useMotionDial();\n  const parent = useContext(MotionConfigContext);\n  const still = useMemo(() => (active ? { ...ctx, reduced: true } : ctx), [ctx, active]);\n  return (\n    <DialContext.Provider value={still}>\n      <MotionConfig reducedMotion={active ? \"always\" : parent.reducedMotion}>\n        <div className=\"contents\" style={active ? STILL_TOKENS : undefined}>\n          {children}\n        </div>\n      </MotionConfig>\n    </DialContext.Provider>\n  );\n}\n\n/** The enter transition for a tier. Pass it to a motion component's `transition`. */\nexport function useTransition(tier: Tier): Transition {\n  const { dial, locked, slow, reduced, tuning } = useMotionDial();\n  if (reduced) return { duration: 0 };\n  const k = tuning ? slow : 1;\n  if (tier === \"instant\") return { type: \"tween\", duration: (dial.instant / 1000) * k, ease: locked.easeOut };\n  const sp = dial[tier];\n  return { type: \"spring\", visualDuration: sp.time * k, bounce: sp.bounce };\n}\n\n/** The exit transition for a tier: quicker than the enter, a plain ease-out tween, never a bounce. */\nexport function useExit(tier: Tier): Transition {\n  const { dial, locked, slow, reduced, tuning } = useMotionDial();\n  if (reduced) return { duration: 0 };\n  const k = tuning ? slow : 1;\n  const base = tier === \"instant\" ? dial.instant / 1000 : dial[tier].time;\n  return { type: \"tween\", duration: base * dial.exit * k, ease: locked.easeOut };\n}\n\n/** How far things travel on entry, in px (0 under reduced motion). Use fractions of it, never more. */\nexport function useTravel(): number {\n  const { dial, reduced } = useMotionDial();\n  return reduced ? 0 : dial.travel;\n}\n\n/** Seconds between items in an expressive entrance (0 under reduced motion). */\nexport function useStagger(): number {\n  const { dial, slow, reduced, tuning } = useMotionDial();\n  return reduced ? 0 : (dial.stagger / 1000) * (tuning ? slow : 1);\n}\n\n/* One contract, three bodies (proposals/worlds.md, Bodies): a part picks its body by the material on <html>. */\nexport { byWorld, MaterialProvider, useMaterial } from \"./deal\";\n",
      "target": "lib/litt/motion/index.tsx"
    },
    {
      "path": "registry/litt/lib/motion/spring.ts",
      "type": "registry:lib",
      "content": "// @litt/motion 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n/**\n * Springs as time + bounce.\n *\n * One pair of numbers drives every output:\n * - Motion (React):  { type: \"spring\", visualDuration: time, bounce }\n * - Framer:          Spring transition, Time + Bounce fields\n * - Plain CSS:       an easing made with linear() plus a settle duration\n *\n * The conversion mirrors Motion's own: visualDuration sets the stiffness,\n * bounce sets the damping ratio (1 - bounce), mass is 1.\n */\nexport type Spring = { time: number; bounce: number }; // time in seconds, bounce 0..1\n\nexport function physics({ time, bounce }: Spring) {\n  const root = (2 * Math.PI) / (Math.max(time, 0.01) * 1.2);\n  const stiffness = root * root;\n  const ratio = Math.min(1, Math.max(0.05, 1 - bounce));\n  const damping = 2 * ratio * Math.sqrt(stiffness);\n  return { stiffness, damping, mass: 1 };\n}\n\n/** Simulate 0 → 1 until it settles. Returns [t (s), x] samples and the settle time. */\nexport function simulate(spring: Spring, step = 1 / 240) {\n  const { stiffness, damping, mass } = physics(spring);\n  let x = 0, v = 0, t = 0;\n  const pts: [number, number][] = [[0, 0]];\n  while (t < 4) {\n    const a = (-stiffness * (x - 1) - damping * v) / mass;\n    v += a * step; x += v * step; t += step;\n    pts.push([t, x]);\n    if (t > spring.time * 0.5 && Math.abs(x - 1) < 0.001 && Math.abs(v) < 0.01) break;\n  }\n  return { pts, settle: t };\n}\n\n/** Peak overshoot as a fraction (0.12 = 12% past the target). */\nexport function overshoot(spring: Spring) {\n  return Math.max(0, ...simulate(spring).pts.map(([, x]) => x - 1));\n}\n\n/**\n * A CSS linear() easing that reproduces the spring, plus its settle duration in ms.\n *\n * litt.md's check.mjs wants the list to start at 0 and end at exactly 1, and a\n * bounce-0 spring never passes 1. The solver is a fixed-step integration, so\n * the last sample lands a hair off the target and a critically damped spring\n * can creep a hair past it. Both are numerical noise, so they are corrected\n * here and the pasted tokens pass the check.\n */\nexport function toCss(spring: Spring, points = 40) {\n  const { pts, settle } = simulate(spring);\n  const out: number[] = [];\n  for (let i = 0; i <= points; i++) {\n    const idx = Math.round((i / points) * (pts.length - 1));\n    const v = pts[idx][1];\n    out.push(+(spring.bounce === 0 ? Math.min(v, 1) : v).toFixed(4));\n  }\n  out[0] = 0;\n  out[out.length - 1] = 1;\n  return { easing: `linear(${out.join(\", \")})`, duration: Math.round(settle * 1000) };\n}\n\n/** Sample for drawing: x normalised to the settle time. */\nexport function curve(spring: Spring, n = 48) {\n  const { pts } = simulate(spring);\n  return Array.from({ length: n + 1 }, (_, i) => {\n    const p = pts[Math.round((i / n) * (pts.length - 1))];\n    return [i / n, p[1]] as [number, number];\n  });\n}\n",
      "target": "lib/litt/motion/spring.ts"
    },
    {
      "path": "registry/litt/lib/motion/tokens.ts",
      "type": "registry:lib",
      "content": "// @litt/motion 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n/**\n * The locked values, read from the litt.md tokens the page actually uses.\n *\n * tokens.css owns every number. CSS reads them as custom properties and JS\n * springs read the same custom properties here, so the two can never drift.\n * Only the Playground overrides them, on its own page.\n *\n * Browsers and the build's CSS minifier write the same value in different\n * forms (\"100ms\" or \".1s\", \"0.75\" or \".75\"), so every parser here accepts both.\n */\nimport type { Spring } from \"./spring\";\n\nexport type Dial = {\n  /** ms, the instant tween. */\n  instant: number;\n  responsive: Spring;\n  expressive: Spring;\n  /** ms between items in an expressive entrance. */\n  stagger: number;\n  /** px, the furthest anything moves on entry. */\n  travel: number;\n  /** Exits run at this fraction of the spring's visual time, as a tween, never bouncing. */\n  exit: number;\n};\n\nexport type Locked = {\n  dial: Dial;\n  /** px, --litt-radius-control. */\n  radius: number;\n  /** --litt-ease-out as a cubic-bezier array for Motion. */\n  easeOut: [number, number, number, number];\n};\n\n/** A surface is a control plus the 4px inset, so nested corners stay concentric. */\nexport const surfaceRadius = (control: number) => (control === 0 ? 0 : control + 4);\n\nconst NUMBER = \"(-?(?:\\\\d+\\\\.?\\\\d*|\\\\.\\\\d+))\";\n\n/** What tokens.css ships, for a render with no document (a server). The browser reads the real values. */\nconst SERVER_VALUES: Record<string, string> = {\n  \"--litt-dur-instant\": \"100ms\",\n  \"--litt-spring-responsive\": \"200ms 0\",\n  \"--litt-spring-expressive\": \"350ms 0.12\",\n  \"--litt-stagger\": \"40ms\",\n  \"--litt-travel\": \"16px\",\n  \"--litt-exit\": \"0.75\",\n  \"--litt-radius-control\": \"4px\",\n  \"--litt-ease-out\": \"cubic-bezier(0.25, 0.46, 0.45, 0.94)\",\n};\n\nfunction computed(name: string): string {\n  if (typeof document === \"undefined\") return SERVER_VALUES[name] ?? \"\";\n  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n}\n\n/**\n * The value a token declares outside the reduced-motion block. Under the OS\n * setting tokens.css zeroes the durations, but the Playground still needs the\n * real numbers to show and to copy, so it reads the stylesheet rules instead\n * of the computed style. Returns undefined when it cannot find the rule.\n */\nfunction declared(name: string): string | undefined {\n  let found: string | undefined;\n  const walk = (rules: CSSRuleList) => {\n    for (const rule of Array.from(rules)) {\n      if (rule instanceof CSSMediaRule && /prefers-reduced-motion/.test(rule.conditionText)) continue;\n      if (rule instanceof CSSStyleRule) {\n        if (/(^|,)\\s*:root\\s*(,|$)/.test(rule.selectorText)) {\n          const v = rule.style.getPropertyValue(name).trim();\n          if (v) found = v;\n        }\n      } else if (\"cssRules\" in rule) {\n        walk((rule as CSSGroupingRule).cssRules);\n      }\n    }\n  };\n  for (const sheet of Array.from(document.styleSheets)) {\n    try {\n      walk(sheet.cssRules);\n    } catch {\n      /* a cross-origin sheet (the font stylesheet) hides its rules */\n    }\n  }\n  return found;\n}\n\ntype Read = (name: string) => string;\n\nfunction parseTime(name: string, v: string): number {\n  const m = new RegExp(`^${NUMBER}(ms|s)$`).exec(v);\n  if (!m) throw new Error(`tokens.css: ${name} should be a time such as 100ms, found \"${v}\"`);\n  return m[2] === \"s\" ? Number(m[1]) * 1000 : Number(m[1]);\n}\n\nfunction parsePx(name: string, v: string): number {\n  const m = new RegExp(`^${NUMBER}px$`).exec(v);\n  if (!m) throw new Error(`tokens.css: ${name} should be a length such as 16px, found \"${v}\"`);\n  return Number(m[1]);\n}\n\nfunction parseNumber(name: string, v: string): number {\n  const m = new RegExp(`^${NUMBER}$`).exec(v);\n  if (!m) throw new Error(`tokens.css: ${name} should be a plain number, found \"${v}\"`);\n  return Number(m[1]);\n}\n\nfunction parseSpring(name: string, v: string): Spring {\n  const m = new RegExp(`^${NUMBER}(ms|s)\\\\s+${NUMBER}$`).exec(v);\n  if (!m) throw new Error(`tokens.css: ${name} must read \"<time>ms <bounce>\", found \"${v}\"`);\n  return { time: (m[2] === \"s\" ? Number(m[1]) * 1000 : Number(m[1])) / 1000, bounce: Number(m[3]) };\n}\n\nfunction parseBezier(name: string, v: string): [number, number, number, number] {\n  const m = new RegExp(`^cubic-bezier\\\\(\\\\s*${NUMBER}\\\\s*,\\\\s*${NUMBER}\\\\s*,\\\\s*${NUMBER}\\\\s*,\\\\s*${NUMBER}\\\\s*\\\\)$`).exec(v);\n  if (!m) throw new Error(`tokens.css: ${name} must be a cubic-bezier(), found \"${v}\"`);\n  return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];\n}\n\n/**\n * Read the locked motion and shape tokens. Pass `ignoreReducedMotion` when the\n * OS asks for reduced motion and the real numbers are still wanted.\n */\nexport function readLocked(ignoreReducedMotion = false): Locked {\n  const read: Read = (name) => computed(name);\n  // These three are the only motion tokens the reduced-motion block rewrites.\n  const real: Read = (name) => (ignoreReducedMotion ? (declared(name) ?? computed(name)) : computed(name));\n  return {\n    dial: {\n      instant: parseTime(\"--litt-dur-instant\", real(\"--litt-dur-instant\")),\n      responsive: parseSpring(\"--litt-spring-responsive\", read(\"--litt-spring-responsive\")),\n      expressive: parseSpring(\"--litt-spring-expressive\", read(\"--litt-spring-expressive\")),\n      stagger: parseTime(\"--litt-stagger\", real(\"--litt-stagger\")),\n      travel: parsePx(\"--litt-travel\", real(\"--litt-travel\")),\n      exit: parseNumber(\"--litt-exit\", read(\"--litt-exit\")),\n    },\n    radius: parsePx(\"--litt-radius-control\", read(\"--litt-radius-control\")),\n    easeOut: parseBezier(\"--litt-ease-out\", read(\"--litt-ease-out\")),\n  };\n}\n",
      "target": "lib/litt/motion/tokens.ts"
    },
    {
      "path": "registry/litt/lib/motion/rules.ts",
      "type": "registry:lib",
      "content": "// @litt/motion 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n/**\n * The litt.md v0.5 motion and shape rules, as the Playground checks them.\n *\n * KEEP IN STEP WITH skills/litt-md/scripts/check.mjs. LIMITS mirrors its\n * check 9 (motion) and check 7 (radius): change a bound there and change it\n * here, in the same commit. workflows/evolve.md lists this file in the\n * Propagate step.\n *\n * What check.mjs enforces, and where it shows up below:\n * - instant is a tween of at most 240ms                      -> instantMs\n * - a spring's visual time is at most 350ms                  -> springMs\n * - a spring settles within 600ms                            -> settleMs\n * - bounce is at most 0.12, and exactly 0 on responsive      -> bounce\n * - an exit lasts round(visual time x --litt-exit)                -> exit, and tokensSnippet\n * - corners are 0, --litt-radius-control (4), --litt-radius-surface\n *   (control + 4) or the pill, and a block set 2px into a\n *   control the control radius less 2px (it follows the\n *   control, so nothing here changes for it)                -> control, RADIUS_PILL, cornerRule\n * - the last linear() stop is 1 and responsive never passes\n *   1                                                        -> spring.ts toCss\n *\n * One rule here is stricter than the script. check.mjs accepts any --litt-exit\n * between 0 and 1. The profile fixes exits at 0.75x, so a different value is\n * flagged as a change from the locked rule. Anything that passes these rows\n * also passes check.mjs; the reverse can differ on exit alone.\n *\n * Stagger and travel are not hard rules in v0.5. They appear under\n * \"Differs from locked\" only.\n *\n * spaceRule is a profile rule that check.mjs does not bound: foundations.md\n * (Scale, R9) puts spacing on --litt-space-1 to --litt-space-40. SPACE_SCALE copies that\n * scale, and npm run test:rules fails when it, RADIUS_PILL or the corners\n * cornerRule accepts drift from tokens.css.\n *\n * The Playground checks the motion rows (checkRules). The radius and the\n * typeface are tuned in the Customize panel on every page, which checks them\n * with shapeRule and typeRule and copies them with customizeSnippet. The\n * typeface is the Type decision in the taste profile, not a check.mjs bound:\n * the sans is the face tokens.css sets.\n */\nimport { toCss, type Spring } from \"./spring\";\nimport { surfaceRadius, type Dial, type Locked } from \"./tokens\";\n\nexport const LIMITS = {\n  /** The instant tween, in ms. */\n  instantMs: 240,\n  /** A spring's visual time, in ms. */\n  springMs: 350,\n  /** The time a spring takes to settle, in ms. */\n  settleMs: 600,\n  /** Bounce ceiling. Responsive is exactly 0. */\n  bounce: 0.12,\n  /** Exits run at this fraction of the visual time. */\n  exit: 0.75,\n  /** --litt-radius-control in px. The surface is this plus the 4px inset. */\n  control: 4,\n} as const;\n\nexport type RuleRow = { ok: boolean; text: string };\n\n/** The sans the Playground shows, beside the one tokens.css locks. */\nexport type TypeChoice = { name: string; stack: string; locked: string };\n\nconst ms = (v: number) => `${Math.round(v)}ms`;\nconst sec = (v: number) => ms(v * 1000);\nconst num = (v: number) => String(+v.toFixed(2));\n\n/** The settle time of each spring, in ms (the value for --litt-dur-responsive and --litt-dur-expressive). */\nexport function settleTimes(dial: Dial) {\n  return { responsive: toCss(dial.responsive).duration, expressive: toCss(dial.expressive).duration };\n}\n\n/** What an exit lasts in ms for each tier: round(visual time x exit), as check.mjs derives it. */\nexport function exitTimes(dial: Dial) {\n  const round = (t: number) => Math.round(Math.round(t * 1000) * dial.exit);\n  return { responsive: round(dial.responsive.time), expressive: round(dial.expressive.time) };\n}\n\n/** One row per hard rule, in the order check.mjs reads them. */\nexport function checkRules(dial: Dial): RuleRow[] {\n  const settle = settleTimes(dial);\n  return [\n    {\n      ok: dial.instant <= LIMITS.instantMs,\n      text: `Instant tween at most ${LIMITS.instantMs}ms: ${ms(dial.instant)}`,\n    },\n    {\n      ok: Math.round(Math.max(dial.responsive.time, dial.expressive.time) * 1000) <= LIMITS.springMs,\n      text: `Spring visual time at most ${LIMITS.springMs}ms: responsive ${sec(dial.responsive.time)}, expressive ${sec(dial.expressive.time)}`,\n    },\n    {\n      ok: Math.max(settle.responsive, settle.expressive) <= LIMITS.settleMs,\n      text: `Settle at most ${LIMITS.settleMs}ms: responsive ${ms(settle.responsive)}, expressive ${ms(settle.expressive)}`,\n    },\n    {\n      ok: dial.responsive.bounce === 0,\n      text: `Responsive bounce is 0: ${num(dial.responsive.bounce)}`,\n    },\n    {\n      ok: dial.expressive.bounce <= LIMITS.bounce,\n      text: `Expressive bounce at most ${LIMITS.bounce}: ${num(dial.expressive.bounce)}`,\n    },\n    {\n      ok: dial.exit === LIMITS.exit,\n      text: `Exits run at ${LIMITS.exit}x the spring time, as a tween that never bounces: ${num(dial.exit)}x`,\n    },\n  ];\n}\n\n/** The two spring tiers. Instant is a tween, so it has no spring to check. */\nexport type SpringTier = \"responsive\" | \"expressive\";\n\n/** One spring against its tier: the visual time, the settle and the bounce, the rows checkRules reads for that tier. */\nexport function springRules(spring: Spring, tier: SpringTier): RuleRow[] {\n  const settle = toCss(spring).duration;\n  return [\n    {\n      ok: Math.round(spring.time * 1000) <= LIMITS.springMs,\n      text: `Visual time at most ${LIMITS.springMs}ms: ${sec(spring.time)}`,\n    },\n    {\n      ok: settle <= LIMITS.settleMs,\n      text: `Settles within ${LIMITS.settleMs}ms: ${ms(settle)}`,\n    },\n    tier === \"responsive\"\n      ? { ok: spring.bounce === 0, text: `No bounce on the responsive tier: ${num(spring.bounce)}` }\n      : { ok: spring.bounce <= LIMITS.bounce, text: `Bounce at most ${LIMITS.bounce}: ${num(spring.bounce)}` },\n  ];\n}\n\n/**\n * The pill: half the tallest control (lg, 56), so every control on the scale rounds fully. Not 9999px: large\n * boxes on the control radius (a code block, a popover's inner area) would turn into stadiums and clip their text.\n */\nexport const PILL = 28;\n/** The Customize panel's radius choices: the Playground's steps, then the pill. */\nexport const SHAPES = [0, 2, 4, 6, PILL] as const;\n/** The surface radius for a control radius. A pill has no concentric surface, so cards and panels take 16px with it. */\nexport const shapeSurface = (control: number) => (control === PILL ? 16 : surfaceRadius(control));\n/** A control radius in words: \"Square\", \"4px\", \"Pill\". */\nexport const shapeName = (control: number) => (control === 0 ? \"Square\" : control === PILL ? \"Pill\" : `${control}px`);\n\n/** --litt-radius-pill in px: avatars and icon-only buttons. Not PILL, the Customize panel's pill for controls. */\nexport const RADIUS_PILL = 999;\n\n/** One component corner (check 7): 0, the control radius, the surface radius or the pill. */\nexport function cornerRule(px: number, subject: string): RuleRow {\n  const surface = surfaceRadius(LIMITS.control);\n  return {\n    ok: px === 0 || px === LIMITS.control || px === surface || px === RADIUS_PILL,\n    text: `${subject} on litt's corners, 0, ${LIMITS.control}px, ${surface}px or the pill: ${px === RADIUS_PILL ? \"the pill\" : `${px}px`}`,\n  };\n}\n\n/** The spacing scale (foundations.md, Scale): each step in px, with its token. */\nexport const SPACE_SCALE = [\n  [4, \"--litt-space-1\"],\n  [8, \"--litt-space-2\"],\n  [12, \"--litt-space-3\"],\n  [16, \"--litt-space-4\"],\n  [24, \"--litt-space-6\"],\n  [32, \"--litt-space-8\"],\n  [48, \"--litt-space-12\"],\n  [64, \"--litt-space-16\"],\n  [96, \"--litt-space-24\"],\n  [128, \"--litt-space-32\"],\n  [160, \"--litt-space-40\"],\n] as const;\n\n/** One space (R9): none, or a step on the scale. Off it, the row names the steps on either side. */\nexport function spaceRule(px: number, subject: string): RuleRow {\n  const text = `${subject} on the spacing scale: ${px}px`;\n  if (px === 0 || SPACE_SCALE.some(([step]) => step === px)) return { ok: true, text };\n  const below = SPACE_SCALE.filter(([step]) => step < px).pop();\n  const above = SPACE_SCALE.find(([step]) => step > px);\n  const near = [below, above].flatMap((s) => (s ? [`${s[0]}px`] : [])).join(\" or \");\n  return { ok: false, text: `${text}, use ${near}` };\n}\n\n/** The radius rule (check 7). */\nexport function shapeRule(radius: number): RuleRow {\n  const now = radius === PILL ? \"pill controls\" : `${radius}px controls`;\n  return {\n    ok: radius === LIMITS.control,\n    text: `Controls ${LIMITS.control}px, surfaces ${surfaceRadius(LIMITS.control)}px, pills unchanged: ${now}, ${shapeSurface(radius)}px surfaces`,\n  };\n}\n\n/** The Type decision: the sans is the face tokens.css sets. */\nexport function typeRule(type: TypeChoice): RuleRow {\n  return { ok: type.name === type.locked, text: `The sans is ${type.locked}, with Geist Mono for code and data: ${type.name}` };\n}\n\n/** Every value that is not the locked one, in words. Empty when the dial matches the tokens. */\nexport function diffFromLocked(dial: Dial, locked: Locked): string[] {\n  const out: string[] = [];\n  const d = locked.dial;\n  const add = (label: string, now: string, was: string) => now !== was && out.push(`${label} ${was} → ${now}`);\n  add(\"Instant\", ms(dial.instant), ms(d.instant));\n  add(\"Responsive time\", sec(dial.responsive.time), sec(d.responsive.time));\n  add(\"Responsive bounce\", num(dial.responsive.bounce), num(d.responsive.bounce));\n  add(\"Expressive time\", sec(dial.expressive.time), sec(d.expressive.time));\n  add(\"Expressive bounce\", num(dial.expressive.bounce), num(d.expressive.bounce));\n  add(\"Stagger\", ms(dial.stagger), ms(d.stagger));\n  add(\"Travel\", `${dial.travel}px`, `${d.travel}px`);\n  add(\"Exit\", `${num(dial.exit)}x`, `${num(d.exit)}x`);\n  return out;\n}\n\n/** The tokens.css lines for this dial, ready to paste. Passes check.mjs when checkRules is all ok. */\nexport function tokensSnippet(dial: Dial): string {\n  const r = toCss(dial.responsive);\n  const e = toCss(dial.expressive);\n  const exit = exitTimes(dial);\n  return [\n    `/* Motion, from the litt.md Playground. Springs are time + bounce:`,\n    `   Motion visualDuration + bounce, Framer Time + Bounce. */`,\n    `--litt-dur-instant: ${Math.round(dial.instant)}ms;`,\n    `--litt-spring-responsive: ${Math.round(dial.responsive.time * 1000)}ms ${num(dial.responsive.bounce)};`,\n    `--litt-dur-responsive: ${r.duration}ms;`,\n    `--litt-ease-responsive: ${r.easing};`,\n    `--litt-spring-expressive: ${Math.round(dial.expressive.time * 1000)}ms ${num(dial.expressive.bounce)};`,\n    `--litt-dur-expressive: ${e.duration}ms;`,\n    `--litt-ease-expressive: ${e.easing};`,\n    `--litt-exit: ${num(dial.exit)};`,\n    `--litt-dur-exit-responsive: ${exit.responsive}ms;`,\n    `--litt-dur-exit-expressive: ${exit.expressive}ms;`,\n    `--litt-stagger: ${Math.round(dial.stagger)}ms;`,\n    `--litt-travel: ${dial.travel}px;`,\n  ].join(\"\\n\");\n}\n\n/** The tokens.css lines for the Customize panel's choices: only what differs from the locked values. */\nexport function customizeSnippet(radius: number, lockedRadius: number, type: TypeChoice): string {\n  const out: string[] = [];\n  if (type.name !== type.locked) out.push(`/* Type: every world sets this face in place of its own; sizes, weights by role and tracking stay as they are. */`, `--litt-font-choice: ${type.stack};`);\n  if (radius !== lockedRadius) {\n    if (out.length) out.push(``);\n    const note = radius === PILL ? `/* Shape: pill controls (28px is half the 56px lg control); surfaces take 16px, since a pill has no concentric surface. */` : `/* Shape: surfaces are controls plus the 4px inset. */`;\n    out.push(note, `--litt-radius-control: ${radius}px;`, `--litt-radius-surface: ${shapeSurface(radius)}px;`);\n  }\n  return out.join(\"\\n\");\n}\n",
      "target": "lib/litt/motion/rules.ts"
    },
    {
      "path": "registry/litt/lib/motion/device.ts",
      "type": "registry:lib",
      "content": "// @litt/motion 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n/**\n * The two device signals (foundations.md, section 6).\n *\n * - compact  the viewport is narrower than 640 (40rem), the break the layout already uses.\n * - coarse   the primary pointer is a finger.\n *\n * They are separate on purpose: a landscape phone is coarse and not compact, and a narrow desktop window is\n * compact and not coarse. Use `compact` for presentation (a modal becomes a sheet) and `coarse` for input\n * (long press, 44px targets, hidden key hints). In CSS use `max-sm:` and `pointer-coarse:` instead.\n *\n * Both are false on the server and while hydrating, so Next.js renders the desktop markup and hydrates without a\n * mismatch, then follow the media queries live (a rotation or a resized window updates them). Never read the\n * user agent.\n *\n * The Playground can force a device with <DeviceProvider mode=\"phone\">, so a desktop can preview the phone paths.\n *\n * Two helpers for the phone overlays live here too, because more than one component needs them:\n * - useVisibleScreen: the part of the screen a person can see, which shrinks when the on-screen keyboard opens.\n *   The command menu and the bottom sheet size themselves to it, so the keyboard never covers a row or a footer.\n * - useSheetOpen and useSheetPresence: whether a bottom sheet is open, so a toast can move to the top edge\n *   instead of covering the sheet's buttons.\n */\nimport { createContext, createElement, useContext, useEffect, useLayoutEffect, useState, useSyncExternalStore, type ReactNode } from \"react\";\n\nexport type Device = { compact: boolean; coarse: boolean };\nexport type DeviceMode = \"auto\" | \"phone\" | \"desktop\";\n\nconst COMPACT = \"(max-width: 39.999rem)\";\nconst COARSE = \"(pointer: coarse)\";\n\nconst lists = new Map<string, MediaQueryList>();\nfunction list(query: string): MediaQueryList | null {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return null;\n  let mql = lists.get(query);\n  if (!mql) {\n    mql = window.matchMedia(query);\n    lists.set(query, mql);\n  }\n  return mql;\n}\n\nfunction useMedia(query: string): boolean {\n  return useSyncExternalStore(\n    (onChange) => {\n      const mql = list(query);\n      if (!mql) return () => {};\n      mql.addEventListener(\"change\", onChange);\n      return () => mql.removeEventListener(\"change\", onChange);\n    },\n    () => list(query)?.matches ?? false,\n    () => false,\n  );\n}\n\nconst ModeContext = createContext<DeviceMode>(\"auto\");\n\n/** Forces the device for everything inside it. Only the Playground uses it; \"auto\" follows the real screen. */\nexport function DeviceProvider({ mode, children }: { mode: DeviceMode; children: ReactNode }) {\n  return createElement(ModeContext.Provider, { value: mode }, children);\n}\n\n/** `{ compact, coarse }` for this screen, or what the nearest DeviceProvider forces. */\nexport function useDevice(): Device {\n  const mode = useContext(ModeContext);\n  const compact = useMedia(COMPACT);\n  const coarse = useMedia(COARSE);\n  if (mode === \"phone\") return { compact: true, coarse: true };\n  if (mode === \"desktop\") return { compact: false, coarse: false };\n  return { compact, coarse };\n}\n\n/** The part of the screen a person can see: its top edge and height, in layout pixels. */\nexport type VisibleScreen = { top: number; height: number };\n\n/**\n * The visible screen, from window.visualViewport. It shrinks when the on-screen keyboard opens and moves when the\n * page is panned to keep a field in view; the layout viewport, `fixed inset-0` and 100dvh do not always follow it.\n * Null while disabled, on the server, before the first read, or with no visual viewport: fall back to 100dvh then.\n *\n * @param enabled - Read and follow it only while this is true, such as while a sheet is on a compact screen.\n */\nexport function useVisibleScreen(enabled: boolean): VisibleScreen | null {\n  const [screen, setScreen] = useState<VisibleScreen | null>(null);\n  useLayoutEffect(() => {\n    const vv = typeof window === \"undefined\" ? null : window.visualViewport;\n    if (!enabled || !vv) return;\n    const read = () => {\n      const next = { top: Math.round(vv.offsetTop), height: Math.round(vv.height) };\n      setScreen((prev) => (prev && prev.top === next.top && prev.height === next.height ? prev : next));\n    };\n    read();\n    vv.addEventListener(\"resize\", read);\n    vv.addEventListener(\"scroll\", read);\n    return () => {\n      vv.removeEventListener(\"resize\", read);\n      vv.removeEventListener(\"scroll\", read);\n      setScreen(null);\n    };\n  }, [enabled]);\n  return enabled ? screen : null;\n}\n\n/* How many bottom sheets are open on the page. One page shares it, like the media queries. */\nlet sheets = 0;\nconst sheetListeners = new Set<() => void>();\nconst sheetChanged = (by: number) => {\n  sheets += by;\n  sheetListeners.forEach((listener) => listener());\n};\n\n/**\n * Counts a bottom sheet as open while `open` is true. The sheet calls it with its open state (not its exit), so a\n * toast fired as the sheet closes goes to the usual edge.\n *\n * @param open - Whether the sheet is open.\n */\nexport function useSheetPresence(open: boolean): void {\n  useEffect(() => {\n    if (!open) return;\n    sheetChanged(1);\n    return () => sheetChanged(-1);\n  }, [open]);\n}\n\n/** Whether a bottom sheet is open anywhere on the page. False on the server and while hydrating. */\nexport function useSheetOpen(): boolean {\n  return useSyncExternalStore(\n    (onChange) => {\n      sheetListeners.add(onChange);\n      return () => sheetListeners.delete(onChange);\n    },\n    () => sheets > 0,\n    () => false,\n  );\n}\n\n/**\n * The dismissal thresholds from tokens.css: a released drag leaves when it has travelled `distance` of the\n * surface's size, or moves faster than `velocity` px per ms. Read at gesture time, in the browser.\n */\nexport function readDismiss(): { distance: number; velocity: number } {\n  const style = getComputedStyle(document.documentElement);\n  const num = (name: string) => {\n    const raw = style.getPropertyValue(name).trim();\n    const v = Number(raw);\n    if (!raw || !Number.isFinite(v) || v <= 0) throw new Error(`tokens.css: ${name} should be a positive number, found \"${raw}\"`);\n    return v;\n  };\n  return { distance: num(\"--litt-dismiss-distance\"), velocity: num(\"--litt-dismiss-velocity\") };\n}\n",
      "target": "lib/litt/motion/device.ts"
    },
    {
      "path": "registry/litt/lib/motion/drag.ts",
      "type": "registry:lib",
      "content": "// @litt/motion 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n/**\n * Drag to dismiss (foundations.md, section 6): a sheet dragged down, a toast swiped sideways.\n *\n * ```tsx\n * const drag = useDismissDrag({ direction: \"down\", onDismiss: () => setOpen(false) });\n * <motion.div\n *   {...drag.panel}                                   // data attribute, touch-action and the moving MotionValue\n *   style={{ ...drag.panel.style, ...yourStyle }}     // merge, do not replace\n *   onPointerDown={drag.onPointerDown}                // the whole panel may start a drag (or put it on a handle)\n *   initial={{ y: \"100%\" }} animate={{ y: 0 }}        // enter and exit move the SAME value the finger moves\n *   exit={{ y: \"100%\", transition: drag.exit }}\n * />\n * ```\n *\n * The rules (foundations section 6; the thresholds come from tokens.css through readDismiss(), never from here):\n * - While a finger is down the surface follows it one to one, with no easing.\n * - On release it leaves if it has travelled `--litt-dismiss-distance` of its size (height for \"down\", width sideways)\n *   or moves faster than `--litt-dismiss-velocity`. `onDismiss` fires once; the caller closes the surface and its exit\n *   (`exit={{ ..., transition: drag.exit }}`) plays from where the finger let go, on the exit token for the tier.\n * - Otherwise it springs back on the responsive tier with the speed it was released at, and never bounces.\n *   In fun mode (fun.tsx, a trial) it springs back on fun mode's throw spring instead, so it comes home with a\n *   little life; it still rests exactly at 0. Reduced motion turns fun mode off.\n * - Pulled the wrong way it resists and stops at `--litt-space-6` (sideways has no wrong way).\n * - A drag cannot start in a slider (`input[type=range]`, `[role=slider]`), in `[data-no-drag]`, in a text field\n *   or select that has focus, in a scrolling region that is not at its start, or in the first 500ms after\n *   `enabled` turned true. An unfocused field does not block: a thumb that lands on one still drags.\n *   A scrolling region that IS at its start gives way to the scroll: only a first move in the leaving direction\n *   claims the touch; a first move the other way scrolls the region.\n * - Reduced motion: the drag still follows the finger and the release is instant (the hooks return duration 0).\n * - Transform only. Every gesture needs a button that does the same thing; this hook never replaces one.\n *\n * Import it from \"@/motion/drag\", not \"@/motion\": this file uses the tier hooks from ./index, so index does not\n * re-export it (a re-export would make the two modules depend on each other, which Rollup splits badly).\n *\n * Final API (the stub's names are kept; everything marked \"added\" is new):\n * - `useDismissDrag(options)` -> `{ panel, onPointerDown, offset, exit }`\n * - options: `direction` \"down\" | \"left\" | \"right\" | \"sideways\" (a sheet is \"down\", a toast \"sideways\"),\n *   `onDismiss(info)` (added: `info.direction` is the way it left, \"down\" | \"left\" | \"right\", and `info.velocity`\n *   is the release speed in px per ms; `() => void` still fits), `enabled` (default true), `tier`.\n *   Set `enabled` to false when a drag makes no sense (a toast on a fine pointer). Turning it true starts the\n *   500ms guard. Inside an AnimatePresence the hook reads `useIsPresent()` itself, so it is also off while the\n *   surface leaves: you never pass that in.\n * - `panel` (typed now, was Record<string, unknown>): `{ style, \"data-dismiss-panel\" }`. `style` holds the\n *   MotionValue (`y` for \"down\", `x` otherwise) plus `touch-action` while enabled, so merge it into your own style.\n *   Motion runs enter, exit and the finger on that one value, which is why the exit starts where the finger let go.\n *   Put `initial={{ y: \"100%\" }}` on it and Motion seeds the value with the string \"100%\" until it animates.\n * - `onPointerDown`: put it on the element a drag may start from. It ignores events from portaled children.\n * - `offset`: that same MotionValue, in px along the axis (it also holds the enter and exit travel, and can be a\n *   CSS length string such as \"100%\" or \"0px\" while Motion runs its own enter or exit, so a derived value should read\n *   numbers and lengths, as the Dialog's scrim does).\n * - `exit` (added): `useExit(tier)`, the leave transition for the tier, so a dragged leave and a button leave match.\n *\n * Interpretations worth knowing: if `onDismiss` does not end up closing the surface (a controlled Dialog that\n * ignores `false` while a save runs), the hook sees `enabled` still true two frames later and springs back.\n * Thresholds use the box size, so a sheet that hides `--litt-space-6` below the screen counts that too. After a mouse\n * drag the click that follows is swallowed, so letting go over a button does not press it.\n */\nimport { useCallback, useEffect, useMemo, useRef, type PointerEvent as ReactPointerEvent } from \"react\";\nimport { animate, useIsPresent, useMotionValue, type AnimationPlaybackControls, type MotionStyle, type MotionValue, type Transition, type ValueAnimationTransition } from \"motion/react\";\nimport { readDismiss } from \"./device\";\nimport { useFun } from \"./fun\";\nimport { useExit, useTransition } from \"./index\";\n\nexport type DismissDirection = \"down\" | \"left\" | \"right\" | \"sideways\";\n\nexport type DismissInfo = {\n  /** The way the surface left. \"sideways\" resolves to \"left\" or \"right\" by the side it was going. */\n  direction: \"down\" | \"left\" | \"right\";\n  /** The release speed along the leaving direction, in px per ms (0 when it was held still before letting go). */\n  velocity: number;\n};\n\nexport type DismissDragOptions = {\n  /** Which way the surface leaves. A sheet is \"down\"; a toast is \"sideways\" (either way). */\n  direction: DismissDirection;\n  /** Called once the release decides to leave. The caller closes the surface (its exit then plays from here). */\n  onDismiss: (info: DismissInfo) => void;\n  /** Off when the surface is closed or leaving, or a drag makes no sense (for example on fine pointers for a toast). Default true. */\n  enabled?: boolean;\n  /** The tier whose exit token the leave uses (`exit` in the result). Default \"expressive\" for \"down\", \"responsive\" sideways. */\n  tier?: \"responsive\" | \"expressive\";\n};\n\nexport type DismissPanel = {\n  /** The moving MotionValue (`y` or `x`) and `touch-action`. Merge it into your own style. */\n  style: MotionStyle;\n  /** Lets the pointer handler find the moving element when it sits on a handle inside it. */\n  \"data-dismiss-panel\": \"\" | undefined;\n};\n\nexport type DismissDrag = {\n  /** Spread on the moving `motion` element, then merge `panel.style` into its style. */\n  panel: DismissPanel;\n  /** Put on the element a drag may start from (the whole panel, or a handle); the guards above apply. */\n  onPointerDown: (event: ReactPointerEvent) => void;\n  /** The offset along the drag axis, in px, for anything that should follow the drag (a scrim's opacity). */\n  offset: MotionValue<number>;\n  /** The leave transition for the tier: use it as the `transition` of the exit. */\n  exit: Transition;\n};\n\n/* Interaction constants. These are distances and delays of gesture recognition, not motion tokens. */\nconst OPEN_GUARD_MS = 500; // foundations.md section 6: drags are ignored this long after the sheet opens\nconst SLOP = 6; // px of travel before a touch is a drag, not a tap\nconst WINDOW_MS = 100; // the release velocity is read from the last this-many ms of movement\nconst PULL_BACK = 0.5; // how stiff the wrong-way pull starts (1 would be one to one)\n\n/* A drag never starts in these: they own the gesture (foundations.md section 6). */\nconst NO_DRAG = [\"[data-no-drag]\", \"input[type=range]\", \"[role=slider]\"].join(\",\");\n\n/* Text fields own the gesture only while they have focus, when a drag moves the caret or selects text. An unfocused\n   field is just part of the panel, so a thumb that lands on one still drags the sheet. Radio, checkbox and button\n   inputs are never text fields. */\nconst TEXT_FIELD = [\n  \"input:not([type=checkbox],[type=radio],[type=button],[type=submit],[type=reset],[type=image],[type=file],[type=color],[type=hidden],[type=range])\",\n  \"textarea\",\n  \"select\",\n  \"[role=textbox]\",\n  \"[contenteditable]:not([contenteditable=false])\",\n].join(\",\");\n\nconst SIGN: Record<DismissDirection, 1 | -1 | 0> = { down: 1, right: 1, left: -1, sideways: 0 };\n\n/** `--litt-space-6` in px, from tokens.css (a length in px or rem). Read at gesture time, like readDismiss(). */\nfunction readLimit(): number {\n  const root = document.documentElement;\n  const raw = getComputedStyle(root).getPropertyValue(\"--litt-space-6\").trim();\n  const v = parseFloat(raw);\n  if (!Number.isFinite(v) || v <= 0) throw new Error(`tokens.css: --litt-space-6 should be a length, found \"${raw}\"`);\n  return raw.endsWith(\"rem\") ? v * parseFloat(getComputedStyle(root).fontSize) : v;\n}\n\n/** The pull past rest: starts at PULL_BACK of the finger and flattens out at `limit`. */\nconst resist = (pulled: number, limit: number) => limit * (1 - Math.exp((-PULL_BACK * pulled) / limit));\n\n/**\n * What a touch that starts at `from` may do, looking up to the moving panel. \"blocked\": a region that scrolls on\n * this axis is not at its start. \"contested\": one is at its start, so the first move decides (scroll or drag).\n */\nfunction scanScroll(from: Element, panel: Element, direction: DismissDirection): \"free\" | \"contested\" | \"blocked\" {\n  const vertical = direction === \"down\";\n  let contested = false;\n  for (let el: Element | null = from; el; el = el.parentElement) {\n    const overflow = vertical ? getComputedStyle(el).overflowY : getComputedStyle(el).overflowX;\n    if (/auto|scroll|overlay/.test(overflow)) {\n      const room = vertical ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth;\n      if (room > 1) {\n        const at = vertical ? el.scrollTop : el.scrollLeft;\n        if (direction === \"sideways\") return \"blocked\"; // either way is a scroll\n        const atStart = direction === \"left\" ? at >= room - 1 : at <= 0.5; // the edge a swipe this way pulls toward\n        if (!atStart) return \"blocked\";\n        contested = true;\n      }\n    }\n    if (el === panel) break;\n  }\n  return contested ? \"contested\" : \"free\";\n}\n\nexport function useDismissDrag({ direction, onDismiss, enabled = true, tier }: DismissDragOptions): DismissDrag {\n  const offset = useMotionValue(0);\n  /* Inside an AnimatePresence the surface is also off while it leaves, so a late release can never fight the exit. */\n  const present = useIsPresent();\n  const active = enabled && present;\n  const responsive = useTransition(\"responsive\");\n  const fun = useFun();\n  const spring = fun ? fun.throw : responsive;\n  const exit = useExit(tier ?? (direction === \"down\" ? \"expressive\" : \"responsive\"));\n\n  /* Handlers live as long as a gesture, so they read the latest options through a ref. */\n  const live = useRef({ direction, onDismiss, enabled: active, spring });\n  live.current = { direction, onDismiss, enabled: active, spring };\n  const openedAt = useRef(0);\n  const latch = useRef(false); // a release already decided to leave; ignore touches until it is closed or springs back\n  const abort = useRef<(() => void) | null>(null);\n  const running = useRef<AnimationPlaybackControls | null>(null);\n\n  const springBack = useCallback(\n    (velocity: number) => {\n      running.current?.stop();\n      running.current = animate(offset, 0, { ...live.current.spring, velocity: velocity * 1000 } as ValueAnimationTransition<number>);\n    },\n    [offset],\n  );\n\n  useEffect(() => {\n    if (active) {\n      openedAt.current = performance.now();\n      latch.current = false;\n    } else {\n      abort.current?.();\n    }\n  }, [active]);\n\n  useEffect(\n    () => () => {\n      abort.current?.();\n      running.current?.stop();\n    },\n    [],\n  );\n\n  const onPointerDown = useCallback(\n    (event: ReactPointerEvent) => {\n      const cfg = live.current;\n      if (!cfg.enabled || latch.current || abort.current || !event.isPrimary) return;\n      if (event.pointerType === \"mouse\" && event.button !== 0) return;\n      if (performance.now() - openedAt.current < OPEN_GUARD_MS) return;\n      const target = event.target as Element;\n      if (!event.currentTarget.contains(target)) return; // a portaled child's event bubbling through React\n      const panel = event.currentTarget.closest<HTMLElement>(\"[data-dismiss-panel]\") ?? (event.currentTarget as HTMLElement);\n      const hit = target.closest(NO_DRAG);\n      if (hit && panel.contains(hit)) return;\n      const field = target.closest(TEXT_FIELD);\n      if (field && panel.contains(field) && field.contains(document.activeElement)) return;\n      const region = scanScroll(target, panel, cfg.direction);\n      if (region === \"blocked\") return;\n\n      const dir = cfg.direction;\n      const vertical = dir === \"down\";\n      const sign = SIGN[dir];\n      const { distance, velocity } = readDismiss();\n      const limit = readLimit();\n      const size = vertical ? panel.offsetHeight : panel.offsetWidth;\n      const id = event.pointerId;\n      const touch = event.pointerType === \"touch\";\n      const x0 = event.clientX;\n      const y0 = event.clientY;\n      const savedSelect = panel.style.userSelect;\n      let claimed = false;\n      let base = 0;\n      let along = 0;\n      let samples: { t: number; a: number }[] = [];\n\n      const place = (a: number) => {\n        along = a;\n        if (sign === 0) return offset.set(base + a);\n        const progress = a * sign;\n        offset.set(base + sign * (progress >= 0 ? progress : -resist(-progress, limit)));\n      };\n\n      const move = (e: PointerEvent) => {\n        if (e.pointerId !== id) return;\n        const dx = e.clientX - x0;\n        const dy = e.clientY - y0;\n        const a = vertical ? dy : dx;\n        const across = vertical ? dx : dy;\n        if (!claimed) {\n          if (Math.abs(across) > SLOP && Math.abs(across) > Math.abs(a)) return stop(); // the other axis: not ours\n          if (Math.abs(a) < SLOP) return;\n          if (region === \"contested\" && sign !== 0 && a * sign < 0) return stop(); // the region scrolls the other way\n          claimed = true;\n          running.current?.stop();\n          const now = offset.get() as unknown;\n          base = typeof now === \"number\" ? now : 0; // grabbed mid spring-back: carry on from there\n          panel.style.userSelect = \"none\";\n          window.getSelection()?.removeAllRanges();\n          try {\n            panel.setPointerCapture(id);\n          } catch {\n            /* the pointer is already gone */\n          }\n        }\n        place(a);\n        samples.push({ t: e.timeStamp, a });\n        samples = samples.filter((s) => e.timeStamp - s.t <= WINDOW_MS);\n      };\n\n      const swallowClick = (e: Event) => {\n        e.stopPropagation();\n        e.preventDefault();\n      };\n\n      const release = (e: PointerEvent, cancelled: boolean) => {\n        if (e.pointerId !== id) return;\n        const wasClaimed = claimed;\n        stop();\n        if (!wasClaimed) return;\n        if (!touch) {\n          window.addEventListener(\"click\", swallowClick, { capture: true, once: true });\n          setTimeout(() => window.removeEventListener(\"click\", swallowClick, true), 0);\n        }\n        // Velocity along the axis over the last WINDOW_MS; zero if the finger rested before letting go.\n        const last = samples[samples.length - 1];\n        const first = samples[0];\n        const fresh = last && e.timeStamp - last.t <= WINDOW_MS;\n        const vAxis = fresh && last.t > first.t ? (last.a - first.a) / (last.t - first.t) : 0;\n        const progress = sign === 0 ? Math.abs(along) : along * sign;\n        const speed = sign === 0 ? Math.abs(vAxis) : vAxis * sign;\n        if (!cancelled && (progress >= distance * size || speed >= velocity)) {\n          const way = sign === 0 ? ((speed >= velocity ? vAxis : along) < 0 ? \"left\" : \"right\") : dir;\n          latch.current = true;\n          live.current.onDismiss({ direction: way as DismissInfo[\"direction\"], velocity: speed });\n          // A controlled surface may refuse to close. If it is still enabled after two frames, it came back to rest.\n          requestAnimationFrame(() =>\n            requestAnimationFrame(() => {\n              if (!latch.current || !live.current.enabled) return;\n              latch.current = false;\n              springBack(0);\n            }),\n          );\n        } else {\n          springBack(cancelled ? 0 : vAxis);\n        }\n      };\n\n      const up = (e: PointerEvent) => release(e, false);\n      const cancel = (e: PointerEvent) => release(e, true);\n      // The browser scrolls on a touch unless this is stopped; once the drag has claimed the touch, stop it.\n      const touchMove = (e: TouchEvent) => {\n        if (claimed && e.cancelable) e.preventDefault();\n      };\n\n      function stop() {\n        window.removeEventListener(\"pointermove\", move);\n        window.removeEventListener(\"pointerup\", up);\n        window.removeEventListener(\"pointercancel\", cancel);\n        window.removeEventListener(\"touchmove\", touchMove);\n        if (claimed) panel.style.userSelect = savedSelect;\n        abort.current = null;\n      }\n\n      window.addEventListener(\"pointermove\", move);\n      window.addEventListener(\"pointerup\", up);\n      window.addEventListener(\"pointercancel\", cancel);\n      if (touch) window.addEventListener(\"touchmove\", touchMove, { passive: false });\n      abort.current = stop;\n    },\n    [offset, springBack],\n  );\n\n  const axis = direction === \"down\" ? \"y\" : \"x\";\n  const panel = useMemo<DismissPanel>(() => {\n    const style: MotionStyle = { [axis]: offset };\n    if (active) style.touchAction = axis === \"y\" ? \"pan-x pinch-zoom\" : \"pan-y pinch-zoom\";\n    return { style, \"data-dismiss-panel\": active ? \"\" : undefined };\n  }, [axis, offset, active]);\n\n  return { panel, onPointerDown, offset, exit };\n}\n",
      "target": "lib/litt/motion/drag.ts"
    },
    {
      "path": "registry/litt/lib/motion/deal.ts",
      "type": "registry:lib",
      "content": "// @litt/motion 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n/**\n * Deal and gather: things come from where you touched them, and go back there.\n *\n * The choreography of the gallery's mosaic (after s-p.studio, 2026-11-04), as a building block any surface can use\n * to open from the element that opened it: a tile into its demos, a tile into its page, a link into what it leads to.\n *\n *   const motion = useDealMotion();\n *   const { order } = deal(pieces, { deck: tile.getBoundingClientRect() }, motion);   // open\n *   await gather(order, tile.getBoundingClientRect(), motion);                         // close\n *\n * - deal() starts each piece where it comes from and springs it to its place in the layout, on the expressive\n *   spring, --litt-stagger apart, appearing on the instant tier. Where a piece comes from is the source:\n *     deck    a small deck on one element (a tile): the pieces fan out of it, the nearest first.\n *     each    one element per piece (a mosaic's pieces, for the page they open): each piece grows out of its own.\n *     toward  a nudge of --litt-travel from the side of an element (a link), or from below when there is none: the light\n *             form, for a surface you reach often.\n * - gather() is the way back into a deck on one element, the farthest first, on the expressive exit (an ease-out,\n *   never a bounce). It starts from wherever each piece is drawn, so a close can interrupt a deal.\n * - Smooth on a busy page: both run as Web Animations of transform and opacity alone, which the browser runs off the\n *   main thread, so a deal keeps its frames while the pieces it brings in are still mounting. The springs are the\n *   tokens' own, turned into CSS curves by spring.ts toCss. No filter: a blur repaints every frame.\n * - Nothing is measured while it moves: pass `places`, the untransformed layout boxes, when a piece already carries a\n *   transform.\n * - Reduced motion (useDealMotion's `reduced`): nothing moves, both resolve at once.\n *\n * Each material moves its own way (Nick, 2026-11-05: \"each theme should have its own motion language\"):\n *   Flat    things come from where you touched them: a set deals out of what opened it, an overlay grows out of its\n *           opener from 0.9, and both go back into it.\n *   Press   things have weight: a set settles into place from half of --litt-travel above, one after another, on the\n *           expressive spring, and an overlay rises and settles from --litt-press-scale, as litt always has. Nothing flies.\n *   Device  things are mechanisms: a set switches on in place in three short steps on the instant tier, one after\n *           another like segments lighting, and an overlay switches on and off with no growth and no slide.\n * The material is data-material on <html> (useMaterial); Press is no attribute.\n *\n * An overlay that opens out of its trigger (a Menu, a Dialog) grows from it: it scales up from GROW with its\n * transform-origin on the trigger, so it comes out of the trigger's side without flying across the screen. openerBox()\n * says where the press that opened it happened, read at the press, so it still answers once the trigger is gone (a\n * menu row that opened a dialog).\n */\nimport { createContext, createElement, useCallback, useContext, useEffect, useRef, useSyncExternalStore, type ReactNode } from \"react\";\nimport type { Transition } from \"motion/react\";\nimport { useMotionDial } from \"./index\";\nimport { toCss } from \"./spring\";\n\nexport type Box = { left: number; top: number; width: number; height: number };\n\n/** A Web Animations timing: milliseconds and a CSS easing. */\nexport type Timing = { duration: number; easing: string };\n\n/** The tiers a deal and a gather use, read from the locked tokens (or the Playground's dial). */\nexport type DealMotion = {\n  /** Each piece's way to its place: the expressive spring. */\n  spring: Timing;\n  /** The responsive spring, for what comes with the pieces (a title bar, a scrim). */\n  settle: Timing;\n  /** Appearing: the instant tier. */\n  appear: Timing;\n  /** The way back: the expressive exit. */\n  exit: Timing;\n  /** Milliseconds between pieces. */\n  stagger: number;\n  /** The nudge of the light form, in px. */\n  travel: number;\n  reduced: boolean;\n  /** Which motion language: the material's. */\n  material: Material;\n};\n\n/** litt's materials, each with its own motion language. */\nexport type Material = \"press\" | \"flat\" | \"device\";\n\nconst readMaterial = (): Material => {\n  const m = typeof document === \"undefined\" ? null : document.documentElement.dataset.material;\n  return m === \"flat\" || m === \"device\" ? m : \"press\";\n};\nconst watchMaterial = (change: () => void) => {\n  const mo = new MutationObserver(change);\n  mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-material\"] });\n  return () => mo.disconnect();\n};\n\nconst MaterialContext = createContext<Material | null>(null);\n\n/**\n * A material for what is inside, over the one on <html>: a frame that shows a part in another world (a study's Under\n * each material strip, a Three bodies stage) wraps it in this beside its data-material attribute, so the part picks\n * that world's body and motion language. An app never needs it; its material is on <html>.\n */\nexport function MaterialProvider({ material, children }: { material: Material; children: ReactNode }) {\n  return createElement(MaterialContext.Provider, { value: material }, children);\n}\n\n/** The material: the nearest MaterialProvider's, else the one on <html>, kept current when it changes (the gallery's Customize panel). Press on the server. */\nexport function useMaterial(): Material {\n  const framed = useContext(MaterialContext);\n  const page = useSyncExternalStore(watchMaterial, readMaterial, (): Material => \"press\");\n  return framed ?? page;\n}\n\n/**\n * One contract, three bodies (proposals/worlds.md, Bodies): a part a hand touches keeps one contract and has a body per\n * material, picked here. Press is the default body and lives in the part's own file; Flat's and Device's live in\n * src/components/bodies/<part>.<material>.tsx and are imported by the part, so the registry ships them in its item.\n * A world without a body of its own falls back to Press, so a part gains bodies one at a time.\n */\nexport function byWorld<T>(material: Material, bodies: { press: T; flat?: T; device?: T }): T {\n  return bodies[material] ?? bodies.press;\n}\n\n/* Device's step: three even steps, as a display's segments light, never a fade. */\nconst STEPS = \"steps(3, end)\";\nconst stepEase = (p: number) => (p >= 1 ? 1 : Math.floor(p * 3) / 3);\n\n/** Where pieces come from. */\nexport type DealSource = { deck: Box } | { each: (Box | null)[] } | { toward: Box | null };\n\nexport function useDealMotion(): DealMotion {\n  const { dial, locked, slow, reduced, tuning } = useMotionDial();\n  const material = useMaterial();\n  const k = tuning ? slow : 1;\n  const out = `cubic-bezier(${locked.easeOut.join(\", \")})`;\n  const spring = (s: typeof dial.expressive): Timing => {\n    const css = toCss(s);\n    return { duration: reduced ? 0 : css.duration * k, easing: css.easing };\n  };\n  return {\n    spring: spring(dial.expressive),\n    settle: spring(dial.responsive),\n    appear: { duration: reduced ? 0 : dial.instant * k, easing: out },\n    exit: { duration: reduced ? 0 : dial.expressive.time * 1000 * dial.exit * k, easing: out },\n    stagger: reduced ? 0 : dial.stagger * k,\n    travel: reduced ? 0 : dial.travel,\n    reduced,\n    material,\n  };\n}\n\nconst box = (el: Element): Box => el.getBoundingClientRect();\nconst center = (r: Box) => ({ x: r.left + r.width / 2, y: r.top + r.height / 2 });\nconst dist = (a: Box, b: Box) => Math.hypot(center(a).x - center(b).x, center(a).y - center(b).y);\n\n/* The animations a deal or a gather runs, named so a gather can stop a deal's without touching anyone else's. */\nconst ID = \"litt-deal\";\nconst ours = (el: Element) => el.getAnimations().filter((a) => a.id === ID);\nconst done = (anims: Animation[]) =>\n  Promise.all(anims.map((a) => a.finished)).then(\n    () => undefined,\n    () => undefined,\n  );\n\ntype Offset = { x: number; y: number; scale: number };\nconst transform = (o: Offset) => `translate(${o.x}px, ${o.y}px) scale(${o.scale})`;\n\n/** The k-th card of a small deck on `on`, scaled to about half of it, as an offset from `place`. */\nexport function deckOffset(place: Box, on: Box, k: number, n: number): Offset {\n  const scale = Math.min((on.width * 0.56) / place.width, (on.height * 0.56) / place.height, 1);\n  const fan = (k - (n - 1) / 2) * 3;\n  const from = center(place);\n  const to = center(on);\n  return { x: to.x + fan - from.x, y: to.y + fan * 0.6 - from.y, scale };\n}\n\n/** `place` drawn over `over`: the same centre, as wide. */\nfunction fitOffset(place: Box, over: Box): Offset {\n  const from = center(place);\n  const to = center(over);\n  return { x: to.x - from.x, y: to.y - from.y, scale: over.width / place.width };\n}\n\n/** A nudge of `travel` from the side `toward` is on, or from below. */\nfunction driftOffset(place: Box, toward: Box | null, travel: number): Offset {\n  if (!toward) return { x: 0, y: travel, scale: 1 };\n  const from = center(place);\n  const to = center(toward);\n  const len = Math.hypot(to.x - from.x, to.y - from.y) || 1;\n  return { x: ((to.x - from.x) / len) * travel, y: ((to.y - from.y) / len) * travel, scale: 1 };\n}\n\n/* The layer hint lives only while a piece moves, so a page at rest holds no extra layers. */\nfunction moving(pieces: HTMLElement[], anims: Animation[]) {\n  for (const piece of pieces) piece.style.willChange = \"transform, opacity\";\n  const finished = done(anims);\n  void finished.then(() => {\n    for (const piece of pieces) if (!ours(piece).length) piece.style.willChange = \"\";\n  });\n  return finished;\n}\n\n/**\n * Springs each piece from its source to its place. Returns the order it dealt them in (gather() wants it) and when\n * the last one has settled. Each piece is drawn at its source from the first frame, delay included.\n */\nexport function deal(pieces: HTMLElement[], from: DealSource, motion: DealMotion, { places = pieces.map(box) }: { places?: Box[] } = {}) {\n  const index = pieces.map((_, i) => i);\n  /* Flat deals from the touch outward; Press and Device go in reading order, top to bottom. */\n  if (motion.material === \"flat\" && \"deck\" in from) index.sort((a, b) => dist(places[a]!, from.deck) - dist(places[b]!, from.deck));\n  else if (motion.material !== \"flat\") index.sort((a, b) => places[a]!.top - places[b]!.top || places[a]!.left - places[b]!.left);\n  const order = index.map((i) => pieces[i]!);\n  if (motion.reduced || !pieces.length) return { order, finished: Promise.resolve() };\n\n  const anims: Animation[] = [];\n  if (motion.material !== \"flat\") {\n    index.forEach((i, k) => {\n      const piece = pieces[i]!;\n      const delay = k * motion.stagger;\n      for (const a of ours(piece)) a.cancel();\n      if (motion.material === \"device\") {\n        anims.push(piece.animate([{ opacity: 0 }, { opacity: 1 }], { duration: motion.appear.duration, easing: STEPS, delay, fill: \"backwards\", id: ID }));\n      } else {\n        anims.push(\n          piece.animate([{ transform: `translateY(${-motion.travel / 2}px)` }, { transform: \"none\" }], { ...motion.spring, delay, fill: \"backwards\", id: ID }),\n          piece.animate([{ opacity: 0 }, { opacity: 1 }], { ...motion.appear, delay, fill: \"backwards\", id: ID }),\n        );\n      }\n    });\n    return { order, finished: moving(pieces, anims) };\n  }\n  index.forEach((i, k) => {\n    const piece = pieces[i]!;\n    const place = places[i]!;\n    const own = \"each\" in from ? from.each[i] : null;\n    const start =\n      \"deck\" in from\n        ? deckOffset(place, from.deck, k, pieces.length)\n        : own\n          ? fitOffset(place, own)\n          : driftOffset(place, \"toward\" in from ? from.toward : null, motion.travel);\n    const delay = k * motion.stagger;\n    for (const a of ours(piece)) a.cancel();\n    anims.push(\n      piece.animate([{ transform: transform(start) }, { transform: \"none\" }], { ...motion.spring, delay, fill: \"backwards\", id: ID }),\n      piece.animate([{ opacity: 0 }, { opacity: 1 }], { ...motion.appear, delay, fill: \"backwards\", id: ID }),\n    );\n  });\n  return { order, finished: moving(pieces, anims) };\n}\n\n/**\n * Gathers the pieces back into a deck on `to`, the last dealt first, from wherever each is drawn now. `places` are\n * their layout boxes as they will be when the gather ends; `shift` is how far the whole set is moved right now by\n * something that goes back to rest with it (a drag).\n */\nexport function gather(\n  order: HTMLElement[],\n  to: Box,\n  motion: DealMotion,\n  { places = order.map(box), shift = { x: 0, y: 0 } }: { places?: Box[]; shift?: { x: number; y: number } } = {},\n) {\n  if (motion.reduced || !order.length) return Promise.resolve();\n  const half = motion.exit.duration / 2;\n  const anims: Animation[] = [];\n  if (motion.material !== \"flat\") {\n    for (let j = 0; j < order.length; j++) {\n      const piece = order[order.length - 1 - j]!;\n      const opacity = getComputedStyle(piece).opacity;\n      const lifted = motion.material === \"device\" ? null : getComputedStyle(piece).transform;\n      for (const a of ours(piece)) a.cancel();\n      const delay = (j * motion.stagger) / 2;\n      if (motion.material === \"device\") {\n        anims.push(piece.animate([{ opacity }, { opacity: 0 }], { duration: motion.appear.duration, easing: STEPS, delay, fill: \"both\", id: ID }));\n      } else {\n        /* Press lifts a little off its place as it goes, the way it came, and never gathers anywhere. */\n        anims.push(\n          piece.animate([{ transform: lifted === \"none\" ? \"none\" : lifted! }, { transform: `translateY(${-motion.travel / 4}px)` }], { ...motion.exit, delay, fill: \"both\", id: ID }),\n          piece.animate([{ opacity }, { opacity: 0 }], { ...motion.exit, delay, fill: \"both\", id: ID }),\n        );\n      }\n    }\n    return moving(order, anims);\n  }\n  for (let j = 0; j < order.length; j++) {\n    const k = order.length - 1 - j;\n    const piece = order[k]!;\n    const place = places[k]!;\n    /* Where it is drawn now, against where its box is now, before its running deal lets go of it. */\n    const seen = box(piece);\n    const opacity = getComputedStyle(piece).opacity;\n    const now = { left: place.left + shift.x, top: place.top + shift.y, width: place.width, height: place.height };\n    const from = fitOffset(now, seen);\n    for (const a of ours(piece)) a.cancel();\n    const delay = (j * motion.stagger) / 2;\n    anims.push(\n      piece.animate([{ transform: transform(from) }, { transform: transform(deckOffset(place, to, k, order.length)) }], {\n        ...motion.exit,\n        delay,\n        fill: \"both\",\n        id: ID,\n      }),\n      piece.animate([{ opacity }, { opacity: 0 }], { duration: half, easing: motion.exit.easing, delay: delay + half, fill: \"both\", id: ID }),\n    );\n  }\n  return moving(order, anims);\n}\n\n/** The scale an overlay grows from when it comes out of its trigger, under Flat. */\nexport const GROW = 0.9;\n/* Press grows from --litt-press-scale, as litt's overlays always have. */\nconst PRESS_GROW = 0.98;\n\n/** How an overlay (a menu, a popover, a dialog) enters and leaves under the current material. */\nexport type OverlayMotion = {\n  material: Material;\n  /** The scale it grows from: 0.9 under Flat, 0.98 under Press, 1 under Device (it does not grow). */\n  grow: number;\n  /** Whether it slides its quarter of --litt-travel from the side it opens on: not under Device. */\n  slides: boolean;\n  /** Under Device it switches on and off in steps instead: use these as its transitions. */\n  switches: boolean;\n  switchIn: Transition;\n  switchOut: Transition;\n};\n\nexport function useOverlayMotion(): OverlayMotion {\n  const material = useMaterial();\n  const { dial, reduced, tuning, slow } = useMotionDial();\n  const step: Transition = { duration: reduced ? 0 : (dial.instant / 1000) * (tuning ? slow : 1), ease: stepEase };\n  return {\n    material,\n    grow: material === \"flat\" ? GROW : material === \"press\" ? PRESS_GROW : 1,\n    slides: material !== \"device\",\n    switches: material === \"device\",\n    switchIn: step,\n    switchOut: step,\n  };\n}\n\n/* The last press on something that opens things: where it was and when. */\nlet pressed: { box: Box; at: number } | null = null;\nlet listening = false;\n/* A press opens its overlay in the same task or the next few frames; one older than this opened something else. */\nconst PRESS_MS = 1000;\nconst OPENERS = \"button, a[href], [role=button], [role=menuitem], [role=menuitemcheckbox], [role=menuitemradio], [role=option], [role=tab]\";\n\nfunction listen() {\n  if (listening || typeof document === \"undefined\") return;\n  listening = true;\n  const note = (e: Event) => {\n    const el = (e.target as Element | null)?.closest?.(OPENERS);\n    if (el) pressed = { box: el.getBoundingClientRect(), at: performance.now() };\n  };\n  document.addEventListener(\"pointerdown\", note, true);\n  document.addEventListener(\"keydown\", (e) => (e.key === \"Enter\" || e.key === \" \") && note(e), true);\n}\n\n/* From the moment the engine loads, so the press that mounts an overlay is already noted when the overlay asks. */\nlisten();\n\n/** Starts noting presses (once per page), so openerBox() can answer. Call it in any overlay that grows from its opener. */\nexport function useOpeners() {\n  useEffect(listen, []);\n}\n\n/** The box of whatever was just pressed to open an overlay, or null when nothing was (it opened by itself, or by code). */\nexport function openerBox(): Box | null {\n  const p = pressed;\n  return p && performance.now() - p.at < PRESS_MS ? p.box : null;\n}\n\n/**\n * The transform-origin, in px of `panel`'s own box, that puts the origin on `opener`'s centre. `panel` may already be\n * drawn at `scale` about its centre (its first frame); its untransformed box is worked out from that.\n */\nexport function originOn(panel: Element, opener: Box, scale = 1) {\n  const r = panel.getBoundingClientRect();\n  const w = r.width / scale;\n  const h = r.height / scale;\n  const c = center(r);\n  const o = center(opener);\n  return `${o.x - (c.x - w / 2)}px ${o.y - (c.y - h / 2)}px`;\n}\n\n/**\n * For an overlay that grows out of the press that opened it (a Dialog, the Command menu, a Lightbox): whether it does,\n * and a ref for the panel that puts its transform-origin on the opener before the first frame, while the panel is drawn\n * at GROW. Read on the render where `open` turns true; `enabled` false (a sheet) never grows.\n */\nexport function useGrowFrom<T extends HTMLElement>(open: boolean, enabled = true): { grows: boolean; ref: (el: T | null) => void } {\n  useOpeners();\n  /* Only Flat grows out of its opener; Press rises and Device switches on where it is. */\n  const material = useMaterial();\n  enabled = enabled && material === \"flat\";\n  const opener = useRef<Box | null>(null);\n  const wasOpen = useRef(false);\n  if (open && !wasOpen.current) opener.current = enabled ? openerBox() : null;\n  wasOpen.current = open;\n  const ref = useCallback((el: T | null) => {\n    if (el && opener.current && !el.style.transformOrigin) el.style.transformOrigin = originOn(el, opener.current, GROW);\n  }, []);\n  return { grows: opener.current !== null, ref };\n}\n",
      "target": "lib/litt/motion/deal.ts"
    },
    {
      "path": "registry/litt/lib/motion/hand.ts",
      "type": "registry:lib",
      "content": "// @litt/motion 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n/**\n * The hand: how a pointer becomes the movement of a part (foundations.md, section 6; R4).\n *\n * ```tsx\n * const hand = useHand();\n * const settle = useSettle([x]);\n * const onPointerDown = (e) => {\n *   settle.hold();                                         // caught mid-settle: it is in the hand again, where it is\n *   const from = x.get();\n *   hand.begin(e, {\n *     axis: \"x\",\n *     onMove: ({ along }) => x.set(settle.limit(from + along, lo, hi).shown),\n *     onRelease: (r) =>\n *       r.cancelled ? settle.to([from])\n *       : !r.claimed ? press(r)\n *       : settle.release({ at: [x.get()], speeds: [r.speed], land: ({ points, thrown }) => [rest(points[0], thrown)], commit }),\n *   });\n * };\n * // keys, buttons and a controlled value take the same path with no pointer at all:\n * settle.to([target], { commit });\n * ```\n *\n * Several components make the same five decisions: a release speed read from the last 100ms of movement, slop then\n * pointer capture, the click swallowed after a mouse drag, snapping to a step or a mark, and settling before\n * committing. This file makes them once. It decides nothing about WHAT moves or where it may rest: the component\n * passes that in, so its geometry stays its own.\n *\n * - `useHand()` recognises a gesture: it ignores a second pointer and any mouse button but the first, waits for the\n *   slop, gives the gesture back to the page when the first real move is mostly the other axis (a scroll), captures\n *   the pointer, keeps the trail, swallows the click after a mouse drag, ends the gesture as cancelled on Escape, a\n *   pointercancel or an unmount, and hands back one release with its speed.\n * - `useSettle(values)` finishes one: every value comes to rest on the responsive spring and `commit` runs once, after\n *   all of them have arrived, unless something newer took them first. It is the only place in litt that reads fun\n *   mode's coast, throw and rubber, so Calm cannot get momentum: in Calm a release settles from rest and never passes\n *   an end; in fun mode (motion/fun.tsx, a trial) it coasts on the speed it was let go at, settles on the throw spring\n *   and gives like a rubber band past an end. Both rest on the same target. Under reduced motion it lands at once\n *   (the tier hook returns no duration) and still commits.\n * - `snap(value, rule)` is the grid with its anchors: the nearest anchor within the pull, else the nearest step.\n *\n * Import it from \"@/motion/hand\", not \"@/motion\": like drag.ts it uses the tier hooks from ./index, so index does not\n * re-export it. The slop, the window and the throw distance are gesture constants, not motion tokens.\n */\nimport { useEffect, useMemo, useRef, type PointerEvent as ReactPointerEvent } from \"react\";\nimport { animate, type AnimationPlaybackControls, type MotionValue } from \"motion/react\";\nimport { useFun } from \"./fun\";\nimport { useTransition } from \"./index\";\n\nconst SLOP = 4; // px of travel before a press is a drag\nconst WINDOW_MS = 100; // the release speed is read from the last this-many ms of movement\nconst THROWN_PX = 12; // fun mode: a coast longer than this counts as a throw\n\nexport type Axis = \"x\" | \"y\";\n\n/** Where the pointer is during a gesture. */\nexport type HandPoint = {\n  /** The pointer, in client px. */\n  x: number;\n  y: number;\n  /** Travel since the press, in px. */\n  dx: number;\n  dy: number;\n  /** `dx` or `dy`, whichever is the gesture's axis. */\n  along: number;\n};\n\nexport type HandRelease = HandPoint & {\n  /** False when the pointer never passed the slop: a press, not a drag. */\n  claimed: boolean;\n  /** True when the gesture was taken away (Escape, a pointercancel, the page's scroll, an unmount): put the part back and commit nothing. */\n  cancelled: boolean;\n  /** Speed along the axis over the last 100ms, in px/s, signed like `along`. 0 when the hand rested before letting go. */\n  speed: number;\n};\n\nexport type HandOptions = {\n  /** The axis the part moves on. A first move that is mostly the other axis belongs to the page: the gesture ends cancelled. */\n  axis: Axis;\n  /** Travel before a press becomes a drag. Default 4. */\n  slop?: number;\n  /** The slop was passed and the pointer is captured: take hold of the part. */\n  onClaim?: (point: HandPoint) => void;\n  /** Every move after the claim. Set the part's MotionValue here, one to one. */\n  onMove: (point: HandPoint) => void;\n  /** The pointer came up, or the gesture was cancelled. A mouse drag's trailing click is already swallowed. */\n  onRelease: (release: HandRelease) => void;\n};\n\nexport type Hand = {\n  /** Call it from onPointerDown. It ignores a second pointer, a mouse button other than the first, and a press while a gesture is live. */\n  begin: (event: ReactPointerEvent, options: HandOptions) => void;\n  /** Ends a live gesture as cancelled. Safe when none is live. */\n  cancel: () => void;\n};\n\nconst swallowClick = (e: Event) => {\n  e.stopPropagation();\n  e.preventDefault();\n};\n\n/** One hand: pointer events in; claim, move and release out. */\nexport function useHand(): Hand {\n  const live = useRef<((cancelled: boolean) => void) | null>(null);\n  useEffect(() => () => live.current?.(true), []);\n\n  return useMemo<Hand>(\n    () => ({\n      cancel: () => live.current?.(true),\n      begin: (event, o) => {\n        if (live.current || !event.isPrimary) return;\n        if (event.pointerType === \"mouse\" && event.button !== 0) return;\n        const el = event.currentTarget as HTMLElement;\n        const id = event.pointerId;\n        const touch = event.pointerType !== \"mouse\";\n        const x0 = event.clientX;\n        const y0 = event.clientY;\n        const slop = o.slop ?? SLOP;\n        let claimed = false;\n        let trail: { t: number; v: number }[] = [];\n        let last: HandPoint = { x: x0, y: y0, dx: 0, dy: 0, along: 0 };\n\n        const point = (e: PointerEvent): HandPoint => {\n          const dx = e.clientX - x0;\n          const dy = e.clientY - y0;\n          return { x: e.clientX, y: e.clientY, dx, dy, along: o.axis === \"x\" ? dx : dy };\n        };\n\n        const move = (e: PointerEvent) => {\n          if (e.pointerId !== id) return;\n          last = point(e);\n          if (!claimed) {\n            const across = o.axis === \"x\" ? last.dy : last.dx;\n            if (Math.abs(across) > slop && Math.abs(across) > Math.abs(last.along)) return finish(e.timeStamp, true);\n            if (Math.abs(last.along) < slop) return;\n            claimed = true;\n            try {\n              el.setPointerCapture(id);\n            } catch {\n              /* the pointer is already gone */\n            }\n            window.getSelection()?.removeAllRanges();\n            o.onClaim?.(last);\n          }\n          trail.push({ t: e.timeStamp, v: last.along });\n          trail = trail.filter((s) => e.timeStamp - s.t <= WINDOW_MS);\n          o.onMove(last);\n        };\n        const up = (e: PointerEvent) => e.pointerId === id && finish(e.timeStamp, e.type === \"pointercancel\");\n        const key = (e: KeyboardEvent) => {\n          if (e.key !== \"Escape\" || !claimed) return;\n          e.preventDefault();\n          finish(e.timeStamp, true);\n        };\n        /* touch-action on the element decides who owns a touch; once it is ours, the page must not take it back. */\n        const hold = (e: TouchEvent) => {\n          if (claimed && e.cancelable) e.preventDefault();\n        };\n\n        const finish = (now: number, cancelled: boolean) => {\n          window.removeEventListener(\"pointermove\", move);\n          window.removeEventListener(\"pointerup\", up);\n          window.removeEventListener(\"pointercancel\", up);\n          window.removeEventListener(\"keydown\", key, true);\n          window.removeEventListener(\"touchmove\", hold);\n          live.current = null;\n          const first = trail[0];\n          const end = trail[trail.length - 1];\n          const fresh = !!end && now - end.t <= WINDOW_MS;\n          const speed = fresh && first && end.t > first.t ? ((end.v - first.v) / (end.t - first.t)) * 1000 : 0;\n          if (claimed && !touch) {\n            window.addEventListener(\"click\", swallowClick, { capture: true, once: true });\n            setTimeout(() => window.removeEventListener(\"click\", swallowClick, true), 0);\n          }\n          o.onRelease({ ...last, claimed, cancelled, speed: cancelled ? 0 : speed });\n        };\n\n        window.addEventListener(\"pointermove\", move);\n        window.addEventListener(\"pointerup\", up);\n        window.addEventListener(\"pointercancel\", up);\n        window.addEventListener(\"keydown\", key, true);\n        if (touch) window.addEventListener(\"touchmove\", hold, { passive: false });\n        live.current = (cancelled) => finish(performance.now(), cancelled);\n      },\n    }),\n    [],\n  );\n}\n\nexport type SnapRule = {\n  /** The grid's step, in the value's units. */\n  step: number;\n  /** Where the grid starts. Defaults to `min`. */\n  origin?: number;\n  /** The lowest value the part may rest on. */\n  min: number;\n  /** The highest value the part may rest on. */\n  max: number;\n  /** Values that pull a release toward them, such as the start of each segment or an event. Off the grid is fine. */\n  anchors?: readonly number[];\n  /** How near an anchor must be to take the release, in the value's units. Defaults to half a step. */\n  pull?: number;\n};\n\n/** Decimal places of a number, so a step of 0.1 never drifts to 0.30000000000000004. */\nfunction places(n: number): number {\n  const s = String(n);\n  const dot = s.indexOf(\".\");\n  return dot < 0 ? 0 : s.length - dot - 1;\n}\n\n/** The nearest anchor within the pull, else the nearest grid point, kept in [min, max]. */\nexport function snap(value: number, rule: SnapRule): number {\n  const { step, min, max, anchors = [], pull = step / 2 } = rule;\n  const origin = rule.origin ?? min;\n  const clamp = (v: number) => Math.min(max, Math.max(min, v));\n  let best: number | null = null;\n  for (const raw of anchors) {\n    const a = clamp(raw);\n    if (Math.abs(a - value) <= pull && (best === null || Math.abs(a - value) < Math.abs(best - value))) best = a;\n  }\n  if (best !== null) return best;\n  const digits = Math.max(places(step), places(origin));\n  const onGrid = Number((Math.round((value - origin) / step) * step + origin).toFixed(digits));\n  return clamp(onGrid);\n}\n\n/** Where a release would come to rest if it were left to coast, and whether fun mode counts it as a throw. */\nexport type Landing = {\n  /** One point per value: where the hand let go, plus fun mode's coast when there is one. */\n  points: number[];\n  /** True only in fun mode, and only when the coast carries more than a few px. Pull wider then. */\n  thrown: boolean;\n};\n\nexport type Settle = {\n  /**\n   * Takes the values to `targets` on the settle spring (responsive in Calm, fun mode's throw spring in fun), each\n   * carrying its entry in `velocities` (px/s, ignored in Calm). `commit` runs once, after all of them have arrived,\n   * unless a newer settle or `hold()` took them first. Keys, buttons and a controlled value use this with no hand.\n   */\n  to: (targets: number[], options?: { velocities?: number[]; commit?: () => void }) => void;\n  /**\n   * A hand let go with the values at `at`, moving at `speeds` px/s. In Calm they rest from where they were let go,\n   * from rest. In fun mode they coast first and may be thrown. `land` turns the landing into the targets, so the\n   * component owns the snapping and this file owns the fork.\n   */\n  release: (options: { at: number[]; speeds: number[]; land: (landing: Landing) => number[]; commit?: () => void }) => void;\n  /** Where to show a part the hand wants at `want` but that is kept in [lo, hi]: pinned in Calm, a rubber band in fun mode. `kept` is the pinned value. */\n  limit: (want: number, lo: number, hi: number) => { kept: number; shown: number };\n  /** Stops any running settle and leaves the values where they are: a hand took the part mid-flight. A stopped settle never commits. */\n  hold: () => void;\n};\n\n/** The finish of a gesture, for any set of MotionValues: one for a ruler, two for a brush's edges, one per row. */\nexport function useSettle(values: MotionValue<number>[]): Settle {\n  const fun = useFun();\n  const responsive = useTransition(\"responsive\");\n  const latest = useRef({ values, fun, spring: fun ? fun.throw : responsive });\n  latest.current = { values, fun, spring: fun ? fun.throw : responsive };\n  const run = useRef<AnimationPlaybackControls[] | null>(null);\n\n  const settle = useMemo<Settle>(() => {\n    const hold = () => {\n      run.current?.forEach((r) => r.stop());\n      run.current = null;\n    };\n    const to: Settle[\"to\"] = (targets, options) => {\n      hold();\n      const { values: vs, spring, fun: f } = latest.current;\n      /* Calm settles from rest whatever the caller passes, so a velocity can never carry a part past its target. */\n      const mine = vs.map((v, i) => animate(v, targets[i] ?? v.get(), { ...spring, velocity: f ? (options?.velocities?.[i] ?? 0) : 0 }));\n      run.current = mine;\n      void Promise.all(mine).then(() => {\n        if (run.current !== mine) return;\n        run.current = null;\n        options?.commit?.();\n      });\n    };\n    return {\n      to,\n      hold,\n      release: ({ at, speeds, land, commit }) => {\n        const { fun: f } = latest.current;\n        const carry = at.map((_, i) => (f ? f.coast(speeds[i] ?? 0) : 0));\n        const thrown = !!f && carry.some((c) => Math.abs(c) > THROWN_PX);\n        const targets = land({ points: at.map((p, i) => p + (carry[i] ?? 0)), thrown });\n        to(targets, { velocities: speeds, commit });\n      },\n      limit: (want, lo, hi) => {\n        const kept = Math.min(hi, Math.max(lo, want));\n        const { fun: f } = latest.current;\n        return { kept, shown: f && want !== kept ? kept + f.rubber(want - kept) : kept };\n      },\n    };\n  }, []);\n\n  useEffect(() => settle.hold, [settle]);\n  return settle;\n}\n",
      "target": "lib/litt/motion/hand.ts"
    },
    {
      "path": "registry/litt/lib/motion/fun.tsx",
      "type": "registry:lib",
      "content": "\"use client\";\n// @litt/motion 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Fun mode: a trial, off unless a <FunMode> is on above the components. While it runs, the gallery's Customize panel is\n * where a viewer turns it on (Motion: Calm or Fun), the way the neutrals were split-tested before Stone was locked in.\n *\n * The one rule: fun mode changes how things move, never how they look at rest. A component in fun mode comes to rest\n * on exactly the frame it draws with fun mode off, and with fun mode off it runs the code it always ran. The fun is in\n * three moments:\n * - Arrival: a data part's marks grow to their values in a choreographed wave, and its figures count up with them.\n * - Change: a new value springs from the old one, interruptibly.\n * - Touch: what is under the finger answers it (a press that squishes, a tick that draws, a lens over a heatmap).\n *   Things you hold are physical: a held thumb lifts and stretches with the speed of the finger (useFunGrab), a held\n *   switch stretches toward where it will go, and a thrown thing keeps the speed it was let go at, coasts (coast) and\n *   settles on a step (the throw spring). Past an end it gives like a rubber band (rubber) and comes back.\n *\n * Two honesty rules: a mark may overshoot by a hair, as a physical thing would, but a figure never shows a value the\n * data does not hold, and a count never goes back while a mark settles. And celebration is earned: only a real\n * milestone (a run that went live) gets one, once, small, in the hues already on screen.\n *\n * Reduced motion turns it off, as does StillMotion: useFun() returns null and every component runs as it always has.\n *\n * The springs here go past the locked dial on purpose (the expressive bounce is 0.12); that is the trial. If fun mode\n * is locked in, they move into tokens.css and the rules, through the evolve workflow.\n */\nimport { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport { animate, useMotionValue, useMotionValueEvent, type MotionValue, type Transition } from \"motion/react\";\nimport { toCss, type Spring } from \"./spring\";\nimport { useMotionDial } from \"./index\";\n\n/** Fun mode's springs, as time and bounce, the engine's own form. */\nexport const FUN_SPRINGS = {\n  /** A mark growing to its value: one soft overshoot, then still. Rails, ticks, bars. */\n  grow: { time: 0.75, bounce: 0.2 },\n  /** A small thing landing: a dot, a check, a step's icon. */\n  pop: { time: 0.38, bounce: 0.55 },\n  /** Rows trading places. */\n  swap: { time: 0.5, bounce: 0.18 },\n  /** Following a pointer: tight and without overshoot, so it never lags the finger. */\n  follow: { time: 0.18, bounce: 0 },\n  /** The selected segment gliding to a new choice. */\n  glide: { time: 0.3, bounce: 0.3 },\n  /** A press: down and back with a little life. */\n  press: { time: 0.2, bounce: 0.45 },\n  /** A thrown thing landing on its step: it carries the speed it was let go at, so the time is the settle, not the trip. */\n  throw: { time: 0.45, bounce: 0.22 },\n} satisfies Record<string, Spring>;\n\n/** How far a throw carries: the distance it would cover in this many seconds at the speed it was let go. */\nconst COAST_SECONDS = 0.2;\n/** How far past an end a pull can go, in px, however hard it pulls. */\nconst RUBBER_LIMIT = 40;\n/** How stiff the pull past an end starts (1 would be one to one). */\nconst RUBBER_GIVE = 0.55;\n/** A held thumb lifts this much, and stretches along the way it moves by up to STRETCH_MAX, reached at STRETCH_SPEED px/s. */\nconst LIFT = 1.15;\nconst STRETCH_MAX = 0.3;\nconst STRETCH_SPEED = 1600;\n/** A held thumb that stops moving for this long, in ms, loses its stretch. */\nconst STRETCH_REST_MS = 80;\n\n/** Drawing a line or a count: a tween, so the pen moves at a steady hand and a count never overshoots. */\nconst DRAW_SECONDS = 0.9;\nconst DRAW_EASE: [number, number, number, number] = [0.25, 0.1, 0.25, 1];\n/** A tick is short, so its pen is quick. */\nconst TICK_SECONDS = 0.28;\n/** Seconds between neighbours in a wave. */\nconst WAVE = 0.014;\n/** A failure's one shake, sideways, in px: the way a wrong password says no. */\nconst SHAKE = [0, -3, 3, -2, 2, 0];\nconst SHAKE_SECONDS = 0.4;\n/** A celebration's dots scatter and fade over this long, after a beat. */\nconst BURST_SECONDS = 0.7;\nconst BURST_DELAY = 0.12;\nconst BURST_EASE: [number, number, number, number] = [0.2, 0, 0, 1];\n/** How deep a press goes in fun mode (the locked --litt-press-scale is 0.98). */\nconst PRESS_SCALE = 0.95;\n\nconst spring = (s: Spring): Transition => ({ type: \"spring\", visualDuration: s.time, bounce: s.bounce });\nconst press = toCss(FUN_SPRINGS.press);\n\nexport type FunMotion = {\n  grow: Transition;\n  pop: Transition;\n  swap: Transition;\n  follow: Transition;\n  glide: Transition;\n  /** The pen: a steady tween for drawing a line and counting a figure together. */\n  draw: Transition & { duration: number };\n  /** The pen for a tick or a check mark: the same steady hand, quicker. */\n  tick: Transition;\n  /** Seconds between neighbours in a wave. */\n  wave: number;\n  /** A failure's one shake: the x keyframes in px and their timing. */\n  shake: { x: number[]; transition: Transition };\n  /** A celebration's dots: how they scatter and fade. */\n  burst: Transition;\n  /** For CSS: the press as a scale, a linear() easing and its settle time in ms. */\n  press: { scale: number; easing: string; duration: number };\n  /** For CSS: the glide as a linear() easing and its settle time in ms, for a thumb that crosses its track. */\n  glideCss: { easing: string; duration: number };\n  /** A thrown thing landing on its step. Pass the release speed as `velocity`, in the animated value's units per second. */\n  throw: Transition;\n  /** How far a throw at `velocity` (px per second, signed) carries past where it was let go, in px. */\n  coast: (velocity: number) => number;\n  /** A pull `over` px past an end, as the px it shows: one to one at first, then giving less and less, never past a limit. */\n  rubber: (over: number) => number;\n};\n\nconst MOTION: FunMotion = {\n  grow: spring(FUN_SPRINGS.grow),\n  pop: spring(FUN_SPRINGS.pop),\n  swap: spring(FUN_SPRINGS.swap),\n  follow: spring(FUN_SPRINGS.follow),\n  glide: spring(FUN_SPRINGS.glide),\n  draw: { type: \"tween\", duration: DRAW_SECONDS, ease: DRAW_EASE },\n  tick: { type: \"tween\", duration: TICK_SECONDS, ease: DRAW_EASE },\n  wave: WAVE,\n  shake: { x: SHAKE, transition: { type: \"tween\", duration: SHAKE_SECONDS, ease: \"easeOut\" } },\n  burst: { type: \"tween\", duration: BURST_SECONDS, delay: BURST_DELAY, ease: BURST_EASE },\n  press: { scale: PRESS_SCALE, easing: press.easing, duration: press.duration },\n  glideCss: toCss(FUN_SPRINGS.glide),\n  throw: spring(FUN_SPRINGS.throw),\n  coast: (velocity) => velocity * COAST_SECONDS,\n  rubber: (over) => Math.sign(over) * RUBBER_LIMIT * (1 - Math.exp((-RUBBER_GIVE * Math.abs(over)) / RUBBER_LIMIT)),\n};\n\nconst FunContext = createContext(false);\n\n/** Turns fun mode on (or off, with on={false}) for everything inside. Reduced motion still wins. */\nexport function FunMode({ on = true, children }: { on?: boolean; children: ReactNode }) {\n  return <FunContext.Provider value={on}>{children}</FunContext.Provider>;\n}\n\n/** Fun mode's motion, or null when it is off: no <FunMode> above, reduced motion, or inside StillMotion. */\nexport function useFun(): FunMotion | null {\n  const on = useContext(FunContext);\n  const { reduced } = useMotionDial();\n  return on && !reduced ? MOTION : null;\n}\n\n/** A figure on its way from `from` to `to`, `p` of the way there: never past `to`, whichever way it is going. */\nexport function honestCount(from: number, to: number, p: number) {\n  return from + (to - from) * Math.max(0, Math.min(1, p));\n}\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 2 });\n\n/**\n * A number counting inside its own text: \"1,380 of 3,000\" at 640 reads \"640 of 3,000\". The value is found as written\n * (grouped, \"1,380\", or plain, \"1380\"); text that does not hold it stays as it is, so a count is never invented.\n */\nexport function countText(text: string, value: number, shown: number) {\n  const now = groupDigits(Math.round(shown));\n  for (const form of [groupDigits(value), String(value)]) {\n    const at = text.indexOf(form);\n    if (at >= 0) return text.slice(0, at) + now + text.slice(at + form.length);\n  }\n  return text;\n}\n\nexport type FunValue = {\n  /** The animated value, in the caller's units (a percent, a tick count). Read it into transforms while `moving`. */\n  value: MotionValue<number>;\n  /** The figure to show: it counts honestly from where it was to `count`, never past it and never back. */\n  shown: number;\n  /** True while it moves. Settled, draw the component's own markup from the real value, not the motion value. */\n  moving: boolean;\n};\n\n/**\n * A value as fun mode moves it. With fun mode on it grows from 0 when the component mounts (after `delay` seconds) and\n * springs to every new value, on the `kind` spring (or the pen, `draw`, for a count that keeps pace with a line). With it off it is simply the value, and nothing ever moves.\n * `count` is the figure counted alongside (the value itself by default), so a bar in percent can count its number.\n */\nexport function useFunValue(target: number, { kind = \"grow\", delay = 0, count = target }: { kind?: \"grow\" | \"swap\" | \"pop\" | \"draw\"; delay?: number; count?: number } = {}): FunValue {\n  const fun = useFun();\n  const value = useMotionValue(target);\n  const [shown, setShown] = useState(count);\n  const [moving, setMoving] = useState(false);\n  const span = useRef({ from: count, to: count, fromV: target, toV: target });\n  const came = useRef(1);\n  const shownNow = useRef(count);\n\n  useMotionValueEvent(value, \"change\", (v) => {\n    const { from, to, fromV, toV } = span.current;\n    const p = toV === fromV ? 1 : (v - fromV) / (toV - fromV);\n    came.current = Math.max(came.current, Math.max(0, Math.min(1, p)));\n    shownNow.current = honestCount(from, to, came.current);\n    setShown(shownNow.current);\n  });\n  useMotionValueEvent(value, \"animationStart\", () => setMoving(true));\n  useMotionValueEvent(value, \"animationComplete\", () => setMoving(false));\n  useMotionValueEvent(value, \"animationCancel\", () => setMoving(false));\n\n  /* Arrival, once, before the first paint, so the rest frame never flashes first. */\n  useLayoutEffect(() => {\n    if (!fun) return;\n    span.current = { from: 0, to: count, fromV: 0, toV: target };\n    came.current = 0;\n    value.set(0);\n    const run = animate(value, target, { ...fun[kind], delay });\n    return () => run.stop();\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- the arrival plays on mount only\n  }, []);\n\n  /* A new value springs from wherever it is; with fun mode off it simply is the new value. */\n  const first = useRef(true);\n  useEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    if (!fun) {\n      span.current = { from: count, to: count, fromV: target, toV: target };\n      value.jump(target);\n      shownNow.current = count;\n      setShown(count);\n      return;\n    }\n    span.current = { from: shownNow.current, to: count, fromV: value.get(), toV: target };\n    came.current = 0;\n    const run = animate(value, target, fun[kind]);\n    return () => run.stop();\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- follows the value only\n  }, [target, count]);\n\n  return { value, shown, moving };\n}\n\nexport type FunGrab = {\n  /** A finger took hold of `el`: it lifts. Lets go by itself when the pointer comes up anywhere. */\n  grab: (el: HTMLElement | null) => void;\n  /** The held thing is now at `at` px along its track: it stretches along the way it moves, by the finger's speed. */\n  move: (at: number) => void;\n};\n\n/**\n * A thumb you hold, in fun mode: it lifts when a finger takes it, stretches along the way it moves with the finger's\n * speed (and thins across it, so it reads as one soft thing), and wobbles back to its own size when let go. Only its\n * scale moves, never its place, so the thumb still sits exactly where the value puts it. Null with fun mode off.\n */\nexport function useFunGrab(): FunGrab | null {\n  const fun = useFun();\n  const held = useRef<{ el: HTMLElement; at: number | null; t: number; rest: number } | null>(null);\n\n  useEffect(\n    () => () => {\n      const h = held.current;\n      if (h) window.clearTimeout(h.rest);\n      held.current = null;\n    },\n    [],\n  );\n\n  if (!fun) return null;\n\n  const stretch = (el: HTMLElement, s: number) =>\n    animate(el, { scaleX: LIFT * (1 + s), scaleY: LIFT * (1 - s / 2) }, fun.follow);\n\n  return {\n    grab(el) {\n      if (!el) return;\n      held.current = { el, at: null, t: 0, rest: 0 };\n      animate(el, { scaleX: LIFT, scaleY: LIFT }, fun.pop);\n      const up = () => {\n        window.removeEventListener(\"pointerup\", up);\n        window.removeEventListener(\"pointercancel\", up);\n        const h = held.current;\n        if (!h || h.el !== el) return;\n        window.clearTimeout(h.rest);\n        held.current = null;\n        animate(el, { scaleX: 1, scaleY: 1 }, fun.pop);\n      };\n      window.addEventListener(\"pointerup\", up);\n      window.addEventListener(\"pointercancel\", up);\n    },\n    move(at) {\n      const h = held.current;\n      if (!h) return;\n      const now = performance.now();\n      if (h.at !== null && now > h.t) {\n        const speed = (Math.abs(at - h.at) / (now - h.t)) * 1000;\n        stretch(h.el, STRETCH_MAX * Math.min(1, speed / STRETCH_SPEED));\n      }\n      h.at = at;\n      h.t = now;\n      window.clearTimeout(h.rest);\n      h.rest = window.setTimeout(() => held.current === h && stretch(h.el, 0), STRETCH_REST_MS);\n    },\n  };\n}\n",
      "target": "lib/litt/motion/fun.tsx"
    }
  ],
  "docs": "Installed in lib/litt/motion. The components use it for you, and it needs no provider. Fun mode, a trial, is a <FunMode> around the app: springier motion, the same at rest.",
  "meta": {
    "version": "2026.11.09",
    "released": "2026-11-09",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
