{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "seven-segment",
  "type": "registry:ui",
  "title": "Seven-segment readout",
  "description": "A figure or a count on seven-segment digits behind LCD glass, each digit's segments lit from the ink and fading like phosphor when they change. A Device part.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/seven-segment.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/seven-segment 2026.11.08. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Seven-segment: a figure or a count on seven-segment digits behind LCD glass (proposals/device-studies.md, Agent\n * indicators, Segments). A Device part: the glass is --litt-field with --litt-field-ink for lit segments, so it is only at home\n * under data-material=\"device\" (components.md, \"Seven-segment\").\n *\n * - Anatomy: a pane of glass (--litt-field, --litt-field-shade) holding one figure per character. A figure is an SVG of 43 by 66\n *   units with seven polygons, a to g, 6.6 thick with a gap of 1 between them, slanted 0.09 about its middle row.\n *   An unlit segment is the ink at 10% and a lit one the ink. A dot belongs to the figure before it and lights a\n *   small round point at the figure's foot.\n * - What it takes: the digits 0 to 9, a minus, a dot and a space. `digits` figures wide (default: as many as the value has);\n *   the value is right-aligned, and one too long for the glass shows a bar on every figure, never a wrong number.\n * - Motion: a change lights the new segments at once, and the ones that went out fade like phosphor: brightness times\n *   exp(-dt / 0.11) each frame, under 0.02 written as 0. One small loop per readout writes the segments' opacity\n *   attributes, never React state, and stops when every segment has settled. Nothing moves on its own (R4).\n *   Reduced motion switches the segments at once.\n * - Accessibility: the figures are hidden from assistive tech and the value is said in words in hidden text (\"minus 12.5\").\n *   A change is not announced; put it in a live region yourself if it matters.\n */\nimport { useEffect, useRef, useState } from \"react\";\nimport { useMotionDial } from \"../../lib/litt/motion\";\n\nexport type SevenSegmentProps = {\n  /** What to show: the digits 0 to 9, a minus, a dot and a space. A number is shown as it prints. */\n  value: number | string;\n  /** How many figures wide the glass is. Default: as many as the value has (a dot shares the figure before it). */\n  digits?: number;\n  /** What the readout is, said before the value to a screen reader: \"Temperature\". */\n  label?: string;\n  className?: string;\n};\n\n/* The studies' numbers, in the figure's own units. */\nconst T = 6.6; // thickness\nconst GAP = 1;\nconst SLANT = 0.09;\nconst COL = [5.5, 31] as const; // the vertical bars' left edges\nconst ROW = [5, 33, 61] as const; // the horizontal bars' centre lines\nconst DECAY = 0.11; // seconds: brightness falls by 1/e in this long\nconst FLOOR = 0.02; // under this a segment is written as out\nconst UNLIT = 0.1;\n\nconst H = T / 2;\nconst GP = GAP / Math.SQRT2; // the tip's inset, so the diagonal gap between two segments is GAP\nconst XL = COL[0] + H;\nconst XR = COL[1] + H;\n\nconst pts = (list: [number, number][]) => list.map(([x, y]) => `${(x + (ROW[1] - y) * SLANT).toFixed(2)},${y.toFixed(2)}`).join(\" \");\nconst across = (y: number): [number, number][] => [[XL + GP, y], [XL + GP + H, y - H], [XR - GP - H, y - H], [XR - GP, y], [XR - GP - H, y + H], [XL + GP + H, y + H]];\nconst down = (x: number, y1: number, y2: number): [number, number][] => [[x, y1 + GP], [x + H, y1 + GP + H], [x + H, y2 - GP - H], [x, y2 - GP], [x - H, y2 - GP - H], [x - H, y1 + GP + H]];\n\n/** a b c d e f g, in that order. */\nconst SEGMENTS = [\n  pts(across(ROW[0])),\n  pts(down(XR, ROW[0], ROW[1])),\n  pts(down(XR, ROW[1], ROW[2])),\n  pts(across(ROW[2])),\n  pts(down(XL, ROW[1], ROW[2])),\n  pts(down(XL, ROW[0], ROW[1])),\n  pts(across(ROW[1])),\n];\nconst DOT = { cx: 39.4, cy: 62.6, r: 3 };\n\nconst LIT: Record<string, string> = { \"0\": \"abcdef\", \"1\": \"bc\", \"2\": \"abdeg\", \"3\": \"abcdg\", \"4\": \"bcfg\", \"5\": \"acdfg\", \"6\": \"acdefg\", \"7\": \"abc\", \"8\": \"abcdefg\", \"9\": \"abcdfg\", \"-\": \"g\", \" \": \"\" };\n\ntype Figure = { char: string; dot: boolean };\n\n/** The figures a value needs: each character is one, and a dot lights the point of the one before it. */\nfunction figuresOf(text: string): Figure[] {\n  const out: Figure[] = [];\n  for (const char of text) {\n    if (char === \".\" && out.length && !out[out.length - 1]!.dot) out[out.length - 1]!.dot = true;\n    else if (char === \".\") out.push({ char: \" \", dot: true });\n    else out.push({ char: char in LIT ? char : \" \", dot: false });\n  }\n  return out;\n}\n\n/** Eight flags per figure: the seven segments, then the point. */\nfunction litOf(figs: Figure[], digits: number): boolean[] {\n  const over = figs.length > digits;\n  const shown = over ? Array.from({ length: digits }, () => ({ char: \"-\", dot: false })) : [...Array.from({ length: digits - figs.length }, () => ({ char: \" \", dot: false })), ...figs];\n  return shown.flatMap((f) => [...\"abcdefg\"].map((s) => LIT[f.char]!.includes(s)).concat(f.dot));\n}\n\nfunction Digit({ lit }: { lit: boolean[] }) {\n  /* React writes each opacity once, when the figure mounts; from then on the loop owns the attribute. */\n  const [first] = useState(lit);\n  return (\n    <svg viewBox=\"0 0 43 66\" aria-hidden=\"true\" className=\"h-[2.4em] w-auto shrink-0 fill-current\">\n      {SEGMENTS.map((points, i) => (\n        <polygon key={i} data-seg points={points} opacity={first[i] ? 1 : UNLIT} />\n      ))}\n      <circle data-seg cx={DOT.cx} cy={DOT.cy} r={DOT.r} opacity={first[7] ? 1 : UNLIT} />\n    </svg>\n  );\n}\n\n/** A readout of digits behind LCD glass: new segments light at once and the old ones fade like phosphor. */\nexport function SevenSegment({ value, digits, label, className = \"\" }: SevenSegmentProps) {\n  const text = typeof value === \"number\" ? String(value) : value;\n  const figs = figuresOf(text);\n  const width = Math.max(1, digits ?? figs.length);\n  const lit = litOf(figs, width);\n  const { reduced } = useMotionDial();\n\n  const root = useRef<HTMLSpanElement>(null);\n  const state = useRef({ level: [] as number[], target: [] as boolean[], written: [] as string[], frame: 0 });\n\n  useEffect(() => {\n    const el = root.current;\n    const s = state.current;\n    if (!el) return;\n    const segs = [...el.querySelectorAll(\"[data-seg]\")];\n    const write = (i: number) => {\n      const op = (UNLIT + (1 - UNLIT) * s.level[i]!).toFixed(3);\n      if (s.written[i] !== op) {\n        segs[i]!.setAttribute(\"opacity\", op);\n        s.written[i] = op;\n      }\n    };\n    /* A different number of figures is a new glass, not a change: it starts as it is. */\n    if (s.level.length !== lit.length) {\n      s.level = lit.map((on) => (on ? 1 : 0));\n      s.written = lit.map((on) => (on ? \"1.000\" : UNLIT.toFixed(3)));\n    }\n    s.target = lit;\n    cancelAnimationFrame(s.frame);\n    s.frame = 0;\n    if (reduced) {\n      lit.forEach((on, i) => {\n        s.level[i] = on ? 1 : 0;\n        write(i);\n      });\n      return;\n    }\n    let moving = false;\n    lit.forEach((on, i) => {\n      if (on) {\n        s.level[i] = 1; // lit at once\n        write(i);\n      } else if (s.level[i]! > 0) moving = true;\n    });\n    if (!moving) return;\n    let last = performance.now();\n    const frame = (now: number) => {\n      const fall = Math.exp(-Math.min(0.1, (now - last) / 1000) / DECAY);\n      last = now;\n      let again = false;\n      s.target.forEach((on, i) => {\n        if (on || s.level[i] === 0) return;\n        s.level[i] = s.level[i]! * fall < FLOOR ? 0 : s.level[i]! * fall;\n        if (s.level[i]! > 0) again = true;\n        write(i);\n      });\n      s.frame = again ? requestAnimationFrame(frame) : 0;\n    };\n    s.frame = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(s.frame);\n    // lit is rebuilt each render; its contents are what matter.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [lit.join(\"\"), reduced]);\n\n  const words = text.replace(/-/g, \"minus \").replace(/\\s+/g, \" \").trim();\n  return (\n    <span\n      ref={root}\n      className={\n        \"inline-flex w-fit items-center gap-[0.35em] rounded-litt-control bg-litt-field px-3 py-2 text-litt-body text-litt-field-ink shadow-[inset_0_0_0_1px_var(--litt-field-line),var(--litt-field-shade)] \" + className\n      }\n    >\n      <span className=\"sr-only\">\n        {label ? `${label}: ` : \"\"}\n        {words}\n      </span>\n      {Array.from({ length: width }, (_, i) => (\n        <Digit key={i} lit={lit.slice(i * 8, i * 8 + 8)} />\n      ))}\n    </span>\n  );\n}\n",
      "target": "components/litt/seven-segment.tsx"
    }
  ],
  "docs": "Needs data-material=\"device\" on <html>. Pass a value and the digits; a change rolls the segments on the instant tier. Under reduced motion they switch at once.",
  "meta": {
    "version": "2026.11.08",
    "released": "2026-11-08",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
