{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tape-progress",
  "type": "registry:ui",
  "title": "Tape progress",
  "description": "Progress as two tape reels: the supply empties and the take-up fills, each turning at the speed the tape's constant pace gives its radius, with the value said in words. A Device part.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/tape-progress.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/tape-progress 2026.11.08. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Tape progress: Progress said as two tape reels in a window (proposals/device-studies.md, Tape reels). A Device part:\n * the window is LCD glass (--litt-field, --litt-field-ink, --litt-field-shade), so it is only at home under data-material=\"device\"\n * (components.md, \"Tape progress\"). It takes Progress's props and says the value the way Progress does.\n *\n * - Anatomy: the label and the reading above, as Progress has them, then a smoked window (200 by 110 in its own units,\n *   at most 440px wide): the supply reel on the left emptying, the take-up on the right filling, the tape between them\n *   over two guides and across a head, and a record light that is lit while the work runs (the accent, the one small\n *   light in the view).\n * - The reels are discs. A reel's pack is a disc whose radius follows the value by area: the supply is\n *   sqrt(hub^2 + (1 - p) * span) and the take-up sqrt(hub^2 + p * span), both 15 to 38, so the tape on one is the tape\n *   gone from the other. Each reel sets two local properties, --tape-r (the pack radius) and --tape-a (the hub's angle),\n *   and the tape is one path whose ends are tangent to the packs.\n * - The pace is constant, so a reel's angular velocity is span / (K * r) times the change in value: it goes with 1/r, the\n *   emptying reel speeding up and the filling one slowing down, about eight turns end to end.\n * - Motion: the value is eased by a critically damped spring (stiffness 140, damping 24), so a change glides and a\n *   steady stream of small ones reads as one pace. The loop writes the two properties and the tape's path from the\n *   spring each frame, never React state, and stops when the spring has settled. Nothing moves on its own (R4), and the\n *   first render sits at the value. Reduced motion jumps to each new value.\n * - Ending: done fills the take-up and the reading says Done; failed stops the reels where they are, the record light goes\n *   out and the reading says Failed in --litt-danger. With no value yet the reels sit at the start and the reading says Waiting.\n * - Accessibility: the window is a progressbar named by the label with aria-valuenow while the value is known and the\n *   reading as aria-valuetext, as Progress is. Ending done or failed is said once in a polite status.\n */\nimport { useEffect, useId, useRef, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { useMotionDial } from \"../../lib/litt/motion\";\n\nexport type TapeProgressStatus = \"active\" | \"done\" | \"failed\";\n\nexport type TapeProgressProps = {\n  /** What is in progress: \"Exporting the reel\", \"Importing 1,204 pages\". */\n  label: ReactNode;\n  /** How far along it is, from 0 to max. Leave it out while that is not known yet: the reels sit at the start. */\n  value?: number;\n  /** The value that means finished. */\n  max?: number;\n  /** The reading at the end of the label row, and what a screen reader hears: \"412 of 1,204\". Default: the percent. */\n  valueText?: string;\n  /** active while it runs; done fills the take-up and says Done; failed stops where it is and says Failed. */\n  status?: TapeProgressStatus;\n  /** A line under the window: the time left, or what failed and what to do about it. */\n  message?: ReactNode;\n  /** Hides the label row but keeps it as the name, for a row that already shows the name. */\n  hideLabel?: boolean;\n  className?: string;\n};\n\n/* The window's geometry, in its own units (the studies' numbers). */\nconst FULL = 38; // the full pack\nconst HUB = 15;\nconst SPAN = FULL * FULL - HUB * HUB; // 1219\nconst K = (2 * (FULL - HUB)) / (2 * Math.PI * 8); // about eight turns end to end\nconst SUPPLY = { x: 52, y: 46 };\nconst TAKE_UP = { x: 148, y: 46 };\nconst GUIDE_L = { x: 13, y: 96 };\nconst GUIDE_R = { x: 187, y: 96 };\nconst TAPE_Y = 91;\n\n/* The spring that eases the value: critically damped, integrated in fixed steps. */\nconst STIFFNESS = 140;\nconst DAMPING = 24;\nconst STEP = 1 / 240;\n\nconst packRadius = (p: number, supply: boolean) => Math.sqrt(HUB * HUB + (supply ? 1 - p : p) * SPAN);\n\n/** Where a line from (px, py) touches a disc: the tangent point on the outer side (side -1 left, 1 right of the pack). */\nfunction tangent(c: { x: number; y: number }, r: number, px: number, py: number, side: 1 | -1): [number, number] {\n  const dx = px - c.x;\n  const dy = py - c.y;\n  const a = Math.atan2(dy, dx);\n  const b = Math.acos(Math.min(1, r / Math.hypot(dx, dy)));\n  const t = side === 1 ? a + b : a - b;\n  return [c.x + r * Math.cos(t), c.y + r * Math.sin(t)];\n}\n\nconst tapePath = (p: number) => {\n  const [sx, sy] = tangent(SUPPLY, packRadius(p, true), GUIDE_L.x, TAPE_Y, 1);\n  const [tx, ty] = tangent(TAKE_UP, packRadius(p, false), GUIDE_R.x, TAPE_Y, -1);\n  return `M${sx.toFixed(2)} ${sy.toFixed(2)}L${GUIDE_L.x} ${TAPE_Y}L${GUIDE_R.x} ${TAPE_Y}L${tx.toFixed(2)} ${ty.toFixed(2)}`;\n};\n\nconst inkMix = (pct: number) => `color-mix(in oklab, var(--litt-field-ink) ${pct}%, transparent)`;\n\n/** Progress as two tape reels: the supply empties and the take-up fills, each turning at the pace its radius gives it. */\nexport function TapeProgress({ label, value, max = 100, valueText, status = \"active\", message, hideLabel = false, className = \"\" }: TapeProgressProps) {\n  const labelId = useId();\n  const messageId = useId();\n  const { reduced } = useMotionDial();\n  const known = value !== undefined || status === \"done\";\n  const share = status === \"done\" ? 1 : max > 0 && value !== undefined ? Math.max(0, Math.min(1, value / max)) : 0;\n  const reading =\n    status === \"done\" ? \"Done\" : status === \"failed\" ? \"Failed\" : value === undefined ? (valueText ?? \"Waiting\") : (valueText ?? `${Math.round(share * 100)}%`);\n  const tone = status === \"failed\" ? \"text-litt-danger\" : \"text-litt-fg-subtle\";\n  const recording = status === \"active\" && known && share < 1;\n\n  const supply = useRef<HTMLDivElement>(null);\n  const takeUp = useRef<HTMLDivElement>(null);\n  const tape = useRef<SVGPathElement>(null);\n  /* The first value, for React's one write of the properties the loop owns from then on. */\n  const first = useRef(share);\n  const sim = useRef({ x: share, v: 0, target: share, shown: share, angS: 0, angT: 0, frame: 0, ready: false });\n\n  /* Writes the pack radii, hub angles and the tape for the spring's value x, turning each reel by the tape it moved. */\n  const paint = (x: number) => {\n    const s = sim.current;\n    const dp = x - s.shown;\n    if (dp !== 0) {\n      const mid = s.shown + dp / 2;\n      const turn = (r: number) => ((SPAN / (K * r)) * dp * 180) / Math.PI;\n      s.angS = (s.angS + turn(packRadius(mid, true))) % 360;\n      s.angT = (s.angT + turn(packRadius(mid, false))) % 360;\n      s.shown = x;\n    }\n    supply.current?.style.setProperty(\"--tape-r\", packRadius(x, true).toFixed(3));\n    supply.current?.style.setProperty(\"--tape-a\", `${s.angS.toFixed(1)}deg`);\n    takeUp.current?.style.setProperty(\"--tape-r\", packRadius(x, false).toFixed(3));\n    takeUp.current?.style.setProperty(\"--tape-a\", `${s.angT.toFixed(1)}deg`);\n    tape.current?.setAttribute(\"d\", tapePath(x));\n  };\n\n  useEffect(() => {\n    const s = sim.current;\n    s.target = share;\n    cancelAnimationFrame(s.frame);\n    s.frame = 0;\n    /* The first value, and any value under reduced motion, is where the reels are, not somewhere they travel to. */\n    if (reduced || !s.ready) {\n      s.ready = true;\n      s.x = share;\n      s.v = 0;\n      paint(share);\n      return;\n    }\n    let last = performance.now();\n    let acc = 0;\n    const frame = (now: number) => {\n      acc += Math.min(0.1, (now - last) / 1000);\n      last = now;\n      while (acc >= STEP) {\n        s.v += (STIFFNESS * (s.target - s.x) - DAMPING * s.v) * STEP;\n        s.x += s.v * STEP;\n        acc -= STEP;\n      }\n      const settled = Math.abs(s.target - s.x) < 5e-5 && Math.abs(s.v) < 1e-3;\n      if (settled) {\n        s.x = s.target;\n        s.v = 0;\n      }\n      paint(s.x);\n      s.frame = settled ? 0 : requestAnimationFrame(frame);\n    };\n    s.frame = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(s.frame);\n    // paint only reads refs, so it never changes what this effect does.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [share, reduced]);\n\n  const reel = (at: { x: number; y: number }, r0: number): CSSProperties =>\n    ({ left: `${(at.x / 200) * 100}%`, top: `${(at.y / 110) * 100}%`, \"--tape-r\": r0.toFixed(3), \"--tape-a\": \"0deg\" }) as CSSProperties;\n  const hub = { background: `repeating-conic-gradient(from var(--tape-a), ${inkMix(55)} 0deg 24deg, transparent 24deg 120deg)` };\n\n  /* A function that returns elements, not a component: a component made here would be a new type every render and remount the reels. */\n  const reelOf = (at: { x: number; y: number }, supplySide: boolean, el: RefObject<HTMLDivElement | null>) => (\n    <div key={at.x} ref={el} aria-hidden=\"true\" className=\"absolute aspect-square w-[40%] -translate-x-1/2 -translate-y-1/2\" style={reel(at, packRadius(first.current, supplySide))}>\n      <span className=\"absolute inset-0 rounded-litt-pill shadow-[inset_0_0_0_1px_var(--litt-field-line)]\" />\n      <span\n        className=\"absolute top-1/2 left-1/2 aspect-square w-[calc(var(--tape-r)*2.5%)] -translate-x-1/2 -translate-y-1/2 rounded-litt-pill bg-[color-mix(in_oklab,var(--litt-field-ink)_13%,transparent)]\"\n      />\n      <span className=\"absolute top-1/2 left-1/2 aspect-square w-[37.5%] -translate-x-1/2 -translate-y-1/2 rounded-litt-pill bg-litt-field shadow-[inset_0_0_0_1px_var(--litt-field-line)]\" />\n      <span className=\"absolute top-1/2 left-1/2 aspect-square w-[30%] -translate-x-1/2 -translate-y-1/2 rounded-litt-pill\" style={hub} />\n    </div>\n  );\n\n  return (\n    <div className={\"grid min-w-0 gap-2 \" + className}>\n      <div className={hideLabel ? \"sr-only\" : \"flex items-baseline justify-between gap-4\"}>\n        <span id={labelId} className=\"min-w-0 truncate text-litt-small text-litt-fg\">\n          {label}\n        </span>\n        <span className={\"shrink-0 text-litt-caption font-normal tabular-nums \" + tone}>{reading}</span>\n      </div>\n      <div\n        role=\"progressbar\"\n        aria-labelledby={labelId}\n        aria-describedby={message ? messageId : undefined}\n        aria-valuemin={0}\n        aria-valuemax={max}\n        aria-valuenow={known ? Math.round(share * max) : undefined}\n        aria-valuetext={reading}\n        className=\"relative aspect-[200/110] w-full max-w-[27.5rem] overflow-hidden rounded-litt-control bg-litt-field shadow-[inset_0_0_0_1px_var(--litt-field-line),var(--litt-field-shade)] forced-colors:border\"\n      >\n        <svg viewBox=\"0 0 200 110\" aria-hidden=\"true\" className=\"absolute inset-0 size-full\">\n          <path ref={tape} d={tapePath(first.current)} fill=\"none\" strokeWidth={1.6} className=\"stroke-[color-mix(in_oklab,var(--litt-field-ink)_30%,transparent)] forced-colors:stroke-[CanvasText]\" />\n          {[GUIDE_L, GUIDE_R].map((g) => (\n            <circle key={g.x} cx={g.x} cy={g.y} r={5} fill=\"none\" strokeWidth={1} className=\"stroke-[color-mix(in_oklab,var(--litt-field-ink)_30%,transparent)]\" />\n          ))}\n          <rect x={91} y={TAPE_Y} width={18} height={9} className=\"fill-[color-mix(in_oklab,var(--litt-field-ink)_25%,transparent)]\" />\n          <circle cx={114} cy={96} r={1.6} className={recording ? \"fill-litt-accent\" : \"fill-[color-mix(in_oklab,var(--litt-field-ink)_18%,transparent)]\"} />\n        </svg>\n        {reelOf(SUPPLY, true, supply)}\n        {reelOf(TAKE_UP, false, takeUp)}\n      </div>\n      {message && (\n        <p id={messageId} className={\"text-litt-small \" + (status === \"failed\" ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {message}\n        </p>\n      )}\n      {/* The end is said once, so nobody has to watch the reels. Kept mounted, so the change is announced. */}\n      <span role=\"status\" className=\"sr-only\">\n        {status === \"active\" ? \"\" : `${typeof label === \"string\" ? `${label}: ` : \"\"}${status === \"done\" ? \"done\" : \"failed\"}.`}\n      </span>\n    </div>\n  );\n}\n",
      "target": "components/litt/tape-progress.tsx"
    }
  ],
  "docs": "Needs data-material=\"device\" on <html>. The same props as Progress (label, value, max, status). Under reduced motion the reels jump to the value.",
  "meta": {
    "version": "2026.11.08",
    "released": "2026-11-08",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
