{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scrubber","type":"registry:component","title":"Scrubber","description":"A budget, a timeout or a position on a scale where the neighbourhood matters: the readout says how far under or over the named limit the value is, the ticks can be real measurements (\"87% of requests at or under 90 ms\"), hover shows where a press lands, a lens opens around the knob while it is held or focused (the knob never moves under it), and a release decides at once while the knob settles with the look's physics.","categories":["forms"],"dependencies":["@radix-ui/react-direction@1.1.4","clsx@^2.1.1","lucide-react@^1.48.0","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/scrubber/messages.ts","type":"registry:file","target":"src/ui/beamline/components/scrubber/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English defaults; instance labels take precedence. */\nexport const english = {\n  /** The distance under a threshold, when the threshold gives no words of its own. */\n  below: \"{delta} under the {limit} limit\",\n  /** The distance over a threshold, when the threshold gives no words of its own. */\n  above: \"{delta} over the {limit} limit\",\n  atLimit: \"at the {limit} limit\",\n  /** With a distribution: how many measured values sit at or under the value (\"92 % of requests at or under 90 ms\"). */\n  share: \"{share} of {what} at or under {value}\",\n  samples: \"samples\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/scrubber/scrubber.module.css","type":"registry:file","target":"src/ui/beamline/components/scrubber/scrubber.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Scrubber: a well (the inset role) holding a row of ticks and a knob (the knob role). At rest everything is placed\n     by CSS from its value; the lens, a held knob past an end and a glide move parts with `translate` and `scale` only\n     (scrubber.tsx draw()), so nothing lays out the page while it moves and rest is plain CSS again. */\n  .root {\n    container-type: inline-size;\n    display: grid;\n    gap: var(--pui-space-2);\n    width: 100%;\n    min-width: min(10rem, 100%);\n  }\n  .root[data-disabled] {\n    opacity: var(--pui-opacity-disabled);\n  }\n\n  /* the label and the readout share a line when there is room (inline form), and stack in a narrow one */\n  .head {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: baseline;\n    justify-content: space-between;\n    gap: var(--pui-space-1) var(--pui-space-3);\n  }\n  .root[data-form=\"stacked\"] .head {\n    flex-direction: column;\n    align-items: flex-start;\n  }\n  .headless {\n    justify-content: flex-end;\n  }\n  .label {\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-medium);\n    color: var(--pui-ink);\n  }\n  .readout {\n    display: inline-flex;\n    flex-wrap: wrap;\n    align-items: baseline;\n    gap: var(--pui-space-1-5);\n    min-width: 0;\n    font-size: var(--pui-text-sm);\n    color: var(--pui-ink-2);\n  }\n  /* every run of words takes its direction from its own text (dir=\"auto\" in the markup): \"90 ms\" stays \"90 ms\" in a\n     right-to-left page, and a translated readout runs in its own script's direction */\n  .value {\n    font-family: var(--pui-font-numeric);\n    font-variant-numeric: tabular-nums;\n    color: var(--pui-ink);\n  }\n  .side {\n    display: inline-flex;\n    align-items: center;\n    gap: var(--pui-space-1);\n    font-variant-numeric: tabular-nums;\n  }\n  .sideIcon {\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    flex-shrink: 0;\n    align-self: center;\n  }\n\n  /* the scale and its labels: one hover region, so moving from the well to a label keeps the hairline */\n  .track {\n    display: grid;\n    gap: var(--pui-space-1-5);\n  }\n  /* the well: 28px drawn, 44px to the touch (the ::before is the hit area); the page keeps vertical scrolling */\n  .well {\n    position: relative;\n    height: 28px;\n    touch-action: pan-y;\n    user-select: none;\n    cursor: pointer;\n  }\n  .well::before {\n    content: \"\";\n    position: absolute;\n    inset: -8px 0;\n  }\n  .root[data-disabled] .well {\n    cursor: default;\n  }\n  /* the painted well (the inset role) and the clipped layer of zone and ticks: past an end both stretch with the look's\n     give (scrubber.tsx draw()), so the knob stays inside its well */\n  .surface {\n    --m-inset-fill: var(--pui-control-track);\n    position: absolute;\n    inset: 0;\n    border-radius: var(--pui-radius-control);\n    transition: var(--m-transition);\n  }\n  .clip {\n    position: absolute;\n    inset: 0;\n    overflow: hidden;\n    border-radius: var(--pui-radius-control);\n    pointer-events: none;\n  }\n  /* values run inside the end padding: the zone, the ticks, the rail and the marks share this inset */\n  .inner {\n    position: absolute;\n    inset: 6px 10px;\n  }\n  /* the side past the threshold, tinted by its tone (toneTint): drawn past the far end so the lens can move its edge\n     without a gap */\n  .zone {\n    position: absolute;\n    inset-block: -6px;\n    inset-inline-end: -60%;\n  }\n  .tick {\n    position: absolute;\n    bottom: 0;\n    width: 1px;\n    height: 6px;\n    margin-inline-start: -0.5px;\n    background: var(--pui-ink-3);\n    opacity: 0.55;\n    transform-origin: bottom;\n  }\n  .tick[data-major] {\n    height: 11px;\n    background: var(--pui-ink-2);\n    opacity: 1;\n  }\n  /* with a distribution every tick is a bar of the measured values nearest it */\n  .root[data-distribution] .tick {\n    width: 2px;\n    height: calc(2px + 14px * var(--pui-scrubber-density, 0));\n    margin-inline-start: -1px;\n    border-radius: var(--pui-radius-pill) var(--pui-radius-pill) 0 0;\n    opacity: 0.8;\n  }\n  /* ticks past the threshold take its tone's ink (toneInk on the tick) */\n  .tick[data-over] {\n    background: currentColor;\n  }\n\n  /* the rail: where values run; it holds the native range input (keyboard, screen readers, forms) and the knob */\n  .rail {\n    position: absolute;\n    inset-block: 0;\n    inset-inline: 10px;\n  }\n  .native {\n    position: absolute;\n    inset: 0;\n    width: 100%;\n    height: 100%;\n    margin: 0;\n    opacity: 0;\n    pointer-events: none;\n  }\n  /* hover shows where a press lands: a hairline at the value a press there would set (the lens never follows a free\n     pointer, so what is drawn under it is what it selects) */\n  .probe {\n    position: absolute;\n    inset-block: 3px;\n    left: 0;\n    width: 2px;\n    margin-inline-start: -1px;\n    border-radius: var(--pui-radius-pill);\n    background: var(--pui-ink);\n    opacity: 0;\n    pointer-events: none;\n    transition: opacity var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .root[data-probe] .probe {\n    opacity: 0.6;\n    transition-duration: var(--pui-duration-fast);\n  }\n\n  /* the knob: a capsule standing in the well, placed at its value by CSS and moved only with `translate` (the lens, a\n     glide, the give); a 1px boundary keeps 3:1 against the well in every look and theme */\n  .rail .knob {\n    position: absolute;\n    top: 50%;\n    width: 8px;\n    height: 34px;\n    margin-block-start: -17px;\n    margin-inline-start: -4px;\n    border-radius: var(--pui-radius-pill);\n    box-shadow:\n      var(--m-knob-edge),\n      0 0 0 1px var(--pui-edge-control),\n      var(--pui-control-thumb-shadow);\n    cursor: grab;\n    pointer-events: none;\n    transition:\n      scale var(--pui-duration-fast) var(--pui-ease-default),\n      var(--m-transition);\n  }\n  .knob[data-live] {\n    scale: 1.08;\n  }\n  .well:active .knob {\n    cursor: grabbing;\n  }\n  .native:focus-visible ~ .knob {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: 2px;\n    transition: none;\n  }\n\n  /* labelled marks under the well: each is a zero-width anchor at its value, its label centred on it; the ends stay\n     inside the room, and labels that would touch fold away (their ticks stay) */\n  /* the row of labels passes presses through between its labels, so the well's 44px touch area reaches under it */\n  .marks {\n    position: relative;\n    user-select: none;\n    pointer-events: none;\n    height: calc(var(--pui-text-xs) * 1.5);\n    margin-inline: 10px;\n  }\n  /* under the lens labels slide aside for each other; one that would drift nearer another pin folds at once (a fade\n     would leave ghosts over its neighbours) */\n  .root[data-lens] .markLabel {\n    transition: none;\n  }\n  .mark {\n    position: absolute;\n    top: 0;\n    width: 0;\n    cursor: pointer;\n    pointer-events: auto;\n    transform-origin: top center;\n  }\n  /* only the words fold: the pin stays, so every mark still shows where it is */\n  .mark[data-folded] {\n    pointer-events: none;\n  }\n  .mark[data-folded] .markLabel {\n    opacity: 0;\n  }\n  /* a pin from the well to the label ties the words to their moment; pointed at, the probe's hairline stands on the\n     mark (a press on the label picks it) */\n  .mark::before {\n    content: \"\";\n    position: absolute;\n    bottom: 2px;\n    left: -0.5px;\n    width: 1px;\n    height: calc(var(--pui-space-1-5) + 2px); /* from just above the words to 4px inside the well */\n    background: currentColor;\n    color: var(--pui-ink-3);\n    pointer-events: none;\n  }\n  .mark:hover::before {\n    color: var(--pui-ink-2);\n  }\n  .mark[data-limit]::before {\n    color: var(--pui-ink-2);\n  }\n  .markLabel {\n    position: absolute;\n    left: 0;\n    padding-block: 4px;\n    margin-block-start: -4px;\n    font-size: var(--pui-text-xs);\n    color: var(--pui-ink-3);\n    white-space: nowrap;\n    transform: translateX(-50%);\n    transition: opacity var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .mark[data-edge=\"start\"] .markLabel {\n    transform: none;\n  }\n  .mark[data-edge=\"end\"] .markLabel {\n    transform: translateX(-100%);\n  }\n  .mark:dir(rtl)[data-edge=\"start\"] .markLabel {\n    transform: translateX(-100%);\n  }\n  .mark:dir(rtl)[data-edge=\"end\"] .markLabel {\n    transform: none;\n  }\n  .mark:hover .markLabel {\n    color: var(--pui-ink-2);\n  }\n  .mark[data-limit] .markLabel {\n    color: var(--pui-ink-2);\n    font-weight: var(--pui-weight-medium);\n  }\n  .share {\n    justify-self: start; /* the line sits at the page's start edge while its words keep their own direction */\n    font-size: var(--pui-text-xs);\n    color: var(--pui-ink-3);\n    font-variant-numeric: tabular-nums;\n  }\n}\n"},{"path":"beamline/components/scrubber/scrubber.tsx","type":"registry:file","target":"src/ui/beamline/components/scrubber/scrubber.tsx","content":"\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { Check, Info, TriangleAlert } from \"lucide-react\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport { useScopeDirection } from \"../../hooks/use-scope-direction\";\nimport { useWidthAs } from \"../../hooks/use-content-width\";\nimport { useDrag } from \"../../hooks/use-drag\";\nimport { useLadder } from \"../../hooks/use-ladder\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { dodge, fisheye, fitRanked, labelBox, magnification, snap, toStep, unfisheye } from \"../../lib/axis\";\nimport { createSpring, settleWithin, type Spring } from \"../../lib/spring\";\nimport { DEFAULT_HAND, VelocityTracker, project, readHand, rubberband, type Hand } from \"../../lib/gesture\";\nimport { toneInk, toneTint, type Tone } from \"../../lib/tone\";\nimport { cn } from \"../../lib/utils\";\nimport { focusFromPress } from \"../../lib/focus\";\nimport { english } from \"./messages\";\nimport styles from \"./scrubber.module.css\";\n\nexport interface ScrubberMark {\n  /** Where the labelled tick sits. Required. */\n  value: number;\n  /** Its label; defaults to the formatted value. */\n  label?: ReactNode;\n  /** When labels compete for room, a higher priority keeps its words first (the limit and the ends still lead);\n   *  equal priorities keep their order. A folded mark keeps its pin. Defaults to 0. */\n  priority?: number;\n}\n\nexport interface ScrubberThreshold {\n  /** The limit. Required. */\n  value: number;\n  /** Words for the distance under the limit; \"{delta}\" stands for it, else it leads (\"of headroom\" → \"50 ms of headroom\");\n   *  \"{limit}\" stands for the limit (\"under the {limit} budget\"). */\n  below?: string;\n  /** Words for the distance over the limit (\"over the {limit} budget\" → \"60 ms over the 120 ms budget\"). */\n  above?: string;\n  /** The limit's own label on the scale; defaults to its formatted value. It is the last label to fold. */\n  label?: ReactNode;\n  /** Meaning of the side under the limit: \"neutral\" | \"accent\" | \"success\" | \"warning\" | \"danger\" | \"info\"; default\n   *  success. For a minimum (staying above is good) give belowTone \"danger\" and aboveTone \"success\". */\n  belowTone?: Tone;\n  /** Meaning of the side over the limit (the well past it carries this tone); same names; default danger. */\n  aboveTone?: Tone;\n}\n\nexport type ScrubberClassNames = Partial<\n  Record<\"root\" | \"head\" | \"label\" | \"readout\" | \"well\" | \"tick\" | \"zone\" | \"knob\" | \"marks\" | \"mark\" | \"share\", string>\n>;\n\nexport interface ScrubberProps {\n  /** Visible label or accessible name. Required. */\n  label: ReactNode;\n  /** Shows the label visually while keeping its accessible name; defaults to true. */\n  showLabel?: boolean;\n  /** The chosen value, when controlled. */\n  value?: number;\n  /** Initial value when uncontrolled; defaults to min. */\n  defaultValue?: number;\n  /** Every change of the value: each preview while dragging, and the decided value at once on release, a key or a\n   *  mark. To save only what was decided, use onValueCommit. */\n  onValueChange?: (value: number) => void;\n  /** Once per decided change: a release, each key let go, a mark pressed, a screen reader's adjust; never a cancelled\n   *  drag. Save here (debounce or confirm in the app if a save is costly). */\n  onValueCommit?: (value: number) => void;\n  /** A drag was cancelled (Escape, a lost pointer): the value is back to `value` and nothing was committed. */\n  onValueCancel?: (value: number) => void;\n  /** Lowest value. Defaults to 0. */\n  min?: number;\n  /** Highest value. Defaults to 100. */\n  max?: number;\n  /** Increment the value moves by. Defaults to 1. */\n  step?: number;\n  /** Labelled ticks; numbers, or { value, label, priority }. Page Up/Down jump between them; they pull the value like\n   *  detents within 6 px of where they are drawn. When labels crowd, a higher priority keeps its words; every mark keeps\n   *  its pin. A mark at the threshold's value is the threshold's own (one label, its `label`, kept before the others). */\n  marks?: (number | ScrubberMark)[];\n  /** The values the arrows step between instead of `step` (the arrivals on a timeline: one key, one event). */\n  stops?: number[];\n  /** A limit: the readout says how far under or over it the value is, the well past it carries its tone. */\n  threshold?: ScrubberThreshold;\n  /** Measured values along the scale (the last day's latencies): the ticks become their distribution and the readout\n   *  says how many sit at or under the value. */\n  distribution?: number[];\n  /** What the distribution counts, for the readout (\"requests\" → \"92 % of requests at or under 90 ms\"). */\n  distributionLabel?: string;\n  /** Shows the share at or under the value under the scale; defaults to true. False keeps the distribution as the scale only. */\n  showShare?: boolean;\n  /** Turns a value into its text (\"70 ms\"). Wins over the plain settings below. */\n  format?: (value: number) => string;\n  /** Plain formatting a server component can pass: Intl options for the number. */\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Text before the formatted number (\"$\"). */\n  prefix?: string;\n  /** Text after the formatted number (\" ms\"). */\n  suffix?: string;\n  /** What a screen reader hears for the value; defaults to the value, the distance to the limit and the share. */\n  valueText?: (value: number) => string;\n  /** How strongly the lens magnifies around the knob while it is held or has keyboard focus (0 off). Default 2.5. */\n  lens?: number;\n  /** Prevents interaction. */\n  disabled?: boolean;\n  /** Field name for native form submission (the native range input underneath carries it). */\n  name?: string;\n  /** Reading direction; inherits the surrounding direction when omitted. */\n  dir?: \"ltr\" | \"rtl\";\n  /** Classes on the documented visual root. */\n  className?: string;\n  /** Classes for the named visual parts. */\n  classNames?: ScrubberClassNames;\n  /** Inline styles on the documented visual root. */\n  style?: CSSProperties;\n  /** Receives the root element. */\n  ref?: Ref<HTMLDivElement>;\n}\n\nconst SIDE_ICON: Record<Tone, typeof Check> = {\n  success: Check,\n  accent: Check,\n  neutral: Info,\n  info: Info,\n  warning: TriangleAlert,\n  danger: TriangleAlert,\n};\n/** Narrowest gap between two ticks, px; under it they thin out to round multiples of the step. */\nconst TICK_GAP = 7;\n/** How near (px) a mark pulls the value in: a detent wider than the tick it sits on (Fitts). */\nconst PULL_PX = 6;\n/** Px over which the give past an end spends itself (the look's give decides how much of it shows). */\nconst GIVE_EXTENT = 120;\n/** Pointer speed (px/s) at which the lens has flattened to half its strength (speed-coupled flattening; Gutwin 2002). */\nconst FLATTEN_AT = 900;\n/** The pointer-speed window, ms: speed is measured over it, and a hand that has not moved for that long is resting. */\nconst VELOCITY_WINDOW = 120;\n/** Width of the well's padding at each end, px (the rail inside it is where values run); matches the module. */\nconst END_PAD = 10;\n\n/**\n * A value on a scale where the neighbourhood matters: a budget, a year, a version, a replay position. Hover shows a\n * hairline where a press would land; while the knob is held or has keyboard focus a lens magnifies the scale around it,\n * the knob being the lens's fixed point so it never moves under it, flattening while the hand moves fast. Marks pull like\n * detents; a release decides the value at once (with a flick, where it would slide to with the look's carry) and only the\n * knob settles; past an end the well itself stretches with the look's give. With a threshold the readout says how far\n * under or over the limit the value is; with a distribution the ticks are the measured values (the lens spreads them,\n * never changes their height) and the readout says how many sit at or under the value. A native range input underneath\n * carries keyboard, screen readers and forms. Design note: research/parts/scrubber.md.\n */\nexport function Scrubber({\n  label,\n  showLabel = true,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  onValueCancel,\n  min = 0,\n  max = 100,\n  step = 1,\n  marks,\n  stops,\n  threshold,\n  distribution,\n  distributionLabel,\n  showShare = true,\n  format: formatProp,\n  formatOptions,\n  prefix = \"\",\n  suffix = \"\",\n  valueText,\n  lens = 2.5,\n  disabled = false,\n  name,\n  dir: dirProp,\n  className,\n  classNames,\n  style,\n  ref,\n}: ScrubberProps) {\n  // ── props become values ──\n  const i18n = useMessages(\"scrubber\", english);\n  const format = formatProp ?? ((v: number) => `${prefix}${i18n.number(v, formatOptions)}${suffix}`);\n  const [value, setValue] = useControllable(valueProp, defaultValue ?? min, onValueChange);\n  const [rootRef, dir] = useScopeDirection(ref, dirProp);\n  const rtl = dir === \"rtl\";\n  const labelId = useId();\n\n  // ── elements ──\n  const root = useRef<HTMLDivElement | null>(null);\n  const track = useRef<HTMLDivElement>(null);\n  const well = useRef<HTMLDivElement>(null);\n  const surface = useRef<HTMLDivElement>(null);\n  const clip = useRef<HTMLDivElement>(null);\n  const rail = useRef<HTMLDivElement>(null);\n  const knob = useRef<HTMLDivElement>(null);\n  const zone = useRef<HTMLDivElement>(null);\n  const probe = useRef<HTMLDivElement>(null);\n  const input = useRef<HTMLInputElement>(null);\n  const marksRef = useRef<HTMLDivElement>(null);\n\n  // ── room and state ──\n  const form = useLadder(root, [\n    [\"stacked\", 0],\n    [\"inline\", 280],\n  ]);\n  const width = useWidthAs(rail, (w) => Math.round(w), 0);\n  const motion = useMotionTokens(well);\n  const [keyboard, setKeyboard] = useState(false);\n  const [holding, setHolding] = useState(false);\n  const [lensing, setLensing] = useState(false);\n\n  // ── derived ──\n  const span = max - min || 1;\n  const clamp = (v: number) => Math.min(max, Math.max(min, v));\n  const pct = (v: number) => ((v - min) / span) * 100;\n  const markList = useMemo(() => {\n    const list = (marks ?? []).map((m) =>\n      typeof m === \"number\" ? { value: m, label: undefined as ReactNode } : { ...m },\n    );\n    // the limit is always on the scale, as the label that folds last\n    if (threshold && !list.some((m) => m.value === threshold.value))\n      list.push({ value: threshold.value, label: threshold.label });\n    else if (threshold?.label) for (const m of list) if (m.value === threshold.value) m.label = threshold.label;\n    return list.sort((a, b) => a.value - b.value);\n  }, [marks, threshold]);\n  const markValues = useMemo(() => markList.map((m) => m.value), [markList]);\n  // one tick per step while they keep TICK_GAP apart, otherwise every k-th step; marks are always ticks\n  const tickValues = useMemo(() => {\n    const steps = Math.round(span / step);\n    const room = width ? Math.max(2, Math.floor(width / TICK_GAP)) : 40;\n    const every = Math.max(1, Math.ceil(steps / room));\n    const out = new Set<number>(markValues);\n    for (let i = 0; i <= steps; i += every) out.add(toStep(min + i * step, step, min));\n    out.add(max);\n    return [...out].filter((v) => v >= min && v <= max).sort((a, b) => a - b);\n  }, [span, step, width, min, max, markValues]);\n  // with a distribution each tick's height is how many measured values sit near it (a triangular kernel two ticks wide\n  // each side, so a burst of sparse arrivals reads as a taller group, not a row of equal spikes); only a tick with a\n  // value of its own rises, so four arrivals draw four ticks, never a smear across their neighbours (rule 8)\n  const density = useMemo(() => {\n    if (!distribution?.length) return null;\n    const counts = tickValues.map(() => 0);\n    const own = tickValues.map(() => false);\n    for (const sample of distribution) {\n      if (sample < min || sample > max) continue;\n      let lo = 0;\n      let hi = tickValues.length - 1;\n      while (hi - lo > 1) {\n        const mid = (lo + hi) >> 1;\n        if (tickValues[mid] <= sample) lo = mid;\n        else hi = mid;\n      }\n      const at = sample - tickValues[lo] <= tickValues[hi] - sample ? lo : hi;\n      own[at] = true;\n      for (let k = -2; k <= 2; k++) if (counts[at + k] !== undefined) counts[at + k] += 3 - Math.abs(k);\n    }\n    const shown = counts.map((c, i) => (own[i] ? c : 0));\n    const top = Math.max(...shown, 1);\n    return shown.map((c) => c / top);\n  }, [distribution, tickValues, min, max]);\n  const share = useMemo(\n    () => (distribution?.length ? distribution.filter((d) => d <= value).length / distribution.length : null),\n    [distribution, value],\n  );\n  const side: \"below\" | \"above\" | \"at\" | null = threshold\n    ? value > threshold.value\n      ? \"above\"\n      : value < threshold.value\n        ? \"below\"\n        : \"at\"\n    : null;\n  const sideTone: Tone | null = threshold\n    ? side === \"above\"\n      ? (threshold.aboveTone ?? \"danger\")\n      : (threshold.belowTone ?? \"success\")\n    : null;\n  const sideWords = (() => {\n    if (!threshold || !side) return \"\";\n    const limit = format(threshold.value);\n    if (side === \"at\") return i18n(\"atLimit\", { limit });\n    const delta = format(Math.abs(value - threshold.value));\n    const words = side === \"above\" ? threshold.above : threshold.below;\n    if (!words) return i18n(side, { delta, limit });\n    const filled = words.replaceAll(\"{limit}\", limit);\n    return filled.includes(\"{delta}\") ? filled.replace(\"{delta}\", delta) : `${delta} ${filled}`;\n  })();\n  const shareWords =\n    share === null || !showShare\n      ? \"\"\n      : i18n(\"share\", {\n          // rounded down, so \"100 %\" only when every measured value is inside\n          share: i18n.number(Math.floor(share * 100) / 100, { style: \"percent\", maximumFractionDigits: 0 }),\n          what: distributionLabel ?? i18n(\"samples\"),\n          value: format(value),\n        });\n  const spoken = valueText ? valueText(value) : [format(value), sideWords, shareWords].filter(Boolean).join(\", \");\n  const SideIcon = sideTone ? SIDE_ICON[sideTone] : null;\n\n  // ── the moving model, outside React: the lens, the knob as drawn, the hand ──\n  const model = useRef({\n    distortion: 0,\n    drawn: value,\n    /** A glide was under way when the scale changed: the value's effect sends it on from the knob's carried place. */\n    reglide: false,\n    hand: DEFAULT_HAND as Hand,\n    over: false,\n    start: value,\n    raw: value,\n    // where the press landed: the pointer's px and the undistorted px it selected; a drag moves the knob by the\n    // pointer's travel from there, so the knob never jumps away from what was pressed\n    anchorX: 0,\n    anchorU: 0,\n    probe: null as number | null,\n    left: 0,\n    width: 0,\n    scale: 1,\n    ticks: [] as HTMLElement[],\n    marks: [] as HTMLElement[],\n    labels: [] as number[],\n    shown: [] as boolean[],\n    // the labels shown the last time the scale was at rest: under the lens these stay (and dodge), no others\n    restShown: [] as boolean[],\n  });\n  const lensSpring = useRef<Spring | null>(null);\n  const knobSpring = useRef<Spring | null>(null);\n  const speed = useRef(new VelocityTracker(VELOCITY_WINDOW));\n  const restTimer = useRef(0);\n  const committed = useRef(value);\n  const keyDirty = useRef(false);\n  const dragActive = useRef(false);\n  const keyboardRef = useRef(false);\n  const pressed = useRef(false);\n\n  /** Px from the rail's start edge for a value (mirrored in RTL); past the ends it keeps counting. */\n  const xOf = (v: number) => {\n    const x = ((v - min) / span) * model.current.width;\n    return rtl ? model.current.width - x : x;\n  };\n  /** The value under a px position on the rail (not clamped). */\n  const valueAt = (x: number) => {\n    const w = model.current.width || 1;\n    return min + ((rtl ? w - x : x) / w) * span;\n  };\n  /** The pointer's px on the rail, in layout px, so a scaled ancestor (a zoomed canvas) does not skew it. */\n  const local = (clientX: number) => (clientX - model.current.left) * model.current.scale;\n  /** The lens's centre: always the knob as drawn, so the knob is its fixed point and never moves under the lens. */\n  const focusX = () => Math.min(Math.max(xOf(clamp(model.current.drawn)), 0), model.current.width);\n  /** The undistorted px drawn at `x` under the lens as it is now: what a press there selects. */\n  const underPointer = (x: number) => {\n    const m = model.current;\n    return m.distortion > 0.01 ? unfisheye(x, focusX(), m.distortion, 0, m.width) : x;\n  };\n  /** The mark pull in value units, so a detent catches within PULL_PX of where marks are drawn under the lens. */\n  const pull = () => {\n    const m = model.current;\n    const mag = m.distortion > 0.01 ? magnification(focusX(), focusX(), m.distortion, 0, m.width) : 1;\n    return (PULL_PX / mag / (m.width || 1)) * span;\n  };\n  /** Half a pixel in the value's units: what counts as at rest for the knob's spring, whatever the scale (a replay track\n   *  in milliseconds would otherwise count as moving long after it looks still). */\n  const halfPx = () => (span / (model.current.width || 1)) * 0.5;\n  /** Measures the rail and caches what draw() touches; never called in a frame that has written styles. */\n  const measure = () => {\n    const el = rail.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const m = model.current;\n    m.left = r.left;\n    m.width = el.offsetWidth;\n    m.scale = r.width ? el.offsetWidth / r.width : 1;\n    m.ticks = [...(clip.current?.querySelectorAll<HTMLElement>(\"[data-slot=scrubber-tick]\") ?? [])];\n    m.marks = [...(marksRef.current?.querySelectorAll<HTMLElement>(\"[data-slot=scrubber-mark]\") ?? [])];\n    m.labels = m.marks.map((mark) => (mark.firstElementChild as HTMLElement | null)?.offsetWidth ?? 0);\n  };\n\n  /** Draws lens, knob, give, the hover hairline and folded labels from the model: transforms and attributes only. */\n  const draw = () => {\n    const m = model.current;\n    const d = m.distortion > 0.01 ? m.distortion : 0;\n    const w = m.width;\n    const focus = focusX();\n    const lensed = (x: number) => (d ? fisheye(x, focus, d, 0, w) : x);\n    const grow = (x: number, k: number, cap: number) =>\n      d ? Math.min(cap, 1 + (magnification(x, focus, d, 0, w) - 1) * k) : 1;\n    // past an end the well stretches with the look's give; the knob rides its stretched end and the labels move with it\n    const drawnX = xOf(m.drawn);\n    const over = drawnX < 0 ? drawnX : drawnX > w ? drawnX - w : 0;\n    const full = w + END_PAD * 2;\n    const stretch = over ? (full + Math.abs(over)) / full : 1;\n    const stretched = (x: number) =>\n      stretch === 1 ? 0 : over > 0 ? (x + END_PAD) * (stretch - 1) : -(w + END_PAD - x) * (stretch - 1);\n    for (const layer of [surface.current, clip.current]) {\n      if (!layer) continue;\n      layer.style.transformOrigin = over < 0 ? \"right center\" : \"left center\";\n      layer.style.scale = stretch > 1.0005 ? `${stretch} 1` : \"\";\n    }\n    // the lens spreads the ticks; it grows plain ticks a little, but measured bars keep their height (rule 8)\n    for (const tick of m.ticks) {\n      const x = xOf(Number(tick.dataset.value));\n      const g = density ? 1 : grow(x, 0.45, 2.4);\n      tick.style.translate = d ? `${lensed(x) - x}px 0` : \"\";\n      tick.style.scale = g > 1.001 ? `1 ${g}` : \"\";\n    }\n    // labels fold by rank against where they are drawn at rest: the limit first, then the ends, then the rest in order;\n    // under the lens the labels shown at rest slide aside for each other, each staying nearest its own pin\n    const nudges: number[] = [];\n    const lifts: number[] = [];\n    // each mark's pin (where its label must stay nearest) and which way its label hangs (1 start, -1 end, 0 centred)\n    const pins: number[] = [];\n    const inwards: number[] = [];\n    const boxes = m.marks.map((mark, i) => {\n      const x = xOf(Number(mark.dataset.value));\n      const lift = grow(x, 0.06, 1.16);\n      const shift = lensed(x) - x + stretched(x);\n      mark.style.translate = Math.abs(shift) > 0.01 ? `${shift}px 0` : \"\";\n      mark.style.scale = lift > 1.001 ? String(lift) : \"\";\n      const edge = mark.dataset.edge as \"start\" | \"end\" | undefined;\n      const anchor = x + shift;\n      const width = (m.labels[i] ?? 0) * lift;\n      const box = labelBox(anchor, width, w, edge, rtl);\n      // the words stay inside the scale: a label the lens carries past an end is drawn in its clamped box (the pin\n      // stays on the mark); the label is scaled with the mark from its anchor, so the nudge is in its own pixels\n      const inward = edge === \"start\" ? (rtl ? -1 : 1) : edge === \"end\" ? (rtl ? 1 : -1) : 0;\n      const drawnStart = inward === 1 ? anchor : inward === -1 ? anchor - width : anchor - width / 2;\n      nudges[i] = box[0] - drawnStart;\n      lifts[i] = lift;\n      pins[i] = anchor;\n      inwards[i] = inward;\n      return box;\n    });\n    if (boxes.length) {\n      const rank = (i: number) => {\n        const mark = m.marks[i];\n        if (mark.dataset.limit !== undefined) return -2;\n        if (mark.dataset.edge) return -1;\n        return d ? Math.abs((boxes[i][0] + boxes[i][1]) / 2 - focus) : i;\n      };\n      // at rest a mark's priority leads its order; under the lens nearness to the knob does\n      const priority = (i: number) => (d ? 0 : -Number(m.marks[i].dataset.priority ?? 0));\n      const order = boxes\n        .map((_, i) => i)\n        .sort((a, b) => Math.min(0, rank(a)) - Math.min(0, rank(b)) || priority(a) - priority(b) || rank(a) - rank(b));\n      // at rest labels fold by rank; under the lens the labels shown at rest stay and slide aside for each other\n      // (dodge), but a label never drifts nearer another mark's pin than its own (rule 8: it names that moment): where\n      // the lens packs them too tight, the least important of those that would drift folds, and comes back once its\n      // own pin has room to spare (a margin inside its reach, wider for a label coming back), so a label at the edge of fitting does not flicker\n      let keep: boolean[] | null = null;\n      if (d && m.restShown.length === boxes.length) {\n        const byPin = pins.map((_, i) => i).sort((a, b) => pins[a] - pins[b]);\n        const reach = new Map<number, [number, number]>();\n        byPin.forEach((i, k) => {\n          const prev = k > 0 ? (pins[byPin[k - 1]] + pins[i]) / 2 : -Infinity;\n          const next = k < byPin.length - 1 ? (pins[byPin[k + 1]] + pins[i]) / 2 : Infinity;\n          reach.set(i, [prev, next]);\n        });\n        let kept = order\n          .filter((i) => m.restShown[i])\n          .sort((a, b) => boxes[a][0] + boxes[a][1] - boxes[b][0] - boxes[b][1]);\n        let starts: number[] | null = null;\n        while (kept.length) {\n          starts = dodge(\n            kept.map((i) => ({ centre: (boxes[i][0] + boxes[i][1]) / 2, width: boxes[i][1] - boxes[i][0] })),\n            8,\n            w,\n          );\n          if (!starts) break;\n          // where each label meets its pin: the start of one hung from the start edge, the end of one from the end\n          const drifting = kept.filter((i, k) => {\n            const width = boxes[i][1] - boxes[i][0];\n            const at = inwards[i] === 1 ? starts![k] : inwards[i] === -1 ? starts![k] + width : starts![k] + width / 2;\n            const [lo, hi] = reach.get(i)!;\n            const share = m.shown[i] ? 0.9 : 0.7;\n            return at < pins[i] - (pins[i] - lo) * share || at > pins[i] + (hi - pins[i]) * share;\n          });\n          if (!drifting.length) break;\n          // a label drifts because its run is packed too tight: the least important label of that run folds (latest in\n          // rank order), which may be a neighbour that is still in reach, so the limit is never the one to go; then the\n          // rest are packed again\n          const run: number[] = [];\n          kept.forEach((_i, k) => {\n            const prev = kept[k - 1];\n            run[k] =\n              k === 0\n                ? 0\n                : run[k - 1] + (starts![k - 1] + boxes[prev][1] - boxes[prev][0] + 8 < starts![k] - 0.5 ? 1 : 0);\n          });\n          const crowded = new Set(drifting.map((i) => run[kept.indexOf(i)]));\n          const fold = kept\n            .filter((_, k) => crowded.has(run[k]))\n            .reduce((a, b) => (order.indexOf(a) > order.indexOf(b) ? a : b));\n          kept = kept.filter((i) => i !== fold);\n        }\n        if (starts) {\n          kept.forEach((i, k) => (nudges[i] += starts![k] - boxes[i][0]));\n          keep = boxes.map((_, i) => kept.includes(i));\n        }\n      }\n      if (!keep) {\n        keep = fitRanked(boxes, order, 8, m.shown);\n        if (!d) m.restShown = keep;\n      }\n      m.shown = keep;\n      m.marks.forEach((mark, i) => {\n        const label = mark.firstElementChild as HTMLElement | null;\n        if (label) label.style.translate = Math.abs(nudges[i]) > 0.25 ? `${nudges[i] / lifts[i]}px 0` : \"\";\n      });\n      m.marks.forEach((mark, i) => {\n        if (keep[i]) delete mark.dataset.folded;\n        else mark.dataset.folded = \"\";\n      });\n    }\n    if (zone.current && threshold) {\n      const x = xOf(clamp(threshold.value));\n      zone.current.style.translate = d ? `${lensed(x) - x}px 0` : \"\";\n    }\n    if (probe.current && m.probe !== null) probe.current.style.translate = `${lensed(xOf(m.probe))}px 0`;\n    if (knob.current) {\n      // the knob is the lens's centre, so it is drawn exactly at its value: the lens never moves it\n      const base = xOf(clamp(latestValue.current));\n      knob.current.style.translate = Math.abs(drawnX - base) > 0.01 ? `${drawnX - base}px 0` : \"\";\n    }\n  };\n  // the knob's CSS place (its value) for draw() between renders\n  const latestValue = useRef(value);\n  latestValue.current = value;\n\n  /** Brings the lens up around the knob at `strength` (1 full), or puts it away. */\n  const aim = (on: boolean, strength = 1) => {\n    const m = model.current;\n    const target = !on || m.hand.reduced || disabled || lens <= 0 ? 0 : lens * strength;\n    if (target > 0.01) setLensing(true);\n    const response =\n      (target > m.distortion ? motion.spring.snappy.visualDuration : motion.duration.fast) * m.hand.weight;\n    lensSpring.current?.to([target], { feel: { response: Math.max(response, 0.04) } });\n    if (!lensSpring.current?.moving) draw();\n  };\n  /** The lens flattens while the hand moves fast (Gutwin 2002) and is back to full when it rests. */\n  const flatten = () => {\n    const [vx] = speed.current.velocity();\n    return 1 / (1 + (Math.abs(vx) / FLATTEN_AT) ** 2);\n  };\n  /** Where a press at `clientX` would set the value, as the value: what is drawn there, pulled by marks, on a step. */\n  const pressValue = (clientX: number) =>\n    toStep(clamp(snap(valueAt(underPointer(local(clientX))), markValues, pull())), step, min);\n\n  // ── the hand: a press selects what is drawn under it, a drag previews, a release decides, only the knob settles ──\n  const begin = (clientX: number) => {\n    const m = model.current;\n    m.hand = readHand(well.current);\n    measure();\n    knobSpring.current?.stop();\n    m.start = value;\n    m.anchorX = local(clientX);\n    m.anchorU = underPointer(m.anchorX);\n    m.drawn = valueAt(m.anchorU);\n    speed.current.reset();\n    pressed.current = true;\n    setProbe(null);\n    setHolding(true);\n  };\n  const preview = (clientX: number) => {\n    const m = model.current;\n    const x = local(clientX);\n    speed.current.add(performance.now(), x, 0);\n    const u = m.anchorU + (x - m.anchorX);\n    const caught = snap(valueAt(Math.min(Math.max(u, 0), m.width)), markValues, pull());\n    m.raw = caught;\n    // the drawn knob follows the hand 1:1 inside the scale and with the look's give past an end\n    const past = u - Math.min(Math.max(u, 0), m.width);\n    const next = toStep(clamp(caught), step, min);\n    // the knob shows the value release would decide (on its step), and past an end the look's give\n    m.drawn = past ? valueAt(xOf(next) + rubberband(past, GIVE_EXTENT, m.hand.give)) : next;\n    if (next !== latestValue.current) setValue(next);\n    aim(true, flatten());\n    draw();\n    // a hand that stops is resting: the lens comes back to full strength once the speed window holds no movement\n    window.clearTimeout(restTimer.current);\n    restTimer.current = window.setTimeout(() => {\n      if (dragActive.current) latest.current.aim(true, 1);\n    }, VELOCITY_WINDOW);\n  };\n  /** Decides `to` at once (the readout and the callbacks have it now) and lets the knob settle there from where it is. */\n  const decide = (to: number, { velocity = 0, held = false, commit = true } = {}) => {\n    const m = model.current;\n    if (to !== latestValue.current) setValue(to);\n    if (commit) {\n      committed.current = to;\n      onValueCommit?.(to);\n    }\n    const spring = knobSpring.current;\n    if (m.hand.reduced || !spring) {\n      m.drawn = to;\n      setHolding(false);\n      draw();\n      return;\n    }\n    spring.set([m.drawn]);\n    spring.to([to], {\n      velocity: [velocity],\n      // only what the hand held and threw may overshoot and carry; a tap, a key or a mark settles critically damped on\n      // the snappy spring, so a keyboard jump lands within the fast duration (rubric item 6)\n      feel: held\n        ? { response: motion.spring.held.visualDuration * m.hand.weight, bounce: m.hand.bounce }\n        : { response: motion.spring.snappy.visualDuration * m.hand.weight, bounce: 0 },\n      restDelta: halfPx(),\n    });\n  };\n  /** After a release the lens stays only for a keyboard user. */\n  const release = () => {\n    pressed.current = false;\n    window.clearTimeout(restTimer.current);\n    aim(keyboardRef.current);\n  };\n  const { dragging } = useDrag(\n    well,\n    {\n      onPress: (p, e) => {\n        if (disabled) return false;\n        // the press hands focus to the range input without the keyboard's ring, and keeps it there after the click\n        focusFromPress(input.current, e);\n        // a hand's focus is never the keyboard's: a finger's press otherwise opened the keyboard lens until its drag\n        // began, and the drag anchored through the half-open lens and trailed the finger\n        setKeyboard(false);\n        // a mouse press is acknowledged at once (the knob jumps to what was drawn under it); a finger may be scrolling\n        // the page, so it waits for a tap or a sideways drag\n        if (e.pointerType === \"mouse\") {\n          begin(p.x);\n          setKeyboard(false);\n          preview(p.x);\n        }\n      },\n      onStart: (p) => {\n        if (p.pointerType !== \"mouse\") begin(p.x);\n        setKeyboard(false);\n        preview(p.x);\n      },\n      onMove: (p) => preview(p.x),\n      onEnd: (p) => {\n        const m = model.current;\n        const units = ((rtl ? -p.vx : p.vx) * m.scale * span) / (m.width || 1);\n        const glide = project(units, m.hand.carry);\n        const landing = toStep(clamp(snap(m.raw + glide, markValues, pull())), step, min);\n        decide(landing, { velocity: units, held: true });\n        release();\n      },\n      onTap: (p) => {\n        if (p.pointerType !== \"mouse\") begin(p.x);\n        setKeyboard(false);\n        decide(toStep(clamp(snap(valueAt(model.current.anchorU), markValues, pull())), step, min));\n        release();\n      },\n      onCancel: () => {\n        decide(model.current.start, { commit: false });\n        release();\n        onValueCancel?.(model.current.start);\n      },\n      onAbandon: () => {\n        pressed.current = false;\n        setHolding(false);\n      },\n    },\n    { axis: \"x\", disabled },\n  );\n  dragActive.current = dragging;\n  keyboardRef.current = keyboard;\n\n  /** Shows the hover hairline at the value a press would set, or hides it; attributes and transforms only. */\n  const setProbe = (v: number | null) => {\n    const m = model.current;\n    m.probe = v;\n    const el = root.current;\n    if (!el) return;\n    if (v === null) delete el.dataset.probe;\n    else {\n      el.dataset.probe = \"\";\n      draw();\n    }\n  };\n\n  // ── keyboard, on the native range input: the value changes at once, the knob glides over the fast duration ──\n  const glideTo = (to: number) => {\n    const m = model.current;\n    m.hand = readHand(well.current);\n    if (m.hand.reduced || !knobSpring.current) {\n      m.drawn = to;\n      draw();\n      return;\n    }\n    // the knob lands within the fast duration however far the key sent it (Home to End as fast as one step)\n    const response = settleWithin(motion.duration.fast * m.hand.weight, to - m.drawn, halfPx());\n    knobSpring.current.to([to], { feel: { response }, restDelta: halfPx() });\n  };\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    const forward = rtl ? -1 : 1;\n    const up = markValues.find((v) => v > value);\n    const down = [...markValues].reverse().find((v) => v < value);\n    // with stops the arrows go to the next or previous stop (one event), else one step\n    const next = stops?.length ? (stops.find((v) => v > value) ?? max) : value + step;\n    const prev = stops?.length ? ([...stops].reverse().find((v) => v < value) ?? min) : value - step;\n    const moves: Record<string, number> = {\n      ArrowRight: forward > 0 ? next : prev,\n      ArrowLeft: forward > 0 ? prev : next,\n      ArrowUp: next,\n      ArrowDown: prev,\n      PageUp: markValues.length ? (up ?? max) : value + step * 10,\n      PageDown: markValues.length ? (down ?? min) : value - step * 10,\n      Home: min,\n      End: max,\n    };\n    if (!(e.key in moves)) return;\n    e.preventDefault();\n    if (!keyboard) setKeyboard(true);\n    const v =\n      stops?.length && !/Page|Home|End/.test(e.key) ? clamp(moves[e.key]) : toStep(clamp(moves[e.key]), step, min);\n    if (v === value) return;\n    keyDirty.current = true;\n    setValue(v);\n    glideTo(v);\n  };\n  const onKeyUp = () => {\n    if (!keyDirty.current) return;\n    keyDirty.current = false;\n    if (committed.current === value) return;\n    committed.current = value;\n    onValueCommit?.(value);\n  };\n  // a screen reader's adjust gesture changes the native input directly\n  const onNativeChange = (e: ChangeEvent<HTMLInputElement>) => {\n    const raw = Number(e.currentTarget.value);\n    // with stops, an adjust goes to the next or previous stop in its direction (one event), like the arrows\n    const v = stops?.length\n      ? raw > value\n        ? clamp(stops.find((x) => x > value) ?? max)\n        : clamp([...stops].reverse().find((x) => x < value) ?? min)\n      : toStep(clamp(raw), step, min);\n    if (v === value) return;\n    setValue(v);\n    glideTo(v);\n    committed.current = v;\n    onValueCommit?.(v);\n  };\n\n  const latest = useRef({ draw, aim, local, pressValue, setProbe, setHolding, setLensing });\n  latest.current = { draw, aim, local, pressValue, setProbe, setHolding, setLensing };\n\n  // ── effects ──\n  // springs are made once; they reach this render's props through `latest`\n  useEffect(() => {\n    const view = root.current?.ownerDocument.defaultView ?? null;\n    lensSpring.current = createSpring([0], {\n      feel: { response: 0.2 },\n      onFrame: ([d]) => {\n        model.current.distortion = d;\n        latest.current.draw();\n      },\n      onRest: ([d]) => latest.current.setLensing(d > 0.01),\n      view,\n    });\n    knobSpring.current = createSpring([model.current.drawn], {\n      feel: { response: 0.32 },\n      onFrame: ([v]) => {\n        model.current.drawn = v;\n        latest.current.draw();\n      },\n      onRest: () => latest.current.setHolding(false),\n      view,\n    });\n    const timer = restTimer;\n    return () => {\n      lensSpring.current?.stop();\n      knobSpring.current?.stop();\n      window.clearTimeout(timer.current);\n    };\n  }, []);\n\n  // the room: measured when it changes (never on a value change, so a value from outside costs no layout read); the\n  // labels' rest fold is computed here, before the first paint\n  useLayoutEffect(() => {\n    measure();\n    draw();\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- remeasure when the room or the scale changes\n  }, [width, dir, tickValues, markList]);\n  // a value set from outside (a controlled change, a replay head moving per event) glides the knob there over the fast\n  // duration, the part's own spring between discrete values (BUILDING §7); the first value is placed at once\n  const placed = useRef(false);\n  // a new scale (a live recording that grew) keeps the knob where it is on screen: what it drew is carried to the same\n  // place on the new scale, so a glide starts from there, never from the old value's new place, which threw a live knob\n  // back for a frame at every arrival (rule 8). Runs before the value's effect below, in the same commit.\n  const scaleWas = useRef({ min, max });\n  useLayoutEffect(() => {\n    const was = scaleWas.current;\n    scaleWas.current = { min, max };\n    if ((was.min === min && was.max === max) || !placed.current) return;\n    const m = model.current;\n    if (knobSpring.current?.moving) m.reglide = true;\n    m.drawn = min + ((m.drawn - was.min) / (was.max - was.min || 1)) * (max - min);\n    knobSpring.current?.set([m.drawn]);\n  }, [min, max]);\n  // A value arriving mid-glide retargets the glide (never dropped), and every value change redraws against the knob's new\n  // CSS place, since a draw made before this render measured against the old one (reduced motion, a drag, a key).\n  useLayoutEffect(() => {\n    const m = model.current;\n    if (!placed.current || !m.width) {\n      placed.current = true;\n      m.drawn = value;\n      draw();\n      return;\n    }\n    // a hand owns the knob: only redraw it where the hand has it\n    if (dragActive.current || pressed.current || (holding && !m.reglide)) return draw();\n    const reglide = m.reglide;\n    m.reglide = false;\n    if (m.drawn === value && !knobSpring.current?.moving && !reglide) return draw();\n    glideTo(value);\n    draw();\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- follow the value, and a glide the scale's change carried\n  }, [value, min, max]);\n\n  // the readout only grows while the room stays the same: its words change length with the value (\"30 ms of headroom\"\n  // → \"5 ms over\"), and a box that shrank back would move the value sideways at every step\n  const readout = useRef<HTMLSpanElement>(null);\n  const widest = useRef({ width: 0, room: -1 });\n  useLayoutEffect(() => {\n    const el = readout.current;\n    if (!el) return;\n    const room = el.parentElement?.clientWidth ?? 0;\n    if (room !== widest.current.room) {\n      widest.current = { width: 0, room };\n      el.style.minWidth = \"\";\n    }\n    const width = el.getBoundingClientRect().width;\n    if (width > widest.current.width + 0.5) {\n      widest.current.width = width;\n      el.style.minWidth = `${Math.ceil(width)}px`;\n    }\n  });\n\n  // hover over the scale and its labels: a hairline shows where a mouse or pen press would land\n  useEffect(() => {\n    const el = track.current;\n    if (!el || disabled) return;\n    const enter = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      model.current.over = true;\n      measure();\n    };\n    const move = (e: PointerEvent) => {\n      // pressed or dragging, the knob is the answer: no hairline\n      if (e.pointerType === \"touch\" || !model.current.over || pressed.current || dragActive.current) return;\n      // over a label a press picks its mark, so the hairline stands on the mark, not under the pointer (rule 8)\n      const mark = (e.target as Element).closest<HTMLElement>(\"[data-slot=scrubber-mark]:not([data-folded])\");\n      latest.current.setProbe(mark ? Number(mark.dataset.value) : latest.current.pressValue(e.clientX));\n    };\n    const leave = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      model.current.over = false;\n      latest.current.setProbe(null);\n    };\n    el.addEventListener(\"pointerenter\", enter);\n    el.addEventListener(\"pointermove\", move);\n    el.addEventListener(\"pointerleave\", leave);\n    return () => {\n      el.removeEventListener(\"pointerenter\", enter);\n      el.removeEventListener(\"pointermove\", move);\n      el.removeEventListener(\"pointerleave\", leave);\n    };\n  }, [disabled, lens]);\n\n  // keyboard focus: the lens sits on the knob (its centre follows the knob's glide every frame)\n  useLayoutEffect(() => {\n    model.current.hand = readHand(well.current);\n    if (keyboard) measure();\n    if (!pressed.current) aim(keyboard);\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- on focus change only\n  }, [keyboard]);\n\n  /** `press`: the mouse's pointerdown that picked it; a finger picks on its click, after the browser's focus step. */\n  const pickMark = (v: number, press?: PointerEvent) => {\n    const m = model.current;\n    m.hand = readHand(well.current);\n    knobSpring.current?.stop();\n    setHolding(true);\n    if (press) focusFromPress(input.current, press);\n    else input.current?.focus({ preventScroll: true, focusVisible: false } as FocusOptions); // as in focusFromPress\n    setKeyboard(false);\n    decide(v);\n  };\n\n  // ── markup ──\n  const zoneStart = threshold ? pct(clamp(threshold.value)) : 0;\n  return (\n    <div\n      ref={(node) => {\n        root.current = node;\n        rootRef(node);\n      }}\n      dir={dirProp}\n      data-slot=\"scrubber\"\n      data-form={form}\n      data-state={dragging ? \"dragging\" : holding ? \"settling\" : \"idle\"}\n      data-side={side ?? undefined}\n      data-over-tone={threshold ? (threshold.aboveTone ?? \"danger\") : undefined}\n      data-lens={lensing ? \"\" : undefined}\n      data-distribution={density ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      style={style}\n      className={cn(styles.root, classNames?.root, className)}\n    >\n      <div data-slot=\"scrubber-head\" className={cn(styles.head, !showLabel && styles.headless, classNames?.head)}>\n        <span id={labelId} className={cn(styles.label, !showLabel && \"sr-only\", classNames?.label)}>\n          {label}\n        </span>\n        <span\n          ref={readout}\n          data-slot=\"scrubber-readout\"\n          data-tone={sideTone ?? undefined}\n          className={cn(styles.readout, classNames?.readout)}\n        >\n          <span dir=\"auto\" className={styles.value}>\n            {format(value)}\n          </span>\n          {threshold && SideIcon && (\n            <span dir=\"auto\" className={cn(styles.side, sideTone && toneInk[sideTone])}>\n              <SideIcon aria-hidden className={styles.sideIcon} />\n              {sideWords}\n            </span>\n          )}\n        </span>\n      </div>\n      <div ref={track} className={styles.track}>\n        <div ref={well} data-slot=\"scrubber-well\" className={cn(styles.well, classNames?.well)}>\n          <div ref={surface} aria-hidden className={cn(styles.surface, \"m-inset\")} />\n          <div ref={clip} aria-hidden className={styles.clip}>\n            <div className={styles.inner}>\n              {threshold && (\n                <div\n                  ref={zone}\n                  data-slot=\"scrubber-zone\"\n                  data-tone={threshold.aboveTone ?? \"danger\"}\n                  className={cn(styles.zone, toneTint[threshold.aboveTone ?? \"danger\"], classNames?.zone)}\n                  style={{ insetInlineStart: `${zoneStart}%` }}\n                />\n              )}\n              {tickValues.map((v, i) => (\n                <span\n                  key={v}\n                  data-slot=\"scrubber-tick\"\n                  data-value={v}\n                  data-major={!density && markValues.includes(v) ? \"\" : undefined}\n                  data-over={threshold && v > threshold.value ? \"\" : undefined}\n                  className={cn(\n                    styles.tick,\n                    threshold && v > threshold.value && toneInk[threshold.aboveTone ?? \"danger\"],\n                    classNames?.tick,\n                  )}\n                  style={{\n                    insetInlineStart: `${pct(v)}%`,\n                    ...(density ? ({ \"--pui-scrubber-density\": density[i] } as CSSProperties) : null),\n                  }}\n                />\n              ))}\n            </div>\n          </div>\n          <div ref={rail} className={styles.rail}>\n            <input\n              ref={input}\n              type=\"range\"\n              min={min}\n              max={max}\n              step={step}\n              value={value}\n              name={name}\n              disabled={disabled}\n              aria-labelledby={labelId}\n              aria-valuetext={spoken}\n              data-slot=\"scrubber-input\"\n              className={styles.native}\n              onChange={onNativeChange}\n              onKeyDown={onKeyDown}\n              onKeyUp={onKeyUp}\n              onFocus={(e) => setKeyboard(e.currentTarget.matches(\":focus-visible\"))}\n              onBlur={() => setKeyboard(false)}\n            />\n            <div ref={probe} aria-hidden data-slot=\"scrubber-probe\" className={styles.probe} />\n            <div\n              ref={knob}\n              aria-hidden\n              data-slot=\"scrubber-knob\"\n              data-live={holding || dragging ? \"\" : undefined}\n              className={cn(styles.knob, \"m-knob\", classNames?.knob)}\n              style={{ insetInlineStart: `${pct(clamp(value))}%` }}\n            />\n          </div>\n        </div>\n        {markList.length > 0 && (\n          // the keyboard has Page Up/Down for the marks; the labels are a pointer shortcut and stay out of the tree\n          <div ref={marksRef} aria-hidden data-slot=\"scrubber-marks\" className={cn(styles.marks, classNames?.marks)}>\n            {markList.map((m) => (\n              <span\n                key={m.value}\n                data-slot=\"scrubber-mark\"\n                data-value={m.value}\n                data-limit={threshold && m.value === threshold.value ? \"\" : undefined}\n                data-priority={m.priority || undefined}\n                data-edge={m.value <= min ? \"start\" : m.value >= max ? \"end\" : undefined}\n                className={cn(styles.mark, classNames?.mark)}\n                style={{ insetInlineStart: `${pct(m.value)}%` }}\n                // a mouse press picks the mark at once; a finger picks it on a tap, never while scrolling the page\n                // the pointerdown keeps its default: cancelling it made the browser read the focus that follows as the\n                // keyboard's (a focus ring and the keyboard lens after a click); focusFromPress cancels only the mouse's\n                // focus step, which would otherwise leave focus on the page\n                onPointerDown={(e) => {\n                  if (disabled || e.button !== 0 || e.pointerType !== \"mouse\") return;\n                  pickMark(m.value, e.nativeEvent);\n                }}\n                onClick={(e) => {\n                  if (disabled || e.detail === 0 || (e.nativeEvent as PointerEvent).pointerType === \"mouse\") return;\n                  pickMark(m.value);\n                }}\n              >\n                <span data-slot=\"scrubber-mark-label\" dir=\"auto\" className={styles.markLabel}>\n                  {m.label ?? format(m.value)}\n                </span>\n              </span>\n            ))}\n          </div>\n        )}\n      </div>\n      {shareWords && (\n        <span data-slot=\"scrubber-share\" dir=\"auto\" className={cn(styles.share, classNames?.share)}>\n          {shareWords}\n        </span>\n      )}\n    </div>\n  );\n}\n\nexport default Scrubber;\n"},{"path":"beamline/hooks/use-content-width.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-content-width.ts","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState, type RefObject } from \"react\";\n\nconst same = (width: number) => width;\n\n/**\n * The width of an element's content box, kept current with a ResizeObserver. It is first read in a layout effect, so\n * a layout chosen by width (a narrow mode, a chart's scale) is right in the first frame instead of jumping a frame\n * later. `fallback` stands in on the server and until the element exists; `key` re-measures when the element is\n * replaced (one that exists only in one view).\n */\nexport function useContentWidth(ref: RefObject<HTMLElement | null>, fallback: number, key?: unknown): number {\n  return useWidthAs(ref, same, fallback, key);\n}\n\n/**\n * The same width, kept as what a part needs from it (`(w) => w < 640` for a narrow mode): the part renders again only\n * when that changes, not at every pixel.\n */\nexport function useWidthAs<T>(\n  ref: RefObject<HTMLElement | null>,\n  read: (width: number) => T,\n  fallback: T,\n  key?: unknown,\n): T {\n  const [value, setValue] = useState(fallback);\n  const latest = useRef(read);\n  latest.current = read;\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    // the computed width is fractional and ignores transforms, like the observer's content box\n    const s = getComputedStyle(el);\n    const edges = [\"paddingLeft\", \"paddingRight\", \"borderLeftWidth\", \"borderRightWidth\"] as const;\n    const box = parseFloat(s.width);\n    const outer = Number.isFinite(box) ? box : el.offsetWidth;\n    const inner =\n      Number.isFinite(box) && s.boxSizing !== \"border-box\" ? 0 : edges.reduce((w, k) => w + parseFloat(s[k]), 0);\n    setValue(latest.current(outer - inner));\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([e]) => setValue(latest.current(e.contentRect.width)));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref, key]);\n  return value;\n}\n\n/**\n * The height of an element's content box, kept current like `useContentWidth`: for parts that lay out at whatever\n * height their parent gives them (a chart, a map or a book that fills its panel). Read in a layout effect, so the first\n * frame is right; with `enabled` false nothing is measured and the fallback stands.\n */\nexport function useContentHeight(\n  ref: RefObject<HTMLElement | null>,\n  fallback: number,\n  key?: unknown,\n  enabled = true,\n): number {\n  const [value, setValue] = useState(fallback);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!enabled || !el) return;\n    const s = getComputedStyle(el);\n    setValue(el.clientHeight - (parseFloat(s.paddingTop) || 0) - (parseFloat(s.paddingBottom) || 0));\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([e]) => setValue(e.contentRect.height));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref, key, enabled]);\n  return enabled ? value : fallback;\n}\n"},{"path":"beamline/hooks/use-controllable.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-controllable.ts","content":"\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * One value in both forms (rules/CONVENTIONS.md): the prop while it is passed, otherwise the element's own copy that\n * starts at `initial` (a value, or a function that makes it once). The setter takes a value or an updater; an updater\n * reads the latest value even twice in one event, and every change reaches `onChange`, controlled or not.\n * `Next` is what a change can carry when it is narrower than the state: a range that starts empty (null) but is only\n * ever changed to a range.\n */\nexport function useControllable<T, Next extends T = T>(\n  value: T | undefined,\n  initial: T | (() => T),\n  onChange?: (next: Next) => void,\n) {\n  const [own, setOwn] = useState<T>(initial);\n  const current = value !== undefined ? value : own;\n  const latest = useRef(current);\n  latest.current = current;\n  const set = useCallback(\n    (next: Next | ((was: T) => Next)) => {\n      const resolved = typeof next === \"function\" ? (next as (was: T) => Next)(latest.current) : next;\n      latest.current = resolved;\n      if (value === undefined) setOwn(resolved);\n      onChange?.(resolved);\n    },\n    [value, onChange],\n  );\n  return [current, set] as const;\n}\n"},{"path":"beamline/hooks/use-drag.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-drag.ts","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\nimport { VelocityTracker } from \"../lib/gesture\";\n\n/** Where the pointer is during a drag, relative to where it was pressed, with its velocity (px/s) over the last 100 ms. */\nexport interface DragPoint {\n  /** Pointer position in client coordinates. */\n  x: number;\n  y: number;\n  /** Distance from the press. */\n  dx: number;\n  dy: number;\n  /** Velocity in px/s (read from the last 100 ms, so a release carries the flick, not the whole drag). */\n  vx: number;\n  vy: number;\n  pointerType: string;\n  /** Shift was held (a part may offer a second mode on it: move one task alone, a finer step). */\n  shift: boolean;\n}\n\nexport interface DragHandlers {\n  /** A press landed on the element. Return false to leave it alone (a press on a nested button or link). */\n  onPress?: (point: DragPoint, event: PointerEvent) => boolean | void;\n  /** The pointer moved past the threshold: the drag begins (the press point is in `point.x - point.dx`). */\n  onStart?: (point: DragPoint) => void;\n  /** The pointer moved during a drag. `event` is the browser's own, for what a point does not carry: a pen's pressure,\n   *  the coalesced samples a fast stroke packs into one event (a drawing surface). */\n  onMove?: (point: DragPoint, event: PointerEvent) => void;\n  /** Released after a drag, with the release velocity. */\n  onEnd?: (point: DragPoint) => void;\n  /** Released without moving past the threshold: a tap or click (the part's tap route, WCAG 2.5.7). */\n  onTap?: (point: DragPoint) => void;\n  /** Escape, a lost pointer or the browser taking the gesture (a page scroll): put things back as they were. */\n  onCancel?: () => void;\n  /** A press that ended as neither a drag nor a tap: a finger that went on to scroll the page, or a pointer the browser\n   *  took before the threshold. Undo whatever `onPress` prepared. */\n  onAbandon?: () => void;\n}\n\nexport interface DragOptions {\n  /** The axis the part moves on. On touch, a drag that starts mostly across it is left to the page (a scroll). */\n  axis?: \"x\" | \"y\" | \"both\";\n  /** Pixels before a press becomes a drag: 3 for a mouse, 8 for a finger or pen unless given. */\n  threshold?: number;\n  disabled?: boolean;\n}\n\n/**\n * The pointer side of a hand on a part, shared by everything dragged, thrown or pulled (a thumb, a puck, a stub, a\n * task, a cable). It listens natively on the element, so moves never render React: the part writes positions in its\n * handlers (a spring, a transform, a custom property) and only `dragging`, which changes at the start and end, is\n * state (for `data-state`).\n *\n * A press becomes a drag past a small threshold, then the pointer is captured so the drag continues outside the\n * element; a release without moving is a tap (`onTap`), the route every drag needs for people who cannot drag. Escape,\n * `pointercancel` and a lost capture call `onCancel`, and the part restores what it showed before the press. On touch the\n * element needs a `touch-action` that leaves the other axis to the page (`pan-y` for a part dragged sideways; never\n * `none` on more than the dragged surface, which would block zoom), and a drag that starts across its axis is dropped\n * so the page can scroll.\n */\nexport function useDrag<T extends HTMLElement | SVGElement>(\n  ref: RefObject<T | null>,\n  handlers: DragHandlers,\n  { axis = \"both\", threshold, disabled = false }: DragOptions = {},\n): { dragging: boolean } {\n  const [dragging, setDragging] = useState(false);\n  const latest = useRef(handlers);\n  latest.current = handlers;\n\n  useEffect(() => {\n    // an SVG part (a chart's brush) is dragged the same way; its pointer events are typed as an HTML element's\n    const el = ref.current as HTMLElement | null;\n    if (!el || disabled) return;\n    const doc = el.ownerDocument;\n    // Escape during a drag belongs to the drag: listened on the window's capture phase, it runs before any dialog or\n    // popover listening on the document, which would otherwise also close around the cancelled drag\n    const win = doc.defaultView ?? window;\n    let active: {\n      id: number;\n      x0: number;\n      y0: number;\n      type: string;\n      started: boolean;\n      tracker: VelocityTracker;\n    } | null = null;\n\n    const point = (e: PointerEvent): DragPoint => {\n      const a = active!;\n      const [vx, vy] = a.tracker.velocity(e.timeStamp);\n      return {\n        x: e.clientX,\n        y: e.clientY,\n        dx: e.clientX - a.x0,\n        dy: e.clientY - a.y0,\n        vx,\n        vy,\n        pointerType: e.pointerType,\n        shift: e.shiftKey,\n      };\n    };\n\n    const finish = () => {\n      if (active?.started && el.hasPointerCapture?.(active.id)) el.releasePointerCapture(active.id);\n      active = null;\n      doc.removeEventListener(\"pointermove\", move);\n      doc.removeEventListener(\"pointerup\", up);\n      doc.removeEventListener(\"pointercancel\", cancel);\n      win.removeEventListener(\"keydown\", key, true);\n      doc.removeEventListener(\"selectstart\", hold, true);\n      doc.removeEventListener(\"dragstart\", hold, true);\n      el.removeEventListener(\"lostpointercapture\", lost);\n      setDragging(false);\n    };\n\n    const cancel = () => {\n      if (!active) return;\n      const started = active.started;\n      finish();\n      if (started) latest.current.onCancel?.();\n      else latest.current.onAbandon?.();\n    };\n\n    // only the part's own capture ending cancels: a finger's press is captured by the child it landed on, and moving\n    // the capture to the part fires lostpointercapture on that child, which bubbles here\n    const lost = (e: PointerEvent) => {\n      if (e.target === el && active?.started && e.pointerId === active.id) cancel();\n    };\n\n    const key = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || !active?.started) return;\n      e.preventDefault();\n      e.stopImmediatePropagation();\n      cancel();\n    };\n\n    // while the hand holds the part, the page neither selects text nor starts its own drag and drop (a selection left\n    // by an earlier drag would otherwise be dragged, and the browser cancels the pointer)\n    const hold = (e: Event) => e.preventDefault();\n\n    const move = (e: PointerEvent) => {\n      if (!active || e.pointerId !== active.id) return;\n      active.tracker.add(e.timeStamp, e.clientX, e.clientY);\n      const p = point(e);\n      if (!active.started) {\n        const limit = threshold ?? (active.type === \"mouse\" ? 3 : 8);\n        if (Math.hypot(p.dx, p.dy) < limit) return;\n        // a finger that starts across the part's axis is scrolling the page\n        const across = axis === \"x\" ? Math.abs(p.dy) > Math.abs(p.dx) : axis === \"y\" && Math.abs(p.dx) > Math.abs(p.dy);\n        if (active.type !== \"mouse\" && across) {\n          finish();\n          latest.current.onAbandon?.();\n          return;\n        }\n        active.started = true;\n        doc.getSelection()?.removeAllRanges();\n        el.setPointerCapture?.(active.id);\n        el.addEventListener(\"lostpointercapture\", lost);\n        setDragging(true);\n        latest.current.onStart?.(p);\n      }\n      latest.current.onMove?.(p, e);\n    };\n\n    const up = (e: PointerEvent) => {\n      if (!active || e.pointerId !== active.id) return;\n      active.tracker.add(e.timeStamp, e.clientX, e.clientY);\n      const p = point(e);\n      const started = active.started;\n      finish();\n      if (started) latest.current.onEnd?.(p);\n      else latest.current.onTap?.(p);\n    };\n\n    const down = (e: PointerEvent) => {\n      if (active || !e.isPrimary || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n      const tracker = new VelocityTracker();\n      tracker.add(e.timeStamp, e.clientX, e.clientY);\n      active = { id: e.pointerId, x0: e.clientX, y0: e.clientY, type: e.pointerType, started: false, tracker };\n      if (latest.current.onPress?.(point(e), e) === false) {\n        active = null;\n        return;\n      }\n      doc.addEventListener(\"pointermove\", move);\n      doc.addEventListener(\"pointerup\", up);\n      doc.addEventListener(\"pointercancel\", cancel);\n      win.addEventListener(\"keydown\", key, true);\n      doc.addEventListener(\"selectstart\", hold, true);\n      doc.addEventListener(\"dragstart\", hold, true);\n    };\n\n    el.addEventListener(\"pointerdown\", down);\n    return () => {\n      el.removeEventListener(\"pointerdown\", down);\n      if (active) finish();\n    };\n  }, [ref, axis, threshold, disabled]);\n\n  return { dragging };\n}\n"},{"path":"beamline/hooks/use-ladder.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-ladder.ts","content":"\"use client\";\n\nimport { useRef, type RefObject } from \"react\";\nimport { useWidthAs } from \"./use-content-width\";\n\n/**\n * The rank ladder: which form a part takes for the room it has. A part lists its forms from the narrowest up, each\n * with the content width it needs (`[[\"glyph\", 0], [\"short\", 180], [\"full\", 300]]`), puts the result on its root as\n * `data-form`, and its stylesheet and markup lay out each form (DESIGN-LANGUAGE \"Responsiveness\": fold by rank, a stop\n * changes structure without a jump). More room shows more; less room folds the least important first and never\n * reorders.\n *\n * A form changes only when the width clears its threshold by half of `band` (default 16 px), so a width sitting on a\n * threshold (a panel being resized slowly, a scrollbar appearing) never flickers between two forms. Read in a layout\n * effect, so the first frame already has the right form; `fallback` is the server's and the first render's form\n * (default the widest, which is what most pages show).\n */\nexport function useLadder<F extends string>(\n  ref: RefObject<HTMLElement | null>,\n  forms: readonly (readonly [F, number])[],\n  { band = 16, fallback }: { band?: number; fallback?: F } = {},\n): F {\n  const current = useRef<number | null>(null);\n  const index = useWidthAs(\n    ref,\n    (width) => {\n      const was = current.current;\n      let next = 0;\n      for (let i = 0; i < forms.length; i++) if (width >= forms[i][1]) next = i;\n      if (was !== null && next !== was) {\n        // climbing needs the width past the next threshold by half the band; dropping needs it below by half\n        while (next > was && width < forms[next][1] + band / 2) next--;\n        while (next < was && width >= forms[next + 1][1] - band / 2) next++;\n      }\n      current.current = next;\n      return next;\n    },\n    fallback === undefined\n      ? forms.length - 1\n      : Math.max(\n          0,\n          forms.findIndex(([f]) => f === fallback),\n        ),\n  );\n  return forms[Math.min(index, forms.length - 1)][0];\n}\n"},{"path":"beamline/hooks/use-motion-tokens.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-motion-tokens.ts","content":"\"use client\";\n\nimport { useMemo, type RefObject } from \"react\";\nimport { useAppearanceRoot } from \"../runtime/appearance\";\nimport { useResolvedAppearance } from \"./use-resolved-appearance\";\nimport { motionAt, motionEase, motionTokens, type MotionTokens } from \"../lib/motion-tokens\";\nimport { semanticMotion } from \"../lib/motion-source\";\n\nconst durationToken = (name: string) =>\n  name === \"exit\" ? \"motion-out\" : name === \"in\" || name === \"out\" ? `motion-${name}` : `duration-${name}`;\nconst easeToken = (name: string) => `ease-${name === \"inOut\" ? \"in-out\" : name}`;\nconst REQUEST = {\n  times: [\n    ...Object.keys(semanticMotion.duration).map(durationToken),\n    ...Object.keys(semanticMotion.spring).map((name) => `spring-${name}`),\n    ...Object.keys(semanticMotion.stagger).map((name) => `stagger-${name}`),\n    \"fade-in\",\n    \"fade-out\",\n  ],\n  easings: [...Object.keys(semanticMotion.ease).map(easeToken), \"ease-spring\", \"ease-spring-bounce\", \"ease-overshoot\"],\n  numbers: [\"motion-scale\", \"motion-look\", \"press-scale\"],\n  // timing only: the host's own type would make every control resolve differently from the page around it\n  typography: false,\n};\n\n/**\n * Resolve each timing at the actual host; omitted host uses the nearest managed root. The first/server snapshot is\n * the pure foundation default. Local CSS values, scope changes, font readiness, OS preference and explicit refresh\n * update the same consumer; no document-wide first-root fallback or persistent global listener.\n */\nexport function useMotionTokens(hostRef?: RefObject<HTMLElement | null>): MotionTokens & {\n  ready: boolean;\n  /** Reduced or none: movement, springs and staggers are instant. */\n  reduced: boolean;\n  mode: \"full\" | \"reduced\" | \"none\";\n  /** Opacity-only arrival and departure (seconds): kept under reduced, instant under none (APPEARANCE A06). */\n  fade: { in: number; out: number };\n} {\n  const root = useAppearanceRoot();\n  const scopeRef = useMemo(() => ({ current: root }), [root]);\n  const resolved = useResolvedAppearance(hostRef ?? scopeRef, REQUEST);\n  return useMemo(() => {\n    if (!resolved.ready)\n      return {\n        ...motionTokens,\n        ready: false,\n        reduced: false,\n        mode: \"full\" as const,\n        fade: { in: motionTokens.duration.in, out: motionTokens.duration.out },\n      };\n    const reduced = resolved.reducedMotion;\n    const speed = (resolved.numbers[\"motion-scale\"] ?? 1) * (resolved.numbers[\"motion-look\"] ?? 1);\n    const fade =\n      resolved.motionMode === \"none\"\n        ? { in: 0.001, out: 0.001 }\n        : {\n            in: resolved.times[\"fade-in\"] ?? semanticMotion.duration.in * speed,\n            out: resolved.times[\"fade-out\"] ?? semanticMotion.duration.out * speed,\n          };\n    const factor = reduced ? 0.001 / semanticMotion.duration.standard : speed;\n    const base = motionAt(factor);\n    const duration = Object.fromEntries(\n      Object.entries(base.duration).map(([name, fallback]) => [\n        name,\n        reduced ? 0.001 : (resolved.times[durationToken(name)] ?? fallback),\n      ]),\n    ) as MotionTokens[\"duration\"];\n    const stagger = Object.fromEntries(\n      Object.entries(base.stagger).map(([name, fallback]) => [\n        name,\n        reduced ? 0 : (resolved.times[`stagger-${name}`] ?? fallback),\n      ]),\n    ) as MotionTokens[\"stagger\"];\n    const ease = Object.fromEntries(\n      Object.entries(base.ease).map(([name, fallback]) => [\n        name,\n        motionEase(resolved.easings[easeToken(name)], fallback),\n      ]),\n    ) as MotionTokens[\"ease\"];\n    const spring = { ...base.spring };\n    for (const name of Object.keys(semanticMotion.spring) as (keyof typeof semanticMotion.spring)[])\n      spring[name] = {\n        ...base.spring[name],\n        visualDuration: reduced ? 0.001 : (resolved.times[`spring-${name}`] ?? base.spring[name].visualDuration),\n        bounce: reduced ? 0 : base.spring[name].bounce,\n      };\n    return {\n      ...base,\n      ready: true,\n      reduced,\n      mode: resolved.motionMode,\n      fade,\n      duration,\n      stagger,\n      ease,\n      spring,\n      pressScale: resolved.numbers[\"press-scale\"] ?? base.pressScale,\n      css: {\n        spring: resolved.easings[\"ease-spring\"] ?? base.css.spring,\n        springBounce: resolved.easings[\"ease-spring-bounce\"] ?? base.css.springBounce,\n        overshoot: resolved.easings[\"ease-overshoot\"] ?? base.css.overshoot,\n      },\n    };\n  }, [resolved]);\n}\n"},{"path":"beamline/hooks/use-scope-direction.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-scope-direction.ts","content":"\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState, type Ref } from \"react\";\nimport { useDirection } from \"@radix-ui/react-direction\";\nimport { useAppearance, useAppearanceRoot } from \"../runtime/appearance\";\nimport { APPEARANCE_EVENT } from \"../lib/motion-tokens\";\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n/** Preserve Radix direction in managed scopes and read the actual parent in static scopes before Radix stamps dir. */\nexport function useScopeDirection<T extends HTMLElement>(ref?: Ref<T>, explicit?: \"ltr\" | \"rtl\") {\n  const root = useRef<T | null>(null);\n  const provided = useDirection(explicit);\n  const appearance = useAppearance();\n  const managedRoot = useAppearanceRoot();\n  const [inherited, setInherited] = useState<\"ltr\" | \"rtl\">();\n  const read = useCallback(() => {\n    const around = root.current?.parentElement;\n    if (around)\n      setInherited(around.ownerDocument.defaultView?.getComputedStyle(around).direction === \"rtl\" ? \"rtl\" : \"ltr\");\n  }, []);\n  useIsoLayoutEffect(read, [read, provided, appearance]);\n  useEffect(() => {\n    const doc = root.current?.ownerDocument;\n    doc?.addEventListener(APPEARANCE_EVENT, read);\n    return () => doc?.removeEventListener(APPEARANCE_EVENT, read);\n  }, [read]);\n  const attach = useCallback(\n    (node: T | null) => {\n      root.current = node;\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            root.current = null;\n            cleanup();\n          };\n      } else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n  return [\n    attach,\n    explicit ?? (managedRoot ? provided : provided === \"rtl\" ? provided : (inherited ?? provided)),\n  ] as const;\n}\n"},{"path":"beamline/lib/axis.ts","type":"registry:file","target":"src/ui/beamline/lib/axis.ts","content":"/**\n * One axis for every part that lays values along a line: a scrubber, a replay bar, a timeline, a budget. A linear\n * scale, readable ticks, a lens that magnifies around a point, and snapping with a little pull, so the arithmetic is\n * written once and every part that magnifies or snaps does it the same way.\n *\n * The lens is the graphical fisheye of Sarkar and Brown (CHI 1992) applied on each side of a focus: the point under\n * the focus stays where it is (so the thing under the pointer never runs away from it, the failure fisheye menus are\n * known for; Gutwin, CHI 2002), neighbours spread out, and the far ends stay pinned to the ends of the track, so the\n * whole axis remains on screen. Distortion 0 is no lens.\n */\n\nexport interface Scale {\n  (value: number): number;\n  domain: readonly [number, number];\n  range: readonly [number, number];\n  /** The value at a position on the range (clamped to the domain). */\n  invert(position: number): number;\n}\n\n/** A linear map from `domain` (values) to `range` (pixels or any position). */\nexport function linearScale(domain: readonly [number, number], range: readonly [number, number]): Scale {\n  const [d0, d1] = domain;\n  const [r0, r1] = range;\n  const k = d1 === d0 ? 0 : (r1 - r0) / (d1 - d0);\n  const scale = ((v: number) => r0 + (v - d0) * k) as Scale;\n  scale.domain = domain;\n  scale.range = range;\n  scale.invert = (p: number) => {\n    if (k === 0) return d0;\n    const v = d0 + (p - r0) / k;\n    return Math.min(Math.max(v, Math.min(d0, d1)), Math.max(d0, d1));\n  };\n  return scale;\n}\n\n/** About `count` round tick values covering [min, max] (1, 2 or 5 times a power of ten apart). */\nexport function niceTicks(min: number, max: number, count: number): number[] {\n  if (!(max > min) || count < 1) return [min];\n  const raw = (max - min) / count;\n  const power = 10 ** Math.floor(Math.log10(raw));\n  const unit = [1, 2, 5, 10].map((m) => m * power).find((s) => s >= raw) ?? 10 * power;\n  const out: number[] = [];\n  // counted in whole steps from a rounded start, so 0.1 + 0.2 never prints as 0.30000000000000004\n  const first = Math.ceil(min / unit - 1e-9);\n  for (let i = first; i * unit <= max + unit * 1e-9; i++) out.push(Number((i * unit).toPrecision(12)));\n  return out;\n}\n\n/**\n * The lens: maps a position on [start, end] to where it is drawn when magnified around `focus` with `distortion`\n * (0 none; 2–4 is a strong lens). The focus and both ends stay put; positions near the focus spread apart.\n */\nexport function fisheye(position: number, focus: number, distortion: number, start: number, end: number): number {\n  if (distortion <= 0) return position;\n  const side = position < focus ? focus - start : end - focus;\n  if (side <= 0) return position;\n  const x = Math.abs(position - focus) / side;\n  const g = ((distortion + 1) * x) / (distortion * x + 1);\n  return focus + Math.sign(position - focus) * g * side;\n}\n\n/** The undistorted position drawn at `drawn` under the same lens: what a press there selects (rule 8). */\nexport function unfisheye(drawn: number, focus: number, distortion: number, start: number, end: number): number {\n  if (distortion <= 0) return drawn;\n  const side = drawn < focus ? focus - start : end - focus;\n  if (side <= 0) return drawn;\n  const g = Math.min(1, Math.abs(drawn - focus) / side);\n  return focus + Math.sign(drawn - focus) * (g / (distortion + 1 - g * distortion)) * side;\n}\n\n/** How much the lens magnifies at `position` (1 = not at all): the slope of `fisheye` there. */\nexport function magnification(position: number, focus: number, distortion: number, start: number, end: number) {\n  if (distortion <= 0) return 1;\n  const side = position < focus ? focus - start : end - focus;\n  if (side <= 0) return 1;\n  const x = Math.abs(position - focus) / side;\n  return (distortion + 1) / (distortion * x + 1) ** 2;\n}\n\n/**\n * A value pulled onto the nearest mark when it is within `pull` of it (in the value's units), so marks feel like\n * detents without trapping the hand: past the pull radius the value moves freely again.\n */\nexport function snap(value: number, marks: readonly number[], pull: number): number {\n  let best: number | null = null;\n  for (const m of marks)\n    if (Math.abs(m - value) <= pull && (best === null || Math.abs(m - value) < Math.abs(best - value))) best = m;\n  return best ?? value;\n}\n\n/** `value` rounded to the nearest multiple of `step` from `min`, without floating point dust. */\nexport function toStep(value: number, step: number, min = 0): number {\n  if (step <= 0) return value;\n  const decimals = (String(step).split(\".\")[1] ?? \"\").length;\n  return Number((Math.round((value - min) / step) * step + min).toFixed(decimals));\n}\n\n/**\n * Which labels along an axis fit without touching: `at` is each label's position in percent of the `room` (px), `widths`\n * their widths. Both ends always stay; then each label in order that clears the last one kept and the end. Ticks stay\n * for every value; only the words fold (a slider's marks, a scrubber's labels).\n */\nexport function fitLabels(at: readonly number[], widths: readonly number[], room: number, gap: number): boolean[] {\n  const n = at.length;\n  const keep = at.map((_, i) => i === 0 || i === n - 1);\n  if (n < 3) return keep;\n  const x = at.map((p) => (p / 100) * room);\n  const left = (i: number) => Math.max(0, Math.min(room - widths[i], x[i] - widths[i] / 2));\n  const right = (i: number) => left(i) + widths[i];\n  let last = 0;\n  for (let i = 1; i < n - 1; i++) {\n    if (left(i) >= right(last) + gap && right(i) + gap <= left(n - 1)) {\n      keep[i] = true;\n      last = i;\n    }\n  }\n  return keep;\n}\n\n/**\n * A label's box along the axis: centred on its point, or extending inward from an end (`edge` \"start\" or \"end\" in the\n * reading direction), clamped inside [0, room]. Px.\n */\nexport function labelBox(\n  x: number,\n  width: number,\n  room: number,\n  edge?: \"start\" | \"end\",\n  rtl = false,\n): readonly [number, number] {\n  const inward = edge === \"start\" ? (rtl ? -1 : 1) : edge === \"end\" ? (rtl ? 1 : -1) : 0;\n  const left = inward === 1 ? x : inward === -1 ? x - width : x - width / 2;\n  const start = Math.max(0, Math.min(room - width, left));\n  return [start, start + width];\n}\n\n/**\n * Which labels show when they are kept in `order`, most important first (fold by rank: the label the job needs, then\n * the ends, then the rest): each is kept if its box clears every kept box by `gap`. A label that `shown` already shows\n * needs only `gap`; one that would appear needs `gap + hold`, so a label sitting on the edge of fitting does not flicker.\n */\nexport function fitRanked(\n  boxes: readonly (readonly [number, number])[],\n  order: readonly number[],\n  gap: number,\n  shown?: readonly boolean[],\n  hold = 6,\n): boolean[] {\n  const kept: (readonly [number, number])[] = [];\n  const keep = boxes.map(() => false);\n  for (const i of order) {\n    const [l, r] = boxes[i];\n    const need = shown?.[i] ? gap : gap + hold;\n    if (kept.every(([kl, kr]) => r + need <= kl || l >= kr + need)) {\n      kept.push(boxes[i]);\n      keep[i] = true;\n    }\n  }\n  return keep;\n}\n\n/**\n * Labels that keep their order and never overlap, each as near its own anchor as the others allow: neighbours that\n * would overlap pack into one run, centred on the mean of their own places and held inside [0, room]. The answer moves\n * continuously with the anchors, so labels a lens carries slide aside for each other instead of folding and coming\n * back. `items` in drawing order (by centre); returns each one's start, or null when they cannot all fit (fold by rank\n * with `fitRanked` instead).\n */\nexport function dodge(items: readonly { centre: number; width: number }[], gap: number, room: number): number[] | null {\n  const total = items.reduce((s, it) => s + it.width, 0) + gap * Math.max(0, items.length - 1);\n  if (total > room + 0.5) return null;\n  // a run of packed labels: its members' summed wish for the run's start, and where it sits\n  type Run = { from: number; to: number; size: number; sum: number; start: number };\n  const runs: Run[] = [];\n  const place = (r: Run) => {\n    r.start = Math.min(Math.max(r.sum / (r.to - r.from), 0), room - r.size);\n  };\n  items.forEach((it, i) => {\n    const run: Run = { from: i, to: i + 1, size: it.width, sum: it.centre - it.width / 2, start: 0 };\n    place(run);\n    runs.push(run);\n    while (runs.length > 1) {\n      const b = runs[runs.length - 1];\n      const a = runs[runs.length - 2];\n      if (a.start + a.size + gap <= b.start + 1e-6) break;\n      // b's members sit after a's, so their wish for the run's start moves back by a's size and a gap\n      a.sum += b.sum - (b.to - b.from) * (a.size + gap);\n      a.size += gap + b.size;\n      a.to = b.to;\n      runs.pop();\n      place(a);\n    }\n  });\n  const out: number[] = new Array(items.length);\n  for (const r of runs) {\n    let x = r.start;\n    for (let i = r.from; i < r.to; i++) {\n      out[i] = x;\n      x += items[i].width + gap;\n    }\n  }\n  return out;\n}\n"},{"path":"beamline/lib/focus.ts","type":"registry:file","target":"src/ui/beamline/lib/focus.ts","content":"/**\n * Keeping a keyboard user's place when something disappears. Removing the element that has focus (a dismissed alert,\n * a removed chip or file, a deleted row) drops focus on <body>, and the next Tab starts at the top of the page.\n */\n\nconst TABBABLE = \"a[href], button, input:not([type=hidden]), select, textarea, [tabindex], [contenteditable=true]\";\n\nfunction tabbables(scope: Element) {\n  // a modal layer (menu, dialog) marks the rest of the page aria-hidden while it is open (data-aria-hidden): that\n  // content is still where focus should land once the layer closes\n  return [...scope.querySelectorAll<HTMLElement>(TABBABLE)].filter(\n    (e) =>\n      e.tabIndex >= 0 &&\n      !e.matches(\":disabled\") &&\n      !e.closest(\"[inert], [aria-hidden=true]:not([data-aria-hidden])\") &&\n      e.getClientRects().length > 0,\n  );\n}\n\n/** The control after `item` in reading order (outside it), or the one before it. */\nfunction neighbour(item: Element, scope: Element) {\n  const all = tabbables(scope).filter((e) => !item.contains(e));\n  return (\n    all.find((e) => item.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_FOLLOWING) ??\n    all.reverse().find((e) => item.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_PRECEDING) ??\n    null\n  );\n}\n\n/**\n * Call just before `item` is removed or hidden: if focus is inside it, focus moves to the next control after it (or\n * the one before). `scope` keeps the search inside a widget (a toolbar, a field); `fallback` is used when the scope has\n * nothing else to focus. Does nothing when focus is elsewhere, so a removal the user did not start never steals focus.\n */\nexport function focusAfterRemoval(\n  item: Element | null | undefined,\n  { scope, fallback }: { scope?: Element | null; fallback?: HTMLElement | null } = {},\n) {\n  if (!item || !item.contains(document.activeElement)) return;\n  (neighbour(item, scope ?? document.body) ?? fallback)?.focus();\n}\n\n/**\n * For an action that may or may not remove its item (the app decides, as with a row's Delete): call before running it.\n * If the item is gone afterwards and focus fell to the page, focus moves to the control that followed it.\n */\nexport function keepPlaceIfRemoved(item: Element | null | undefined, { scope }: { scope?: Element | null } = {}) {\n  if (!item) return;\n  const target = neighbour(item, scope ?? document.body);\n  const check = () => {\n    if (\n      !item.isConnected &&\n      (!document.activeElement || document.activeElement === document.body) &&\n      target?.isConnected\n    )\n      target.focus();\n  };\n  setTimeout(check, 0);\n  setTimeout(check, 400); // after an exit animation\n}\n\n/**\n * A press on a part's surface that hands focus to its control (a slider's well, a mark under its track): call from the\n * pointerdown. Focus goes to `control` without the keyboard's ring (`focusVisible: false`, so the part does not switch\n * to its keyboard look after a click). A mouse press is also followed by the browser's own focus step, which moves\n * focus to the nearest focusable ancestor or the page and leaves the arrow keys dead after the click; that step is\n * cancelled for this one press (and the press selects no text). Cancelling the pointerdown instead would also hide the\n * click from the browser, which then reads the focus as the keyboard's. A control a keyboard user already focused keeps\n * its ring.\n */\nexport function focusFromPress(\n  control: HTMLElement | null | undefined,\n  event: PointerEvent | { nativeEvent: PointerEvent },\n) {\n  if (!control) return;\n  const pointer = \"nativeEvent\" in event ? event.nativeEvent : event;\n  // asserted, not declared globally: shipped source must type-check without this repo's globals.d.ts, on the\n  // TypeScript an app already has (create-next-app installs 5.9, whose DOM lib lacks focusVisible)\n  control.focus({ preventScroll: true, focusVisible: false } as FocusOptions);\n  // the mouse's compatibility events follow the pointerdown in the same task; a finger's arrive only after a tap\n  if (pointer.pointerType !== \"mouse\") return;\n  const cancel = (e: MouseEvent) => e.preventDefault();\n  window.addEventListener(\"mousedown\", cancel, { capture: true, once: true });\n  setTimeout(() => window.removeEventListener(\"mousedown\", cancel, { capture: true }), 0);\n}\n"},{"path":"beamline/lib/frame.ts","type":"registry:file","target":"src/ui/beamline/lib/frame.ts","content":"/**\n * One frame loop for every moving part of a window: springs, replays, anything that animates under the hand or as data\n * arrives. A part asks for frames while it moves and stops asking at rest; the loop runs only while someone is\n * subscribed, so a page where nothing moves costs no frames at all (idle ≈ 0 CPU), and many parts moving at once\n * share one requestAnimationFrame instead of each running its own.\n *\n * Each subscriber gets the elapsed seconds since the previous frame, capped at 1/30 s: after a long gap (a hidden tab,\n * a busy machine) a part continues from where it was instead of jumping, and a first frame never takes a negative or\n * huge step (the frame's timestamp is the frame's start, so it can precede a `performance.now()` read at subscribe).\n */\n\ntype Tick = (dt: number, now: number) => void;\n\ninterface Loop {\n  ticks: Set<Tick>;\n  raf: number;\n  last: number;\n  /** Inside a frame: subscriptions and the last unsubscribe are settled when the frame ends. */\n  running: boolean;\n}\n\nconst loops = new WeakMap<Window, Loop>();\n\nfunction loopOf(view: Window): Loop {\n  let loop = loops.get(view);\n  if (!loop) {\n    loop = { ticks: new Set(), raf: 0, last: 0, running: false };\n    loops.set(view, loop);\n  }\n  return loop;\n}\n\n/**\n * Calls `tick(dt, now)` on every frame of `view` until the returned function is called. Subscribing from inside a\n * tick starts on the next frame; unsubscribing inside a tick takes effect at once.\n */\nexport function onFrame(tick: Tick, view: Window | null = typeof window === \"undefined\" ? null : window): () => void {\n  if (!view) return () => {};\n  const loop = loopOf(view);\n  loop.ticks.add(tick);\n  const run = (now: number) => {\n    const dt = loop.last ? Math.min(Math.max((now - loop.last) / 1000, 0), 1 / 30) : 1 / 60;\n    loop.last = now;\n    loop.running = true;\n    for (const t of [...loop.ticks]) {\n      if (!loop.ticks.has(t)) continue;\n      try {\n        t(dt, now);\n      } catch (error) {\n        // one part's error must not stop every other part's frames: drop that subscriber, report it, carry on\n        loop.ticks.delete(t);\n        queueMicrotask(() => {\n          throw error;\n        });\n      }\n    }\n    loop.running = false;\n    loop.raf = loop.ticks.size ? view.requestAnimationFrame(run) : 0;\n    if (!loop.raf) loop.last = 0;\n  };\n  if (!loop.raf && !loop.running) {\n    loop.last = 0;\n    loop.raf = view.requestAnimationFrame(run);\n  }\n  return () => {\n    loop.ticks.delete(tick);\n    if (!loop.ticks.size && loop.raf && !loop.running) {\n      view.cancelAnimationFrame(loop.raf);\n      loop.raf = 0;\n      loop.last = 0;\n    }\n  };\n}\n"},{"path":"beamline/lib/gesture.ts","type":"registry:file","target":"src/ui/beamline/lib/gesture.ts","content":"import { readMotionFactor } from \"./motion-tokens\";\n\n/**\n * The arithmetic of a hand on a part, shared by every part that is dragged, thrown or pulled (useDrag is the pointer\n * side). Three pieces, each from how real interfaces feel (Apple, \"Designing Fluid Interfaces\", WWDC 2018):\n *\n * - velocity: read from the last ~100 ms of movement, so the release carries the speed of the flick, not of the whole drag;\n * - projection: where a thrown thing would come to rest if it slid on with the look's carry (`project`), so a part\n *   can decide on release (the nearest value, step or preset to that point) instead of after an animation;\n * - give: past a limit the part follows the hand less and less (`rubberband`) instead of stopping dead, and springs back.\n *\n * How much a part carries and gives is the look's material, read once per gesture from the element (`readHand`):\n * `--m-hand-carry`, `--m-hand-give` and `--m-hand-bounce` (preset-source.css), with `--m-morph-weight` scaling how\n * long things take to settle (times a motion token's duration, which carries the speed setting). Reduced motion\n * carries nothing, gives nothing and never bounces.\n */\n\nexport interface Hand {\n  /** How far a release carries: 0 stops where the hand let go, 1 slides on freely (see `project`). */\n  carry: number;\n  /** How much a part follows the hand past a limit: 0 is a hard stop, 0.55 a phone's rubber band. */\n  give: number;\n  /** Bounce of a released held object (0 critically damped). Only for what the pointer held. */\n  bounce: number;\n  /** The look's weight (`--m-morph-weight`): multiply a motion token's duration by it. The token already carries the\n   *  speed setting, so the weight never does. */\n  weight: number;\n  /** Reduced or no motion: land at once. */\n  reduced: boolean;\n}\n\n/** The hand the defaults describe; also what a server or a test without styles gets. */\nexport const DEFAULT_HAND: Hand = { carry: 0.8, give: 0.45, bounce: 0.14, weight: 1, reduced: false };\n\nconst num = (style: CSSStyleDeclaration, name: string, fallback: number) => {\n  const v = Number.parseFloat(style.getPropertyValue(name));\n  return Number.isFinite(v) ? v : fallback;\n};\n\n/** The look's physics at `el`, read once at the start of a gesture (never per frame). */\nexport function readHand(el: Element | null | undefined): Hand {\n  if (!el || typeof getComputedStyle === \"undefined\") return DEFAULT_HAND;\n  const style = getComputedStyle(el);\n  const factor = readMotionFactor(el);\n  // the motion factor is ~0.005 under reduced motion and 1 at the foundation default\n  const reduced = factor < 0.05;\n  if (reduced) return { carry: 0, give: 0, bounce: 0, weight: 1, reduced: true };\n  return {\n    carry: num(style, \"--m-hand-carry\", DEFAULT_HAND.carry),\n    give: num(style, \"--m-hand-give\", DEFAULT_HAND.give),\n    bounce: num(style, \"--m-hand-bounce\", DEFAULT_HAND.bounce),\n    weight: num(style, \"--m-morph-weight\", 1),\n    reduced: false,\n  };\n}\n\n/** Samples of a pointer's path, kept for the last `window` ms, to read its velocity at release. */\nexport class VelocityTracker {\n  private samples: { t: number; x: number; y: number }[] = [];\n  // a declared field, not a parameter property: shipped source must also compile under erasableSyntaxOnly (Vite's\n  // TypeScript template turns it on), where TypeScript-only syntax with runtime meaning is refused\n  private readonly window: number;\n  constructor(window = 100) {\n    this.window = window;\n  }\n\n  add(t: number, x: number, y: number) {\n    this.samples.push({ t, x, y });\n    const from = t - this.window;\n    while (this.samples.length > 2 && this.samples[0].t < from) this.samples.shift();\n  }\n\n  /** Units per second over the kept window; 0 when the hand stopped before letting go (a pause of more than the window). */\n  velocity(now = this.samples.at(-1)?.t ?? 0): [number, number] {\n    const s = this.samples.filter((p) => p.t >= now - this.window);\n    if (s.length < 2) return [0, 0];\n    // least squares slope over the window: one jittery last sample does not decide the throw\n    const n = s.length;\n    const mt = s.reduce((a, p) => a + p.t, 0) / n;\n    const mx = s.reduce((a, p) => a + p.x, 0) / n;\n    const my = s.reduce((a, p) => a + p.y, 0) / n;\n    let tt = 0;\n    let tx = 0;\n    let ty = 0;\n    for (const p of s) {\n      tt += (p.t - mt) ** 2;\n      tx += (p.t - mt) * (p.x - mx);\n      ty += (p.t - mt) * (p.y - my);\n    }\n    if (tt === 0) return [0, 0];\n    return [(tx / tt) * 1000, (ty / tt) * 1000];\n  }\n\n  reset() {\n    this.samples = [];\n  }\n}\n\n/**\n * How far something released at `velocity` (units/s) slides before friction stops it, with the look's carry. `rate` is\n * the speed kept per millisecond: 0.99 (the default) suits controls on a short scale (a thumb, a puck, a cut), where a\n * flick should carry a little way; 0.998 is a phone list's deceleration, for long scrolling content.\n */\nexport function project(velocity: number, carry: number, rate = 0.99): number {\n  if (carry <= 0) return 0;\n  return (((velocity / 1000) * rate) / (1 - rate)) * carry;\n}\n\n/**\n * Where a part drawn `overshoot` units past its limit sits, given the room `extent` it moves in: it follows less and\n * less the further it goes and never reaches `extent`. `give` 0 is a hard stop.\n */\nexport function rubberband(overshoot: number, extent: number, give: number): number {\n  if (give <= 0 || extent <= 0) return 0;\n  const sign = Math.sign(overshoot);\n  const x = Math.abs(overshoot);\n  return sign * (1 - 1 / ((x * give) / extent + 1)) * extent;\n}\n\n/** `value` held inside [min, max], with the part's give past either end. */\nexport function within(value: number, min: number, max: number, extent: number, give: number): number {\n  if (value < min) return min + rubberband(value - min, extent, give);\n  if (value > max) return max + rubberband(value - max, extent, give);\n  return value;\n}\n\n/** The nearest of `stops` to `value` (a projected landing point picks its step or preset this way). */\nexport function nearest(value: number, stops: readonly number[]): number {\n  let best = stops[0];\n  for (const s of stops) if (Math.abs(s - value) < Math.abs(best - value)) best = s;\n  return best;\n}\n"},{"path":"beamline/lib/spring.ts","type":"registry:file","target":"src/ui/beamline/lib/spring.ts","content":"import { onFrame } from \"./frame\";\n\n/**\n * The spring every part that moves under the hand shares: a thumb thrown to a value, a stub that swings back, a puck\n * that settles between presets, a cut that springs back when a drag is cancelled. One model, so a look's physics\n * (`--m-morph-weight`, `--m-hand-*`, preset-source.css) and the speed setting reach all of them at once.\n *\n * A spring is described the way it should feel, not by raw physics: `response` is about the seconds it takes to settle\n * (the same scale as the motion tokens' `visualDuration`, so `spring.snappy` and a response read alike) and `bounce` is\n * 0 for critically damped, up to 1. Bounce above 0 is only for an object the pointer has been holding (DESIGN-LANGUAGE\n * \"No overshoot except dragged objects\"). Steps are closed-form, so a long frame (a busy machine, a tab coming back)\n * lands where the physics says instead of exploding, and a retarget mid-flight keeps the current velocity.\n *\n * `createSpring` drives values on the window's shared frame loop (`@/lib/frame`) only while something moves and stops at\n * rest, writing through a callback (a transform, a CSS custom property) so a moving part never renders React per frame.\n */\n\nexport interface SpringFeel {\n  /** About how long it takes to settle, in seconds (scaled by the motion speed and the look's weight by the caller). */\n  response: number;\n  /** 0 critically damped (the default) to 1; above 0 only for an object the pointer held. */\n  bounce?: number;\n}\n\n/** ω (rad/s) for a response time, on the same scale as `physical()` in motion-tokens.ts. */\nconst omega = (response: number) => (2 * Math.PI) / (Math.max(response, 0.001) * 1.2);\n\n/**\n * The response for a critically damped glide from rest that covers `distance` and comes within `restDelta` of its\n * target in `seconds`, however far it goes: a keyboard jump (Home, End, a mark) lands in the fast duration where a\n * fixed response would take longer the further it travels. Solves (1 + u)·e^(−u) = restDelta / distance for u = ωt.\n */\nexport function settleWithin(seconds: number, distance: number, restDelta: number): number {\n  const r = Math.abs(restDelta) / Math.max(Math.abs(distance), 1e-9);\n  if (r >= 1) return seconds;\n  let u = 1 - Math.log(r);\n  for (let i = 0; i < 6; i++) u -= (Math.log(1 + u) - u - Math.log(r)) / (-u / (1 + u));\n  return (2 * Math.PI * seconds) / (1.2 * u);\n}\n\n/**\n * One step of a damped spring from `x` with velocity `v` toward `target`, `dt` seconds later. Exact for any `dt`.\n * Returns the new position and velocity.\n */\nexport function stepSpring(x: number, v: number, target: number, feel: SpringFeel, dt: number): [number, number] {\n  const w = omega(feel.response);\n  const zeta = 1 - Math.min(Math.max(feel.bounce ?? 0, 0), 0.95);\n  const d = x - target;\n  if (zeta >= 1) {\n    // critically damped: x(t) = target + (d + (v + w d) t) e^(-w t)\n    const e = Math.exp(-w * dt);\n    return [target + (d + (v + w * d) * dt) * e, (v - w * (v + w * d) * dt) * e];\n  }\n  // underdamped: x(t) = target + e^(-ζwt) (d cos(w_d t) + B sin(w_d t)), B = (v + ζ w d) / w_d\n  const wd = w * Math.sqrt(1 - zeta * zeta);\n  const decay = Math.exp(-zeta * w * dt);\n  const b = (v + zeta * w * d) / wd;\n  const cos = Math.cos(wd * dt);\n  const sin = Math.sin(wd * dt);\n  const pos = decay * (d * cos + b * sin);\n  const vel = decay * ((b * wd - zeta * w * d) * cos - (d * wd + zeta * w * b) * sin);\n  return [target + pos, vel];\n}\n\nexport interface Spring {\n  /** Current values (one per axis). */\n  readonly value: readonly number[];\n  /** Current velocities in units per second. */\n  readonly velocity: readonly number[];\n  readonly moving: boolean;\n  /** Head for `target` from where it is now, keeping its velocity unless one is given (a release hands its own). */\n  /** Retarget, keeping velocity unless given one. `restDelta` changes what counts as at rest from now on (state it as\n   *  half a pixel in the value's units when the value's scale changes, a replay span growing). */\n  to(\n    target: readonly number[],\n    options?: { velocity?: readonly number[]; feel?: SpringFeel; restDelta?: number },\n  ): void;\n  /** Jump to `value` at rest (a drag following the finger 1:1, reduced motion, a reflow). */\n  set(value: readonly number[]): void;\n  /** Stop where it is, at rest. */\n  stop(): void;\n}\n\nexport interface SpringOptions {\n  feel: SpringFeel;\n  /** Receives every frame's values and velocities (write a transform or a custom property here). */\n  onFrame: (value: readonly number[], velocity: readonly number[]) => void;\n  /** Called once each time it comes to rest at its target. */\n  onRest?: (value: readonly number[]) => void;\n  /** Distance and speed under which it counts as at rest, in the value's units. Default 0.01. */\n  restDelta?: number;\n  /** The window whose frames it uses (the part's own, for frames and portals). Default the global one. */\n  view?: Window | null;\n}\n\n/** A spring over one or more axes that moves only while it has somewhere to go. */\nexport function createSpring(initial: readonly number[], options: SpringOptions): Spring {\n  const value = [...initial];\n  const velocity = initial.map(() => 0);\n  let target = [...initial];\n  let feel = options.feel;\n  let stopFrames: (() => void) | null = null;\n  let rest = options.restDelta ?? 0.01;\n  const view = options.view === undefined ? (typeof window === \"undefined\" ? null : window) : options.view;\n\n  const settled = () => value.every((x, i) => Math.abs(x - target[i]) < rest && Math.abs(velocity[i]) < rest * 10);\n\n  const halt = () => {\n    stopFrames?.();\n    stopFrames = null;\n  };\n  const tick = (dt: number) => {\n    for (let i = 0; i < value.length; i++)\n      [value[i], velocity[i]] = stepSpring(value[i], velocity[i], target[i], feel, dt);\n    if (!settled()) return options.onFrame(value, velocity);\n    for (let i = 0; i < value.length; i++) {\n      value[i] = target[i];\n      velocity[i] = 0;\n    }\n    halt();\n    options.onFrame(value, velocity);\n    options.onRest?.(value);\n  };\n  const set = (next: readonly number[]) => {\n    halt();\n    next.forEach((x, i) => {\n      value[i] = x;\n      velocity[i] = 0;\n    });\n    target = [...next];\n    options.onFrame(value, velocity);\n  };\n\n  return {\n    get value() {\n      return value;\n    },\n    get velocity() {\n      return velocity;\n    },\n    get moving() {\n      return stopFrames !== null;\n    },\n    to(next, opts) {\n      target = [...next];\n      if (opts?.velocity) opts.velocity.forEach((v, i) => (velocity[i] = v));\n      if (opts?.feel) feel = opts.feel;\n      if (opts?.restDelta) rest = opts.restDelta;\n      // no window (server, tests) or nothing left to do: land at once\n      if (!view || settled()) {\n        set(target);\n        options.onRest?.(value);\n        return;\n      }\n      stopFrames ??= onFrame(tick, view);\n    },\n    set,\n    stop() {\n      halt();\n      target = [...value];\n      velocity.fill(0);\n    },\n  };\n}\n"},{"path":"beamline/lib/tone.ts","type":"registry:file","target":"src/ui/beamline/lib/tone.ts","content":"/** The meaning tones every element speaks (Badge, Timeline, NotificationCenter, StatusMark …). */\nexport type Tone = \"neutral\" | \"accent\" | \"success\" | \"warning\" | \"danger\" | \"info\";\n\n/** A tone as a tinted plate with its ink on top: an icon disc, a status chip, an event's dot. Colour never carries the\n *  meaning alone, so the plate always holds an icon or a word. */\nexport const toneTint: Record<Tone, string> = {\n  neutral: \"bg-hover-2 text-ink-2\",\n  accent: \"bg-accent-tint text-accent-ink\",\n  success: \"bg-green-tint text-green\",\n  warning: \"bg-orange-tint text-orange\",\n  danger: \"bg-red-tint text-red\",\n  info: \"bg-blue-tint text-blue\",\n};\n\n/** A category's own hue (a file kind, a step kind, a tag): it tells kinds apart and means nothing, so pair it with a\n *  word or an icon. Meanings take a Tone instead. */\nexport type Hue = \"accent\" | \"red\" | \"orange\" | \"green\" | \"blue\" | \"violet\" | \"neutral\";\n\n/** A hue as a tinted plate with its ink on top. */\nexport const hueTint: Record<Hue, string> = {\n  accent: \"bg-accent-tint text-accent-ink\",\n  red: \"bg-red-tint text-red\",\n  orange: \"bg-orange-tint text-orange\",\n  green: \"bg-green-tint text-green\",\n  blue: \"bg-blue-tint text-blue\",\n  violet: \"bg-violet-tint text-violet\",\n  neutral: \"bg-hover-2 text-ink-2\",\n};\n\n/** A tone as ink alone, for a mark or a word that sits on the part's own surface (a tick past a limit, a readout's\n *  words). Marks drawn with `background: currentColor` take it too. */\nexport const toneInk: Record<Tone, string> = {\n  neutral: \"text-ink-2\",\n  accent: \"text-accent-ink\",\n  success: \"text-green\",\n  warning: \"text-orange\",\n  danger: \"text-red\",\n  info: \"text-blue\",\n};\n\n/** How many identity colours there are before the rest fold into \"other\". */\nexport const IDENTITY_COUNT = 6;\n\n/** The colour for \"other\" (the rest of a list past the identity colours, or \"not assigned\"): the look's neutral. */\nexport const identityOther = \"var(--pui-identity-other)\";\n\n/** Which of the six stops a set of n categories takes, the set whose closest two are furthest apart (measured on the\n *  real CSS in all nine looks, dark and light, as displayed): fewer categories never share a hue they could avoid.\n *  The closest two of a set (ΔE OK): n = 2 0.31, n = 3 0.21, n = 4 0.21, n = 5 0.15, n = 6 0.145. */\nconst IDENTITY_SETS: Record<number, number[]> = {\n  1: [1],\n  2: [2, 6],\n  3: [1, 5, 6],\n  4: [2, 3, 5, 6],\n  5: [1, 2, 4, 5, 6],\n  6: [1, 2, 3, 4, 5, 6],\n};\n\n/**\n * A category's identity colour (a CSS colour), for things the user matches across places: a donut's slice and its\n * legend row, a bar and its row, a tag on many cards. Three hues (blue, sky, violet: never red, amber or green, so never\n * good or bad) at two lightness steps; for `n` categories it takes the stops furthest apart for that many, so two, three\n * or four categories never read as one colour. Give it the item's place `i` among all `n` (the whole set, not the ones\n * a filter shows, so a colour stays with its category); past six, the sixth and later are `identityOther`: show the\n * first five and fold the rest into one \"Other\". Status, health and progress take a Tone or a ramp instead.\n */\nexport function identity(i: number, n: number = IDENTITY_COUNT): string {\n  if (n > IDENTITY_COUNT && i >= IDENTITY_COUNT - 1) return identityOther;\n  const set = IDENTITY_SETS[Math.min(Math.max(n, 1), IDENTITY_COUNT)];\n  return `var(--pui-identity-${set[i % set.length]})`;\n}\n"},{"path":"beamline/lib/utils.ts","type":"registry:file","target":"src/ui/beamline/lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\n/* The type roles (utilities.css) and the 2xs step are sizes, not text colours: without this, tailwind-merge reads\n   `text-overline` as a colour and drops it next to `text-ink-3`. A role and a size step still replace each other.\n   Likewise `font-title` is the look's title weight, not a family: read as a family it dropped `font-display` beside it,\n   and every merged page title lost the look's display face. */\nconst twMerge = extendTailwindMerge({\n  extend: {\n    classGroups: {\n      \"font-weight\": [{ font: [\"title\"] }],\n      \"font-family\": [{ font: [\"display\", \"numeric\", \"figure\"] }],\n      \"font-size\": [\n        {\n          text: [\n            \"2xs\",\n            \"display\",\n            \"title\",\n            \"heading\",\n            \"subheading\",\n            \"body\",\n            \"body-sm\",\n            \"reading\",\n            \"label\",\n            \"caption\",\n            \"overline\",\n            \"numeric\",\n            \"code\",\n          ],\n        },\n      ],\n    },\n  },\n});\n\n/** Merge class names, letting later Tailwind utilities win over earlier ones. */\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/** A small seeded random generator (LCG): the same seed gives the same sequence, for stable sample data and jitter. */\nexport function rng(seed = 1) {\n  let s = seed >>> 0 || 1;\n  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32;\n}\n\nexport const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n"}],"docs":"Scrubber is in src/ui/beamline/components/scrubber/scrubber.tsx. The source is in src/ui/beamline (ui/beamline in a project without src/). After Tailwind, import its styles/beamline.css once, then styles/look-prism.css in your app stylesheet (use relative paths). Keep the shared base before all chosen looks. Wrap your app in <UIRoot preset={prism}> with UIRoot from its runtime/appearance and prism from its looks/prism.  shadcn replaces files when asked; use the Beamline managed-source CLI from the start for three-way upgrades that preserve edits.","meta":{"free":true,"version":"0.2.0-rc.42","library":"https://beamline.io/components/scrubber"}}