{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inline-edit","type":"registry:component","title":"Inline Edit","description":"Click-to-edit text that turns into an input or auto-growing textarea in the exact same box (no layout jump) on click, Enter or F2, saves on Enter or blur and cancels on Escape, blocks saves with a validate message and shake, and runs async onSave with a pending spinner, rollback plus error on rejection, and a drawn check-mark flash on success.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/inline-edit.tsx","type":"registry:component","target":"@components/motion/inline-edit.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/inline-edit\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n} from \"react\";\nimport { Loader } from \"@/components/motion/loader\";\nimport { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InlineEditClassNames = {\n  root?: string;\n  display?: string;\n  input?: string;\n  placeholder?: string;\n  errorMessage?: string;\n};\n\nexport interface InlineEditProps {\n  /** Accessible name of the field (\"Name\", \"Bio\"); also used in the edit hint. */\n  label: string;\n  value?: string;\n  defaultValue?: string;\n  /**\n   * Commit handler. Return a promise to show a pending spinner; the shown value\n   * rolls back (with a shake and the rejection message) if it rejects.\n   * Controlled callers update `value` here.\n   */\n  onSave?: (next: string) => unknown;\n  onCancel?: () => void;\n  onEditingChange?: (editing: boolean) => void;\n  /** Return an error message to block the save and keep the editor open. */\n  validate?: (next: string) => string | null | undefined | false;\n  /** Edit in an auto-growing textarea; Enter saves, Shift+Enter adds a line. */\n  multiline?: boolean;\n  placeholder?: string;\n  maxLength?: number;\n  /** Select the whole text when the editor opens (default) or put the caret at the end. */\n  selectOnEdit?: boolean;\n  disabled?: boolean;\n  className?: string;\n  classNames?: InlineEditClassNames;\n}\n\ntype Status = \"idle\" | \"pending\" | \"saved\";\n\nconst SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };\nconst SAVED_FLASH_MS = 1400;\n\nfunction messageOf(reason: unknown) {\n  if (reason instanceof Error && reason.message) return reason.message;\n  if (typeof reason === \"string\" && reason) return reason;\n  return \"Couldn't save. Try again.\";\n}\n\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n  return (\n    typeof value === \"object\" &&\n    value !== null &&\n    typeof (value as PromiseLike<unknown>).then === \"function\"\n  );\n}\n\nexport function InlineEdit({\n  label,\n  value: valueProp,\n  defaultValue,\n  onSave,\n  onCancel,\n  onEditingChange,\n  validate,\n  multiline = false,\n  placeholder = \"Empty\",\n  maxLength,\n  selectOnEdit = true,\n  disabled,\n  className,\n  classNames,\n}: InlineEditProps) {\n  const id = useId();\n  const reduce = useReducedMotion();\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const value = controlled ? (valueProp ?? \"\") : internal;\n\n  const [editing, setEditing] = useState(false);\n  const [draft, setDraft] = useState(value);\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [pendingValue, setPendingValue] = useState(\"\");\n  const [error, setError] = useState<string | null>(null);\n\n  // Refs gate commit/cancel so the blur fired while the editor unmounts can't\n  // save a second time.\n  const editingRef = useRef(false);\n  const restoreFocusRef = useRef(false);\n  const saveIdRef = useRef(0);\n  const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const mountedRef = useRef(true);\n\n  const boxRef = useRef<HTMLDivElement>(null);\n  const displayRef = useRef<HTMLButtonElement>(null);\n  const fieldRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null);\n\n  useEffect(() => {\n    mountedRef.current = true;\n    return () => {\n      mountedRef.current = false;\n      if (flashTimer.current) clearTimeout(flashTimer.current);\n    };\n  }, []);\n\n  const shake = useCallback(() => {\n    if (!boxRef.current || reduce) return;\n    animate(boxRef.current, SHAKE, { duration: 0.45 });\n  }, [reduce]);\n\n  const setEditingState = (next: boolean) => {\n    editingRef.current = next;\n    setEditing(next);\n    onEditingChange?.(next);\n  };\n\n  // Focus the editor as it opens; return focus to the text after a\n  // keyboard commit or cancel (not after blur — that would steal focus).\n  useEffect(() => {\n    if (editing) {\n      const field = fieldRef.current;\n      if (!field) return;\n      field.focus();\n      if (selectOnEdit) field.select();\n      else field.setSelectionRange(field.value.length, field.value.length);\n    } else if (restoreFocusRef.current) {\n      restoreFocusRef.current = false;\n      displayRef.current?.focus();\n    }\n  }, [editing, selectOnEdit]);\n\n  const startEditing = () => {\n    if (disabled || editingRef.current || status === \"pending\") return;\n    if (flashTimer.current) clearTimeout(flashTimer.current);\n    setStatus(\"idle\");\n    setDraft(value);\n    setError(null);\n    setEditingState(true);\n  };\n\n  const cancel = () => {\n    if (!editingRef.current) return;\n    restoreFocusRef.current = true;\n    setDraft(value);\n    setError(null);\n    setEditingState(false);\n    onCancel?.();\n  };\n\n  const flashSaved = () => {\n    setStatus(\"saved\");\n    if (flashTimer.current) clearTimeout(flashTimer.current);\n    flashTimer.current = setTimeout(() => {\n      if (mountedRef.current) setStatus(\"idle\");\n    }, SAVED_FLASH_MS);\n  };\n\n  const commit = (via: \"key\" | \"blur\") => {\n    if (!editingRef.current) return;\n    const next = draft;\n    if (next === value) {\n      if (via === \"key\") restoreFocusRef.current = true;\n      setError(null);\n      setEditingState(false);\n      return;\n    }\n    const invalid = validate?.(next);\n    if (invalid) {\n      setError(invalid);\n      shake();\n      return;\n    }\n\n    if (via === \"key\") restoreFocusRef.current = true;\n    setError(null);\n    setEditingState(false);\n\n    const result = onSave?.(next);\n    if (!isThenable(result)) {\n      if (!controlled) setInternal(next);\n      flashSaved();\n      return;\n    }\n\n    const saveId = ++saveIdRef.current;\n    setPendingValue(next);\n    setStatus(\"pending\");\n    Promise.resolve(result).then(\n      () => {\n        if (!mountedRef.current || saveId !== saveIdRef.current) return;\n        if (!controlled) setInternal(next);\n        flashSaved();\n      },\n      (reason: unknown) => {\n        if (!mountedRef.current || saveId !== saveIdRef.current) return;\n        setStatus(\"idle\");\n        setError(messageOf(reason));\n        shake();\n      },\n    );\n  };\n\n  const onFieldKeyDown = (\n    event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,\n  ) => {\n    if (event.nativeEvent.isComposing) return;\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      cancel();\n    } else if (event.key === \"Enter\" && !(multiline && event.shiftKey)) {\n      event.preventDefault();\n      commit(\"key\");\n    }\n  };\n\n  const shown = status === \"pending\" ? pendingValue : value;\n  const errorId = `${id}-error`;\n  // Display and editor share this box model exactly, so swapping them never\n  // moves the text. The -mx-2 hangs the padding outside so text aligns with\n  // surrounding content.\n  const textBox = cn(\n    \"w-full rounded-lg px-2 py-1 pr-8 text-left\",\n    multiline ? \"whitespace-pre-wrap break-words\" : \"\",\n  );\n  const iconSlot = cn(\n    \"pointer-events-none absolute right-2 z-10 flex size-4 items-center justify-center\",\n    multiline ? \"top-[calc(0.25rem+0.5lh)] -translate-y-1/2\" : \"top-1/2 -translate-y-1/2\",\n  );\n\n  const motionIn = reduce\n    ? { opacity: 1 }\n    : { opacity: 1, scale: 1 };\n  const motionOut = reduce\n    ? { opacity: 0 }\n    : { opacity: 0, scale: 0.6 };\n\n  return (\n    <div\n      data-state={editing ? \"editing\" : status}\n      className={cn(\"relative -mx-2\", className, classNames?.root)}\n    >\n      <div ref={boxRef} className=\"group relative\">\n        {/* Field surface: fades and settles in under the text while editing. */}\n        <motion.span\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute inset-0 rounded-lg border bg-background ring-2\",\n            error\n              ? \"border-destructive ring-destructive/25\"\n              : \"border-foreground/30 ring-ring/40\",\n          )}\n          initial={false}\n          animate={\n            editing\n              ? motionIn\n              : reduce\n                ? { opacity: 0 }\n                : { opacity: 0, scale: 0.98 }\n          }\n          transition={{ duration: editing ? 0.2 : 0.12, ease: EASE_OUT }}\n        />\n\n        {editing ? (\n          multiline ? (\n            <div className=\"relative grid\">\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  textBox,\n                  \"invisible [grid-area:1/1]\",\n                  classNames?.input,\n                )}\n              >\n                {`${draft} `}\n              </span>\n              <textarea\n                ref={fieldRef}\n                aria-label={label}\n                aria-invalid={error ? true : undefined}\n                aria-describedby={error ? errorId : undefined}\n                rows={1}\n                value={draft}\n                maxLength={maxLength}\n                placeholder={placeholder}\n                onChange={(e) => {\n                  setDraft(e.target.value);\n                  if (error) setError(null);\n                }}\n                onKeyDown={onFieldKeyDown}\n                onBlur={() => commit(\"blur\")}\n                className={cn(\n                  textBox,\n                  \"relative resize-none overflow-hidden bg-transparent text-foreground caret-foreground outline-none [grid-area:1/1] placeholder:text-muted-foreground/60\",\n                  classNames?.input,\n                )}\n              />\n            </div>\n          ) : (\n            <input\n              ref={fieldRef}\n              aria-label={label}\n              aria-invalid={error ? true : undefined}\n              aria-describedby={error ? errorId : undefined}\n              value={draft}\n              maxLength={maxLength}\n              placeholder={placeholder}\n              onChange={(e) => {\n                setDraft(e.target.value);\n                if (error) setError(null);\n              }}\n              onKeyDown={onFieldKeyDown}\n              onBlur={() => commit(\"blur\")}\n              className={cn(\n                textBox,\n                \"relative block bg-transparent text-foreground caret-foreground outline-none placeholder:text-muted-foreground/60\",\n                classNames?.input,\n              )}\n            />\n          )\n        ) : (\n          <button\n            ref={displayRef}\n            type=\"button\"\n            disabled={disabled}\n            aria-describedby={error ? errorId : undefined}\n            onClick={startEditing}\n            onKeyDown={(event) => {\n              if (event.key === \"F2\") {\n                event.preventDefault();\n                startEditing();\n              }\n            }}\n            className={cn(\n              textBox,\n              \"relative block cursor-text transition-[background-color,opacity] duration-150\",\n              \"outline-none hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring/50\",\n              !multiline && \"truncate\",\n              status === \"pending\" && \"opacity-70\",\n              disabled && \"cursor-not-allowed hover:bg-transparent\",\n              classNames?.display,\n            )}\n          >\n            {shown ? (\n              shown\n            ) : (\n              <span\n                className={cn(\n                  \"text-muted-foreground/70 italic\",\n                  classNames?.placeholder,\n                )}\n              >\n                {placeholder}\n              </span>\n            )}\n            <span className=\"sr-only\">, edit {label}</span>\n          </button>\n        )}\n\n        <span className={iconSlot}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {status === \"pending\" ? (\n              <motion.span\n                key=\"pending\"\n                className=\"flex\"\n                initial={motionOut}\n                animate={motionIn}\n                exit={motionOut}\n                transition={SPRING_SWAP}\n              >\n                <Loader\n                  variant=\"spinner\"\n                  size={14}\n                  label=\"Saving\"\n                  className=\"text-muted-foreground\"\n                />\n              </motion.span>\n            ) : status === \"saved\" ? (\n              <motion.span\n                key=\"saved\"\n                className=\"flex size-4 items-center justify-center rounded-full bg-success text-background\"\n                initial={motionOut}\n                animate={motionIn}\n                exit={{ opacity: 0 }}\n                transition={SPRING_PRESS}\n              >\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" className=\"size-3\" aria-hidden=\"true\">\n                  <motion.path\n                    d=\"M5 12.5l4.5 4.5L19 7.5\"\n                    stroke=\"currentColor\"\n                    strokeWidth={3}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                    animate={{ pathLength: 1 }}\n                    transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.05 }}\n                  />\n                </svg>\n              </motion.span>\n            ) : !editing && !disabled ? (\n              <motion.span\n                key=\"pencil\"\n                className=\"flex text-muted-foreground\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.15 }}\n              >\n                <span className=\"flex opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100\">\n                  <svg\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={2}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    className=\"size-3.5\"\n                    aria-hidden=\"true\"\n                  >\n                    <path d=\"M12 20h9\" />\n                    <path d=\"M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z\" />\n                  </svg>\n                </span>\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </span>\n      </div>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {status === \"pending\" ? \"Saving\" : status === \"saved\" ? \"Saved\" : \"\"}\n      </span>\n\n      <AnimatePresence initial={false}>\n        {error ? (\n          <motion.p\n            id={errorId}\n            role=\"alert\"\n            initial={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            transition={{ duration: 0.2 }}\n            className={cn(\n              \"px-2 pt-1 text-xs text-destructive\",\n              classNames?.errorMessage,\n            )}\n          >\n            {error}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"components/motion/loader.tsx","type":"registry:component","target":"@components/motion/loader.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useState } from \"react\";\nimport { EASE_IN_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type LoaderVariant =\n  | \"spinner\"\n  | \"dots\"\n  | \"bars\"\n  | \"dot-matrix\"\n  | \"dither\"\n  | \"ascii\"\n  | \"ascii-line\"\n  | \"ascii-braille\"\n  | \"ascii-blocks\"\n  | \"ascii-bounce\"\n  | \"morph\"\n  | \"comet\"\n  | \"scramble\"\n  | \"metaballs\"\n  | \"newton\"\n  | \"helix\"\n  | \"percent\";\n\n// Terminal-style frame sets — the loaders CLI AI agents cycle through.\nconst ASCII_SETS: Record<string, string[]> = {\n  ascii: [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"],\n  \"ascii-line\": [\"|\", \"/\", \"-\", \"\\\\\"],\n  \"ascii-braille\": [\"⣾\", \"⣽\", \"⣻\", \"⢿\", \"⡿\", \"⣟\", \"⣯\", \"⣷\"],\n  \"ascii-blocks\": [\"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\", \"▇\", \"▆\", \"▅\", \"▄\", \"▃\", \"▂\"],\n  \"ascii-bounce\": [\"⠁\", \"⠂\", \"⠄\", \"⡀\", \"⢀\", \"⠠\", \"⠐\", \"⠈\"],\n};\n\nexport interface LoaderProps {\n  /** Which animation to render. */\n  variant?: LoaderVariant;\n  /** Base square size in px. Everything scales from this. */\n  size?: number;\n  /** Seconds per animation cycle. */\n  speed?: number;\n  /** Accessible label announced to screen readers. */\n  label?: string;\n  className?: string;\n}\n\n// Reduced motion keeps a calm opacity pulse and drops every transform.\nconst REDUCED = {\n  animate: { opacity: [1, 0.4, 1] },\n  transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\nexport function Loader({\n  variant = \"spinner\",\n  size = 32,\n  speed = 1,\n  label = \"Loading\",\n  className,\n}: LoaderProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <span\n      role=\"status\"\n      aria-label={label}\n      className={cn(\n        \"inline-flex items-center justify-center text-foreground\",\n        className,\n      )}\n    >\n      {variant === \"spinner\" && <Spinner size={size} speed={speed} reduce={reduce} />}\n      {variant === \"dots\" && <Dots size={size} speed={speed} reduce={reduce} />}\n      {variant === \"bars\" && <Bars size={size} speed={speed} reduce={reduce} />}\n      {variant === \"dot-matrix\" && (\n        <DotMatrix size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"dither\" && <Dither size={size} speed={speed} reduce={reduce} />}\n      {ASCII_SETS[variant] && (\n        <Ascii frames={ASCII_SETS[variant]} size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"morph\" && <Morph size={size} speed={speed} reduce={reduce} />}\n      {variant === \"comet\" && <Comet size={size} speed={speed} reduce={reduce} />}\n      {variant === \"scramble\" && (\n        <Scramble size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"metaballs\" && (\n        <Metaballs size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"newton\" && <Newton size={size} speed={speed} reduce={reduce} />}\n      {variant === \"helix\" && <Helix size={size} speed={speed} reduce={reduce} />}\n      {variant === \"percent\" && (\n        <Percent size={size} speed={speed} reduce={reduce} />\n      )}\n      <span className=\"sr-only\">{label}</span>\n    </span>\n  );\n}\n\ninterface PartProps {\n  size: number;\n  speed: number;\n  reduce: boolean;\n}\n\nfunction Spinner({ size, speed, reduce }: PartProps) {\n  const stroke = Math.max(2, size * 0.09);\n  const r = (size - stroke) / 2;\n  return (\n    <motion.svg\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      animate={reduce ? REDUCED.animate : { rotate: 360 }}\n      transition={\n        reduce\n          ? REDUCED.transition\n          : { duration: speed, ease: \"linear\", repeat: Infinity }\n      }\n    >\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={r}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeOpacity={0.2}\n        strokeWidth={stroke}\n      />\n      <path\n        d={`M ${size / 2} ${size / 2 - r} A ${r} ${r} 0 0 1 ${size / 2 + r} ${size / 2}`}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={stroke}\n        strokeLinecap=\"round\"\n      />\n    </motion.svg>\n  );\n}\n\nfunction Dots({ size, speed, reduce }: PartProps) {\n  const dot = size * 0.24;\n  return (\n    <span className=\"flex items-center\" style={{ gap: size * 0.14 }}>\n      {[0, 1, 2].map((i) => (\n        <motion.span\n          key={i}\n          className=\"rounded-full bg-current\"\n          style={{ width: dot, height: dot }}\n          animate={\n            reduce\n              ? { opacity: [0.4, 1, 0.4] }\n              : { y: [0, -size * 0.3, 0], opacity: [0.5, 1, 0.5] }\n          }\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: i * speed * 0.16,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction Ascii({\n  frames,\n  size,\n  speed,\n  reduce,\n}: PartProps & { frames: string[] }) {\n  const [frame, setFrame] = useState(0);\n  useEffect(() => {\n    // Reduced motion slows the cycle rather than stopping it — it's a glyph\n    // swap, not on-screen movement.\n    const step = ((reduce ? speed * 2.5 : speed) / frames.length) * 1000;\n    const id = setInterval(\n      () => setFrame((f) => (f + 1) % frames.length),\n      step,\n    );\n    return () => clearInterval(id);\n  }, [frames.length, speed, reduce]);\n\n  return (\n    <span\n      className=\"font-mono leading-none tabular-nums\"\n      style={{ fontSize: size, lineHeight: 1 }}\n    >\n      {frames[frame % frames.length]}\n    </span>\n  );\n}\n\n// Each shape is sampled at the same number of points and emitted as an SVG\n// path with identical command structure, so framer tweens the `d` attribute\n// point-to-point — a real morph, not a snap. (clip-path polygon strings don't\n// interpolate reliably in framer, which left the shapes broken.)\nconst MORPH_POINTS = 24;\n\nfunction ngonRadius(ang: number, n: number, phase = 0) {\n  const seg = (2 * Math.PI) / n;\n  const a = ang - phase;\n  const local = (((a % seg) + seg) % seg) - seg / 2;\n  return Math.cos(Math.PI / n) / Math.cos(local);\n}\n\nfunction morphPath(radiusAt: (ang: number) => number) {\n  const parts: string[] = [];\n  for (let i = 0; i < MORPH_POINTS; i++) {\n    const ang = (i / MORPH_POINTS) * 2 * Math.PI - Math.PI / 2;\n    const r = Math.min(1.05, radiusAt(ang));\n    const x = (50 + Math.cos(ang) * 46 * r).toFixed(2);\n    const y = (50 + Math.sin(ang) * 46 * r).toFixed(2);\n    parts.push(`${i === 0 ? \"M\" : \"L\"}${x} ${y}`);\n  }\n  return `${parts.join(\" \")} Z`;\n}\n\nconst MORPH_PATHS = [\n  morphPath(() => 1), // circle\n  morphPath((a) => ngonRadius(a, 4, Math.PI / 4)), // square\n  morphPath((a) => ngonRadius(a, 3)), // triangle\n  morphPath((a) => ngonRadius(a, 6)), // hexagon\n  morphPath((a) => ngonRadius(a, 4)), // diamond\n];\n\n// Each shape appears twice in a row so it fully forms and HOLDS before the\n// next morph. Even keyframe spacing then alternates hold / morph segments.\nconst MORPH_SEQ = [...MORPH_PATHS.flatMap((p) => [p, p]), MORPH_PATHS[0]];\n// Rotation and scale only change across the morph segments, staying put on the\n// holds, so a settled shape sits still.\nconst MORPH_ROT = [0, 0, 72, 72, 144, 144, 216, 216, 288, 288, 360];\nconst MORPH_SCALE = [1, 1, 0.88, 0.88, 1, 1, 0.88, 0.88, 1, 1, 1];\n\nfunction Morph({ size, speed, reduce }: PartProps) {\n  return (\n    <motion.svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 100 100\"\n      role=\"img\"\n      animate={\n        reduce\n          ? { opacity: [1, 0.4, 1] }\n          : { rotate: MORPH_ROT, scale: MORPH_SCALE }\n      }\n      transition={\n        reduce\n          ? { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }\n          : { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }\n      }\n    >\n      <title>Loading</title>\n      <motion.path\n        fill=\"currentColor\"\n        d={MORPH_PATHS[0]}\n        animate={reduce ? undefined : { d: MORPH_SEQ }}\n        transition={\n          reduce\n            ? undefined\n            : { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }\n        }\n      />\n    </motion.svg>\n  );\n}\n\nconst COMET_TRAIL = [0, 1, 2, 3, 4, 5];\n\nfunction Comet({ size, speed, reduce }: PartProps) {\n  const head = size * 0.2;\n  const r = size / 2 - head / 2;\n  return (\n    <span className=\"relative\" style={{ width: size, height: size }}>\n      <motion.span\n        className=\"absolute inset-0\"\n        animate={reduce ? REDUCED.animate : { rotate: 360 }}\n        transition={\n          reduce\n            ? REDUCED.transition\n            : { duration: speed, ease: \"linear\", repeat: Infinity }\n        }\n      >\n        {COMET_TRAIL.map((i) => {\n          const scale = 1 - i * 0.13;\n          const sz = head * scale;\n          return (\n            <span\n              key={i}\n              className=\"absolute top-1/2 left-1/2 rounded-full bg-current\"\n              style={{\n                width: sz,\n                height: sz,\n                marginLeft: -sz / 2,\n                marginTop: -sz / 2,\n                opacity: 1 - i * 0.16,\n                transform: `rotate(${-i * 15}deg) translateY(${-r}px)`,\n              }}\n            />\n          );\n        })}\n      </motion.span>\n    </span>\n  );\n}\n\nconst SCRAMBLE_TARGET = \"LOADING\";\nconst SCRAMBLE_GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/*#@\";\n\nfunction Scramble({ size, speed, reduce }: PartProps) {\n  const [text, setText] = useState(SCRAMBLE_TARGET);\n  useEffect(() => {\n    if (reduce) {\n      setText(SCRAMBLE_TARGET);\n      return;\n    }\n    let tick = 0;\n    const total = SCRAMBLE_TARGET.length + 4;\n    const id = setInterval(\n      () => {\n        const reveal = tick % total;\n        let s = \"\";\n        for (let i = 0; i < SCRAMBLE_TARGET.length; i++) {\n          s +=\n            i < reveal\n              ? SCRAMBLE_TARGET[i]\n              : SCRAMBLE_GLYPHS[\n                  Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)\n                ];\n        }\n        setText(s);\n        tick++;\n      },\n      (speed / SCRAMBLE_TARGET.length) * 1000 * 0.55,\n    );\n    return () => clearInterval(id);\n  }, [speed, reduce]);\n\n  return (\n    <span\n      className=\"font-mono font-medium tracking-[0.2em] tabular-nums\"\n      style={{ fontSize: size * 0.42 }}\n    >\n      {text}\n    </span>\n  );\n}\n\nfunction Metaballs({ size, speed, reduce }: PartProps) {\n  const id = useId().replace(/:/g, \"\");\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 100 100\" role=\"img\">\n      <title>Loading</title>\n      <defs>\n        <filter id={id}>\n          <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"5\" result=\"b\" />\n          <feColorMatrix\n            in=\"b\"\n            values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -8\"\n          />\n        </filter>\n      </defs>\n      <g filter={`url(#${id})`} fill=\"currentColor\">\n        <motion.circle\n          cy=\"50\"\n          r=\"15\"\n          animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [30, 70, 30] }}\n          transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}\n          cx={reduce ? 40 : 30}\n        />\n        <motion.circle\n          cy=\"50\"\n          r=\"15\"\n          animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [70, 30, 70] }}\n          transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}\n          cx={reduce ? 60 : 70}\n        />\n      </g>\n    </svg>\n  );\n}\n\nconst NEWTON_BALLS = [0, 1, 2, 3, 4];\n\nfunction Newton({ size, speed, reduce }: PartProps) {\n  const d = size * 0.2;\n  const out = d * 1.1;\n  // Only the end balls move: the left slides out and back on the first half,\n  // then the right on the second half — the impact appears to jump the three\n  // still middle balls. Pure horizontal slide, no swing, no strings.\n  const moves: Record<number, { x: number[]; times: number[] }> = {\n    0: { x: [0, -out, 0, 0], times: [0, 0.28, 0.5, 1] },\n    4: { x: [0, 0, out, 0], times: [0, 0.5, 0.78, 1] },\n  };\n\n  return (\n    <span className=\"flex items-center justify-center\" style={{ height: d }}>\n      {NEWTON_BALLS.map((i) => {\n        const move = moves[i];\n        return (\n          <motion.span\n            key={i}\n            className=\"rounded-full bg-current\"\n            style={{ width: d, height: d }}\n            animate={reduce || !move ? undefined : { x: move.x }}\n            transition={\n              reduce || !move\n                ? undefined\n                : {\n                    duration: speed * 1.5,\n                    ease: EASE_IN_OUT,\n                    repeat: Infinity,\n                    times: move.times,\n                  }\n            }\n          />\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Helix({ size, speed, reduce }: PartProps) {\n  const rows = 7;\n  const dot = size * 0.14;\n  const amp = size * 0.32;\n  return (\n    <span className=\"relative\" style={{ width: size, height: size }}>\n      {Array.from({ length: rows }, (_, r) => {\n        const top = (r / (rows - 1)) * (size - dot);\n        const delay = (r / rows) * speed;\n        return (\n          <span key={`row-${top}`}>\n            <motion.span\n              className=\"absolute rounded-full bg-current\"\n              style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}\n              animate={\n                reduce\n                  ? { opacity: [0.4, 1, 0.4] }\n                  : {\n                      x: [amp, -amp, amp],\n                      scale: [1, 0.5, 1],\n                      opacity: [1, 0.45, 1],\n                    }\n              }\n              transition={{\n                duration: speed,\n                ease: EASE_IN_OUT,\n                repeat: Infinity,\n                delay,\n              }}\n            />\n            <motion.span\n              className=\"absolute rounded-full bg-current\"\n              style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}\n              animate={\n                reduce\n                  ? { opacity: [0.4, 1, 0.4] }\n                  : {\n                      x: [-amp, amp, -amp],\n                      scale: [0.5, 1, 0.5],\n                      opacity: [0.45, 1, 0.45],\n                    }\n              }\n              transition={{\n                duration: speed,\n                ease: EASE_IN_OUT,\n                repeat: Infinity,\n                delay,\n              }}\n            />\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Percent({ size, speed, reduce }: PartProps) {\n  const [p, setP] = useState(0);\n  useEffect(() => {\n    const dur = (reduce ? speed * 2 : speed) * 1000;\n    const start = { t: 0 };\n    const tickMs = 40;\n    const id = setInterval(() => {\n      start.t += tickMs;\n      const next = Math.min(100, Math.round((start.t / dur) * 100));\n      setP(next);\n      if (next >= 100) start.t = 0;\n    }, tickMs);\n    return () => clearInterval(id);\n  }, [speed, reduce]);\n\n  return (\n    <span\n      className=\"flex flex-col items-center\"\n      style={{ gap: size * 0.14, width: size * 1.4 }}\n    >\n      <span\n        className=\"font-mono font-medium tabular-nums\"\n        style={{ fontSize: size * 0.42, lineHeight: 1 }}\n      >\n        {p}%\n      </span>\n      <span\n        className=\"w-full overflow-hidden rounded-full bg-current/15\"\n        style={{ height: Math.max(3, size * 0.1) }}\n      >\n        <span\n          className=\"block h-full rounded-full bg-current\"\n          style={{ width: `${p}%` }}\n        />\n      </span>\n    </span>\n  );\n}\n\nfunction Bars({ size, speed, reduce }: PartProps) {\n  const bar = size * 0.16;\n  return (\n    <span className=\"flex items-center\" style={{ gap: size * 0.1, height: size }}>\n      {[0, 1, 2, 3].map((i) => (\n        <motion.span\n          key={i}\n          className=\"rounded-full bg-current\"\n          style={{ width: bar, height: size, originY: 1 }}\n          animate={\n            reduce ? { opacity: [0.4, 1, 0.4] } : { scaleY: [0.3, 1, 0.3] }\n          }\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: i * speed * 0.12,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction DotMatrix({ size, speed, reduce }: PartProps) {\n  const n = 3;\n  const gap = size * 0.14;\n  const dot = (size - gap * (n - 1)) / n;\n  const cells = Array.from({ length: n * n }, (_, idx) => idx);\n  return (\n    <span\n      className=\"grid\"\n      style={{\n        gap,\n        gridTemplateColumns: `repeat(${n}, ${dot}px)`,\n      }}\n    >\n      {cells.map((idx) => {\n        const x = idx % n;\n        const y = Math.floor(idx / n);\n        // Diagonal wave: cells light in order of their distance from the corner.\n        const delay = ((x + y) / (2 * (n - 1))) * speed;\n        return (\n          <motion.span\n            key={idx}\n            className=\"rounded-full bg-current\"\n            style={{ width: dot, height: dot }}\n            animate={\n              reduce\n                ? { opacity: [0.3, 1, 0.3] }\n                : { opacity: [0.2, 1, 0.2], scale: [0.7, 1, 0.7] }\n            }\n            transition={{\n              duration: speed,\n              ease: EASE_IN_OUT,\n              repeat: Infinity,\n              delay,\n            }}\n          />\n        );\n      })}\n    </span>\n  );\n}\n\n// Ordered Bayer 4x4 matrix — the classic dithering threshold pattern. Cells\n// light in this order, so the fill shimmers like a dissolving halftone.\nconst BAYER_4 = [\n  0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,\n];\n\nfunction Dither({ size, speed, reduce }: PartProps) {\n  const n = 4;\n  const gap = Math.max(1, size * 0.05);\n  const cell = (size - gap * (n - 1)) / n;\n  return (\n    <span\n      className=\"grid\"\n      style={{ gap, gridTemplateColumns: `repeat(${n}, ${cell}px)` }}\n    >\n      {BAYER_4.map((order, idx) => (\n        <motion.span\n          // biome-ignore lint/suspicious/noArrayIndexKey: fixed matrix cells, order never changes\n          key={idx}\n          className=\"bg-current\"\n          style={{ width: cell, height: cell }}\n          animate={reduce ? { opacity: [0.3, 1, 0.3] } : { opacity: [0.1, 1, 0.1] }}\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: (order / BAYER_4.length) * speed,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}