{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"number-field","type":"registry:component","title":"Number field","description":"A bounded number with − and +: arrows and Page keys step, typing is read in the locale and clamped on leaving, limits can be explained, and the label can scrub.","categories":["forms"],"dependencies":["@radix-ui/react-direction@1.1.4","@radix-ui/react-label@2.1.15","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/_shared/field.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/field.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A field: label (with an optional aside at the end of its row), the control, then one message line. The gap under\n   the control belongs to the message (--pui-field-gap), so a message that arrives or leaves takes its gap with it. */\n  /* a column, not a grid: a message (or a fold) that leaves pulls its gap away with a negative margin, which a flex\n     column honours and a grid track does not (it would stop at zero and stretch the leaving part back open) */\n  .field {\n    --pui-field-gap: var(--pui-space-1-5);\n    /* positioned: a hidden label or a count's spoken words (sr-only, absolutely placed) stay inside the field */\n    position: relative;\n    display: flex;\n    min-width: 0;\n    flex-direction: column;\n    gap: var(--pui-field-gap);\n  }\n  .labelRow {\n    display: flex;\n    min-width: 0;\n    align-items: baseline;\n    gap: var(--pui-space-2);\n  }\n  .aside {\n    margin-inline-start: auto;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n  }\n\n  /* The message: one line at a time. A new line grows in to its own height (0fr to 1fr, no measuring) while the one it\n   replaces folds away above it, so the height glides from one to the other; a line that leaves with nothing after it\n   takes the gap with it. Arrival over --pui-motion-in, departure over --pui-motion-out; reduced motion keeps the fades. */\n  .messages {\n    display: grid;\n    margin-top: calc(var(--pui-field-gap, var(--pui-space-1-5)) * -1);\n  }\n  .line {\n    display: grid;\n    grid-template-rows: 1fr;\n    opacity: 1;\n    transition:\n      grid-template-rows var(--pui-motion-in) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n  }\n  @starting-style {\n    .line {\n      grid-template-rows: 0fr;\n      opacity: 0;\n    }\n  }\n  /* the line a field is first drawn with does not grow in */\n  .line[data-initial] {\n    transition: none;\n  }\n  /* a swap is one change of size: the old line folds and the new one grows over the same duration and curve, so their\n     sum (the field's height) moves straight from one to the other, with no swell between; the old words fade first */\n  .line[data-leaving] {\n    grid-template-rows: 0fr;\n    opacity: 0;\n    transition:\n      grid-template-rows var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n    pointer-events: none;\n  }\n  .messages:has(> .line[data-leaving]) > .line:not([data-leaving], [data-initial]) {\n    transition-duration: var(--pui-motion-out), var(--pui-fade-in, var(--pui-motion-in));\n  }\n  .clip {\n    min-height: 0;\n    overflow: clip;\n    overflow-clip-margin: var(--pui-space-1);\n  }\n  .message {\n    display: flex;\n    align-items: flex-start;\n    gap: var(--pui-space-1-5);\n    padding-top: var(--pui-field-gap, var(--pui-space-1-5));\n    font-size: var(--pui-text-xs);\n    line-height: 1.375;\n  }\n  .description {\n    display: block;\n    color: var(--pui-ink-3);\n  }\n  .error {\n    color: var(--pui-red);\n  }\n  .success {\n    color: var(--pui-green);\n  }\n  .icon {\n    flex-shrink: 0;\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    margin-top: 1px;\n  }\n\n  /* The count in the label's row: tabular, quiet, shown with attention (.reveal) and in words near the limit. */\n  .count {\n    font-variant-numeric: tabular-nums;\n    white-space: nowrap;\n  }\n  .count[data-state=\"near\"],\n  .count[data-state=\"full\"] {\n    color: var(--pui-orange);\n  }\n  .count[data-state=\"over\"] {\n    color: var(--pui-red);\n  }\n\n  /* The status at the end of the frame: the spinner arrives scaled in from its centre, and closes into the check. */\n  .status {\n    display: inline-grid;\n    flex-shrink: 0;\n    place-items: center;\n    animation: status-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  @keyframes status-in {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.4 * var(--pui-motion-travel, 1));\n    }\n  }\n\n  /* The one frame every text-like control sits in: input, textarea, password, search, number, the select and date\n   triggers. Its paint (fill, edge, hover, focus) is the field role of the look in force (looks.css); invalid,\n   read-only and disabled are drawn here so they read the same in every look. */\n  .frame {\n    --_field-height: var(--pui-control-height-md);\n    position: relative;\n    display: flex;\n    min-width: 0; /* a grid or flex parent may make it narrower than the input's natural width */\n    align-items: center;\n    gap: var(--pui-space-2);\n    min-height: var(--_field-height);\n    padding-inline: var(--pui-space-2-5);\n    border-radius: var(--pui-field-radius, var(--pui-radius-control));\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    cursor: text;\n    transition:\n      box-shadow var(--pui-motion-out) var(--pui-ease-default),\n      background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .frame:hover:not([data-disabled]) {\n    transition-duration: 0ms;\n  }\n  .frame[data-state=\"open\"] {\n    box-shadow: var(--m-field-edge-focus);\n  }\n  .frame:focus-within {\n    transition: none;\n  }\n  .frame[data-disabled] {\n    opacity: var(--pui-opacity-disabled);\n    cursor: not-allowed;\n  }\n  .frame[data-readonly] {\n    --m-field-bg: transparent;\n  }\n  .sm {\n    --_field-height: var(--pui-control-height-sm);\n    padding-inline: var(--pui-space-2);\n  }\n  .lg {\n    --_field-height: var(--pui-control-height-lg);\n    padding-inline: var(--pui-space-3);\n    font-size: var(--pui-text-base);\n  }\n  .multiline {\n    align-items: stretch;\n    padding-block: var(--pui-space-2);\n  }\n\n  /* The control itself: borderless, it takes the frame's look. Focus shows on the frame, so the control's own ring is off\n   (the frame's ring is the visible focus indicator, which is always present when the control has focus). */\n  .control {\n    flex: 1;\n    min-width: 0;\n    height: 100%;\n    min-height: calc(var(--_field-height) - 2px);\n    border: 0;\n    outline: none;\n    background: transparent;\n    color: inherit;\n    font: inherit;\n    letter-spacing: inherit;\n    padding: 0;\n  }\n  .control:focus-visible {\n    outline: none;\n  }\n  .control::placeholder {\n    color: var(--pui-ink-3);\n    opacity: 1;\n  }\n  .control:disabled {\n    cursor: not-allowed;\n  }\n  textarea.control {\n    min-height: 0;\n    resize: none;\n    line-height: 1.5;\n    field-sizing: content;\n  }\n  .adornment {\n    display: inline-flex;\n    align-items: center;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n  }\n  .adornment :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.frame {\n  composes: m-field nest-corner from global;\n}\n"},{"path":"beamline/components/_shared/field.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/field.tsx","content":"\"use client\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english } from \"./messages\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type Ref,\n  type TransitionEvent,\n} from \"react\";\nimport { AlertCircle, CircleCheck } from \"lucide-react\";\nimport { Label } from \"../label/label\";\nimport { Spinner } from \"../spinner/spinner\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./field.module.css\";\n\nexport type FieldSize = \"sm\" | \"md\" | \"lg\";\nexport type FieldClassNames = Partial<\n  Record<\"root\" | \"label\" | \"frame\" | \"leading\" | \"trailing\" | \"description\" | \"error\" | \"success\" | \"aside\", string>\n>;\n\nexport interface FieldOptions {\n  /** Visible label, also the accessible name. */\n  label?: ReactNode;\n  /** Keep the label for assistive tech only (dense rows, toolbars). */\n  hideLabel?: boolean;\n  /** One line of help under the control. */\n  description?: ReactNode;\n  /** The problem and its fix; replaces the description and marks the control invalid. */\n  error?: ReactNode;\n  /** The confirmation that the value works (\"@mira is available\"): replaces the description, with a check. */\n  success?: ReactNode;\n  required?: boolean;\n  size?: FieldSize;\n}\n\n/** Ids and aria wiring for one field: the control gets these spread onto it. */\nexport function useFieldIds(\n  id?: string,\n  { description, error, success }: Pick<FieldOptions, \"description\" | \"error\" | \"success\"> = {},\n) {\n  const auto = useId();\n  const controlId = id ?? `field-${auto}`;\n  // the line under the field is one of three, and the control points at whichever is there\n  const descriptionId = (success || description) && !error ? `${controlId}-description` : undefined;\n  const errorId = error ? `${controlId}-error` : undefined;\n  return {\n    controlId,\n    descriptionId,\n    errorId,\n    aria: {\n      id: controlId,\n      \"aria-describedby\": [descriptionId, errorId].filter(Boolean).join(\" \") || undefined,\n      \"aria-invalid\": error ? true : undefined,\n      \"aria-errormessage\": errorId,\n    },\n  };\n}\n\n/** Label above (with an optional aside at the end of its row: a counter, a link), control frame, then the message. */\nexport function Field({\n  label,\n  hideLabel,\n  description,\n  error,\n  success,\n  required,\n  controlId,\n  descriptionId,\n  errorId,\n  aside,\n  className,\n  classNames,\n  style,\n  slot = \"field\",\n  requiredOnControl,\n  children,\n}: FieldOptions & {\n  controlId: string;\n  descriptionId?: string;\n  errorId?: string;\n  /** At the end of the label's row: a character count, \"Forgot password?\", \"Optional\". */\n  aside?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  style?: CSSProperties;\n  slot?: string;\n  /** The control carries `required` itself, so the label does not repeat it in the control's name. */\n  requiredOnControl?: boolean;\n  children: ReactNode;\n}) {\n  const labelNode = label != null && (\n    <Label\n      htmlFor={controlId}\n      required={required}\n      requiredOnControl={requiredOnControl}\n      className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n    >\n      {label}\n    </Label>\n  );\n  return (\n    <div\n      data-slot={slot}\n      data-invalid={error ? \"\" : undefined}\n      style={style}\n      className={cn(styles.field, aside != null && \"reveal-host\", classNames?.root, className)}\n    >\n      {aside != null ? (\n        <div data-slot=\"field-label-row\" className={styles.labelRow}>\n          {labelNode}\n          <span data-slot=\"field-aside\" className={cn(styles.aside, classNames?.aside)}>\n            {aside}\n          </span>\n        </div>\n      ) : (\n        labelNode\n      )}\n      {children}\n      <FieldMessage\n        error={error}\n        success={success}\n        description={description}\n        errorId={errorId}\n        descriptionId={descriptionId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n\ntype Tone = \"error\" | \"success\" | \"description\";\ninterface Line {\n  key: string;\n  tone: Tone;\n  content: ReactNode;\n}\nconst ICON = { error: AlertCircle, success: CircleCheck } as const;\n\n/**\n * The line under a field: its problem when there is one, else its confirmation, else its help. Shared by every field\n * and choice group. A change of line keeps its path: the new line grows in under the control while the old one folds\n * away above it, so what sits below glides instead of jumping, and a quick reversal turns around from where it is.\n * The leaving line is a still copy, hidden from assistive tech and inert; ids and aria point only at the line that is\n * there. It is gone when its fold ends, and by the clock as well: a line that leaves in the frame it arrived has no\n * fold to run, so no end is reported.\n */\nexport function FieldMessage({\n  error,\n  success,\n  description,\n  errorId,\n  descriptionId,\n  slot = \"field\",\n  classNames,\n}: Pick<FieldOptions, \"error\" | \"description\" | \"success\"> & {\n  errorId?: string;\n  descriptionId?: string;\n  /** The slot names' prefix: \"checkbox\" names the lines checkbox-error, checkbox-description. */\n  slot?: string;\n  classNames?: Pick<FieldClassNames, \"error\" | \"description\" | \"success\">;\n}) {\n  const tone: Tone | null = error ? \"error\" : success ? \"success\" : description ? \"description\" : null;\n  const content = error || success || description;\n  // a new text is a new line (one problem replacing another also folds and grows); rich content changes in place\n  const text = typeof content === \"string\" || typeof content === \"number\" ? String(content) : \"\";\n  const current: Line | null = tone ? { key: `${tone}\\u0000${text}`, tone, content } : null;\n  const [last, setLast] = useState<Line | null>(current);\n  const [leaving, setLeaving] = useState<Line | null>(null);\n  // the line a field is drawn with is simply there; only a change of line moves\n  const [changed, setChanged] = useState(false);\n  if ((last?.key ?? null) !== (current?.key ?? null)) {\n    // the line that was there leaves (one at a time: a line already leaving is gone at once)\n    setLeaving(last && last.key !== current?.key ? last : null);\n    setLast(current);\n    setChanged(true);\n  }\n  const box = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    if (!leaving) return;\n    // the clock is the leaving line's own longest transition, read once as it starts to leave (the fold's end event\n    // usually comes first), so a field resolves no motion tokens of its own\n    const el = box.current?.querySelector<HTMLElement>(\"[data-leaving]\");\n    const longest = el\n      ? Math.max(\n          0,\n          ...getComputedStyle(el)\n            .transitionDuration.split(\",\")\n            .map((t) => parseFloat(t) * (t.trim().endsWith(\"ms\") ? 1 : 1000) || 0),\n        )\n      : 0;\n    const id = window.setTimeout(() => setLeaving((l) => (l === leaving ? null : l)), longest + 250);\n    return () => window.clearTimeout(id);\n  }, [leaving]);\n  if (!current && !leaving) return null;\n  const shown = leaving && leaving.key === current?.key ? null : leaving;\n  const ids = { error: errorId, success: descriptionId, description: descriptionId };\n  const line = (l: Line, gone: boolean) => {\n    const Icon = l.tone === \"description\" ? null : ICON[l.tone];\n    return (\n      <div\n        key={l.key}\n        data-slot=\"field-message-line\"\n        data-leaving={gone ? \"\" : undefined}\n        data-initial={changed ? undefined : \"\"}\n        aria-hidden={gone || undefined}\n        inert={gone || undefined}\n        className={styles.line}\n        onTransitionEnd={\n          gone\n            ? (e: TransitionEvent<HTMLDivElement>) => {\n                if (e.target === e.currentTarget && e.propertyName === \"grid-template-rows\") setLeaving(null);\n              }\n            : undefined\n        }\n      >\n        <div className={styles.clip}>\n          <p\n            id={gone ? undefined : ids[l.tone]}\n            data-slot={`${slot}-${l.tone}`}\n            className={cn(styles.message, styles[l.tone], classNames?.[l.tone])}\n          >\n            {Icon && <Icon aria-hidden className={styles.icon} />}\n            {l.content}\n          </p>\n        </div>\n      </div>\n    );\n  };\n  return (\n    <div ref={box} data-slot=\"field-message\" className={styles.messages}>\n      {shown && line(shown, true)}\n      {current && line(current, false)}\n    </div>\n  );\n}\n\n/** What the end of a text field says about its work: a spinner while a value is checked, a drawn check when it works. */\nexport type FieldStatus = \"busy\" | \"success\";\n\n/** The frame a text-like control sits in; clicking anywhere on it focuses the control. */\nexport function FieldFrame({\n  size = \"md\",\n  invalid,\n  disabled,\n  readOnly,\n  multiline,\n  status,\n  statusLabel: statusLabelProp,\n  leading,\n  trailing,\n  className,\n  classNames,\n  children,\n  onPointerDown,\n  ref,\n}: {\n  size?: FieldSize;\n  invalid?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  multiline?: boolean;\n  /** busy: a spinner at the end while the value is checked or saved; success: the ring closes into a check. */\n  status?: FieldStatus;\n  /** What the spinner says while busy (\"Checking\", \"Saving\"). */\n  statusLabel?: string;\n  leading?: ReactNode;\n  trailing?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  children: ReactNode;\n  onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void;\n  /** The frame itself, for a control that lays out by the frame's width (useWidthAs). */\n  ref?: Ref<HTMLDivElement>;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const statusLabel = statusLabelProp === undefined ? i18n(\"statusLabel\") : statusLabelProp;\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"field-control\"\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-readonly={readOnly ? \"\" : undefined}\n      data-status={status}\n      className={cn(styles.frame, styles[size], multiline && styles.multiline, classNames?.frame, className)}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        // a click on the frame's padding or an adornment that is not itself interactive focuses the control\n        const t = e.target as HTMLElement;\n        if (t === e.currentTarget || (t.closest(\"[data-adornment]\") && !t.closest(\"button, a, input, [role=button]\"))) {\n          e.preventDefault();\n          e.currentTarget.querySelector<HTMLElement>(\"input, textarea\")?.focus();\n        }\n      }}\n    >\n      {leading && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"leading\"\n          className={cn(styles.adornment, classNames?.leading)}\n        >\n          {leading}\n        </span>\n      )}\n      {children}\n      {status && (\n        // one ring for both: it spins while the value is checked and closes into the check when it works\n        <span data-slot=\"field-status\" data-adornment=\"status\" className={styles.status}>\n          <Spinner size=\"sm\" state={status === \"busy\" ? \"loading\" : \"success\"} label={statusLabel} />\n        </span>\n      )}\n      {trailing && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"trailing\"\n          className={cn(styles.adornment, classNames?.trailing)}\n        >\n          {trailing}\n        </span>\n      )}\n    </div>\n  );\n}\n\n/**\n * A character count for the label's row (Field `aside`): quiet (\"58/140\") and shown while the field has attention,\n * then always shown and in words as the limit nears (\"12 left\", \"Limit reached\", \"3 over\"), so it never competes with\n * the label at rest. Assistive tech hears it only when it crosses into those words, not at every key.\n */\nexport function FieldCount({\n  length,\n  max,\n  near: nearAt,\n  slot = \"field-count\",\n  className,\n}: {\n  length: number;\n  max: number;\n  /** How many left turns it into words: by default 10 % of the limit, at least 5 (characters); 1 suits a few tags. */\n  near?: number;\n  slot?: string;\n  className?: string;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const left = max - length;\n  const near = left <= (nearAt ?? Math.max(5, Math.ceil(max * 0.1)));\n  const state = left < 0 ? \"over\" : left === 0 ? \"full\" : near ? \"near\" : \"room\";\n  const words =\n    state === \"over\"\n      ? i18n(\"fieldOver\", { count: -left })\n      : state === \"full\"\n        ? i18n(\"fieldFull\")\n        : i18n(\"fieldLeft\", { count: left });\n  return (\n    <span\n      data-slot={slot}\n      data-state={state}\n      data-shown={state === \"room\" ? undefined : \"\"}\n      className={cn(\"reveal\", styles.count, className)}\n    >\n      <span aria-hidden>{state === \"room\" ? `${i18n.number(length)}/${i18n.number(max)}` : words}</span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {state === \"room\" ? \"\" : words}\n      </span>\n    </span>\n  );\n}\n\n/** The class a native input or textarea needs inside a FieldFrame. */\nexport const fieldControlClass = styles.control;\n\n/** What a choice group (segmented control, radio cards) spreads onto its own root when it is a form field. */\nexport interface ChoiceFieldAria {\n  \"aria-labelledby\": string;\n  \"aria-describedby\"?: string;\n  \"aria-invalid\"?: true;\n}\n\n/**\n * Whether a choice control (segmented control, radio cards, chip group) is laid out as a form field, its label shown\n * above like every field's: when asked (`field`), when it has a description, error or required mark, and whenever its\n * label is not hidden. A view switch or a quick filter passes `hideLabel`: its label stays for assistive tech only.\n * (Trials t16 and t19: agents wrote <SegmentedControl label=\"Catering needed\" …> and the question never showed.)\n */\nexport function choiceIsField(\n  field: boolean | undefined,\n  label: ReactNode,\n  hideLabel: boolean | undefined,\n  marked: boolean,\n) {\n  return field ?? (marked || (label != null && label !== \"\" && !hideLabel));\n}\n\n/**\n * A choice group as a form field: the visible label above (with the required marker), the group, then its help or its\n * problem — the same frame and message every text field has. Groups that name themselves only for assistive tech\n * (a view switch in a toolbar) do not use it.\n */\nexport function ChoiceField({\n  slot,\n  label,\n  hideLabel,\n  required,\n  description,\n  error,\n  className,\n  style,\n  classNames,\n  requiredOnControl,\n  children,\n}: Omit<FieldOptions, \"size\" | \"success\"> & {\n  slot: string;\n  /** The group carries `aria-required` itself (a radiogroup), so the label does not repeat it in the group's name. */\n  requiredOnControl?: boolean;\n  className?: string;\n  style?: CSSProperties;\n  classNames?: Pick<FieldClassNames, \"label\" | \"description\" | \"error\">;\n  children: (aria: ChoiceFieldAria) => ReactNode;\n}) {\n  const auto = useId();\n  const labelId = `choice-${auto}-label`;\n  const messageId = error || description ? `choice-${auto}-message` : undefined;\n  return (\n    <div data-slot={slot} data-invalid={error ? \"\" : undefined} style={style} className={cn(styles.field, className)}>\n      <Label\n        id={labelId}\n        required={required}\n        requiredOnControl={requiredOnControl}\n        className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n      >\n        {label}\n      </Label>\n      {children({\n        \"aria-labelledby\": labelId,\n        ...(messageId ? { \"aria-describedby\": messageId } : {}),\n        ...(error ? { \"aria-invalid\": true as const } : {}),\n      })}\n      <FieldMessage\n        error={error}\n        description={description}\n        errorId={messageId}\n        descriptionId={messageId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n"},{"path":"beamline/components/_shared/messages.ts","type":"registry:file","target":"src/ui/beamline/components/_shared/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy for this helper only. */\nexport const english = {\n  results: {\n    one: \"{count} result\",\n    other: \"{count} results\",\n  },\n  noMatch: \"No match for {query}\",\n  fieldOver: \"{count} over\",\n  fieldFull: \"Limit reached\",\n  fieldLeft: \"{count} left\",\n  foldMore: \"{list} and {count} more\",\n  foldShow: \"Show {count} more {noun}: {list}\",\n  statusLabel: \"Checking\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/_shared/roll.ts","type":"registry:file","target":"src/ui/beamline/components/_shared/roll.ts","content":"/**\n * Rolls the text of a field or any text part from one value to the next (number field steps, a time segment): the old text leaves up and the new one\n * arrives from below for an increase, the other way for a decrease. The field's value changes at once; a still copy of\n * each text is drawn over the field for the roll and removed after, so typing and the caret are never touched.\n */\nconst running = new WeakMap<HTMLElement, () => void>();\n\nexport function rollText(\n  input: HTMLElement | null,\n  from: string,\n  to: string,\n  direction: 1 | -1,\n  { duration, easing }: { duration: number; easing: string },\n) {\n  if (!input || from === to || duration < 2 || !input.offsetParent) return;\n  running.get(input)?.();\n  const host = input.offsetParent as HTMLElement;\n  const cs = getComputedStyle(input);\n  const box = document.createElement(\"span\");\n  box.setAttribute(\"aria-hidden\", \"true\");\n  Object.assign(box.style, {\n    position: \"absolute\",\n    left: `${input.offsetLeft}px`,\n    top: `${input.offsetTop}px`,\n    width: `${input.offsetWidth}px`,\n    height: `${input.offsetHeight}px`,\n    overflow: \"hidden\",\n    pointerEvents: \"none\",\n  });\n  const line = (text: string) => {\n    const s = document.createElement(\"span\");\n    s.textContent = text;\n    Object.assign(s.style, {\n      position: \"absolute\",\n      inset: \"0\",\n      display: \"flex\",\n      alignItems: \"center\",\n      justifyContent:\n        cs.textAlign === \"right\" || cs.textAlign === \"end\"\n          ? \"flex-end\"\n          : cs.textAlign === \"center\"\n            ? \"center\"\n            : \"flex-start\",\n      paddingInline: `${cs.paddingLeft} ${cs.paddingRight}`,\n      font: cs.font,\n      fontVariantNumeric: cs.fontVariantNumeric,\n      letterSpacing: cs.letterSpacing,\n      color: cs.color,\n      whiteSpace: \"pre\",\n    });\n    return s;\n  };\n  const old = line(from),\n    next = line(to);\n  box.append(old, next);\n  host.appendChild(box);\n  const color = input.style.color;\n  input.style.color = \"transparent\";\n  const shift = 0.7 * direction;\n  const a = old.animate(\n    [\n      { transform: \"none\", opacity: 1 },\n      { transform: `translateY(${-shift * 100}%)`, opacity: 0 },\n    ],\n    { duration, easing },\n  );\n  next.animate(\n    [\n      { transform: `translateY(${shift * 100}%)`, opacity: 0 },\n      { transform: \"none\", opacity: 1 },\n    ],\n    { duration, easing },\n  );\n  const done = () => {\n    running.delete(input);\n    box.remove();\n    input.style.color = color;\n  };\n  running.set(input, done);\n  a.finished.then(done, done);\n}\n"},{"path":"beamline/components/label/label.tsx","type":"registry:file","target":"src/ui/beamline/components/label/label.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport type { ComponentProps } from \"react\";\nimport * as RadixLabel from \"@radix-ui/react-label\";\nimport { cn } from \"../../lib/utils\";\n\nexport interface LabelProps extends ComponentProps<typeof RadixLabel.Root> {\n  /** Shows the marker and announces \"required\". */\n  required?: boolean;\n  /** The control says \"required\" itself (a native required input, a radiogroup with aria-required): the label shows\n   * the marker only, so the control's name stays the label's words and the state is announced once. */\n  requiredOnControl?: boolean;\n}\n\n/** The text label tied to a form control. It dims with a disabled control next to it (peer) or inside it (group). */\nexport function Label({ required = false, requiredOnControl = false, className, children, ...props }: LabelProps) {\n  const i18n = useMessages(\"label\", englishMessages);\n\n  return (\n    <RadixLabel.Root\n      data-slot=\"label\"\n      {...props}\n      className={cn(\n        \"inline-flex items-center gap-[var(--pui-space-1)] text-sm leading-tight font-medium text-ink select-none group-data-[disabled]:opacity-[var(--pui-opacity-disabled)] peer-disabled:opacity-[var(--pui-opacity-disabled)]\",\n        className,\n      )}\n    >\n      {children}\n      {required && (\n        <>\n          <span aria-hidden className=\"text-red\">\n            *\n          </span>\n          {!requiredOnControl && <span className=\"sr-only\">{i18n(\"required\")}</span>}\n        </>\n      )}\n    </RadixLabel.Root>\n  );\n}\n\nexport default Label;\n"},{"path":"beamline/components/label/messages.ts","type":"registry:file","target":"src/ui/beamline/components/label/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  required: \"required\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/number-field/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/number-field/defaults.ts","content":"/** Pure allowed presentation defaults for NumberField; no component or runtime imports. */\nexport interface NumberFieldDefaults {\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/number-field/messages.ts","type":"registry:file","target":"src/ui/beamline/components/number-field/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  useTheSuggested: \"Use the suggested {value}\",\n  use: \"Use\",\n  decrease: \"Decrease\",\n  increase: \"Increase\",\n  theLowestIs: \"The lowest is {value}.\",\n  theHighestIs: \"The highest is {value}.\",\n  suggestedEnterUsesIt: \"Suggested {value}; Enter uses it\",\n  useConverted: \"Use {value}\",\n  convertsTo: \"{typed} is {value}; Enter uses it\",\n  convertsRounded: \"{typed} is {value}; the field takes {used}, Enter uses it\",\n  convertsOver: \"{typed} is {value}, over the highest, {limit}; Enter uses {limit}\",\n  convertsUnder: \"{typed} is {value}, under the lowest, {limit}; Enter uses {limit}\",\n  overLimit: \"over {limit}\",\n  underLimit: \"under {limit}\",\n  /** While editing a field that takes other units (and has no description of its own). */\n  alsoIn: \"Also in {units}\",\n  unknownUnit: \"Use {units}\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/number-field/number-field.module.css","type":"registry:file","target":"src/ui/beamline/components/number-field/number-field.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* The − and + at the end of the frame: a press steps at once (and repeats while held), acknowledged at 0 ms. */\n  /* a suggested value waits in the empty field: written in the second ink (readable, not yet the value), with Use\n     beside it */\n  [data-suggested]::placeholder {\n    color: var(--pui-ink-2);\n    opacity: 1;\n  }\n  .use {\n    height: var(--pui-space-6);\n    padding-inline: var(--pui-space-1-5);\n    border-radius: var(--pui-radius-chip);\n    font-size: var(--pui-text-xs);\n    font-weight: var(--pui-weight-medium);\n    color: var(--pui-ink-2);\n    user-select: none;\n    transition:\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .use:hover {\n    background: var(--pui-hover);\n    color: var(--pui-ink);\n    transition-duration: 0ms;\n  }\n  /* the conversion reads as part of the number (\"= 5,000\" before the unit), so it keeps the value's ink */\n  .convert {\n    display: inline-flex;\n    align-items: center;\n    gap: var(--pui-space-1);\n    color: var(--pui-ink);\n    background: var(--pui-hover);\n  }\n  .convertLimit {\n    color: var(--pui-ink-3);\n    font-weight: var(--pui-weight-regular);\n  }\n  .convert[data-beyond] {\n    color: var(--pui-ink-2);\n  }\n  .use:active {\n    scale: var(--pui-press-scale);\n  }\n  .stepper {\n    display: grid;\n    width: var(--pui-space-6);\n    height: var(--pui-space-6);\n    place-items: center;\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink-3);\n    touch-action: none;\n    user-select: none;\n    transition:\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      scale var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .stepper:hover {\n    background: var(--pui-hover);\n    color: var(--pui-ink);\n    transition-duration: 0ms;\n  }\n  .stepper:active {\n    scale: var(--pui-press-scale);\n    transition-duration: 0ms;\n  }\n  .stepper:disabled {\n    opacity: var(--pui-opacity-disabled);\n    pointer-events: none;\n  }\n  .stepper :where(svg) {\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n  }\n\n  /* scrub: the label is a handle; its sideways mark is the shared reveal (with attention, always on touch) */\n  .scrub {\n    display: inline-flex;\n    align-items: center;\n    gap: var(--pui-space-1);\n    cursor: ew-resize;\n    /* dragged sideways: the page keeps vertical scrolling over the label (useDrag drops a drag that starts vertically) */\n    touch-action: pan-y;\n    user-select: none;\n  }\n  .scrubMark {\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    color: var(--pui-ink-3);\n  }\n}\n"},{"path":"beamline/components/number-field/number-field.tsx","type":"registry:file","target":"src/ui/beamline/components/number-field/number-field.tsx","content":"\"use client\";\nimport { listFormatter, numberFormatter, useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useLocale } from \"../../runtime/localization\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { NumberFieldDefaults } from \"./defaults\";\nimport { cssEase } from \"../../lib/motion-tokens\";\nexport type { NumberFieldDefaults } from \"./defaults\";\n\nimport {\n  useEffect,\n  useMemo,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type Ref,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\nimport { Minus, MoveHorizontal, Plus } from \"lucide-react\";\nimport {\n  Field,\n  FieldFrame,\n  fieldControlClass,\n  useFieldIds,\n  type FieldClassNames,\n  type FieldSize,\n} from \"../_shared/field\";\nimport { parseLocaleNumber } from \"../../lib/number\";\nimport { rollText } from \"../_shared/roll\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useWidthAs } from \"../../hooks/use-content-width\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport { useDrag } from \"../../hooks/use-drag\";\nimport styles from \"./number-field.module.css\";\n\nexport type NumberFieldSize = FieldSize;\nexport type NumberFieldAffix = ReactNode;\n\nexport interface NumberFieldProps {\n  label: ReactNode;\n  hideLabel?: boolean;\n  value?: number | null;\n  defaultValue?: number | null;\n  onValueChange?: (value: number | null) => void;\n  /** The number as it is typed, before the field is left (read in the locale and its units, not yet clamped; null\n   *  while empty), for words beside the field that follow the typing. Unreadable text is skipped. Commits still go to\n   *  onValueChange. */\n  onValueInput?: (value: number | null) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Shift+arrow and Page Up/Down. Default 10 × step. */\n  largeStep?: number;\n  description?: ReactNode;\n  error?: ReactNode;\n  /** Before the number: \"$\". */\n  prefix?: NumberFieldAffix;\n  /** After the number: \"×\", \"seats\". */\n  suffix?: NumberFieldAffix;\n  /** Drag the label sideways to change the value. */\n  scrub?: boolean;\n  /** false: no − and + at any width, for dense rows of values (a set's weight, reps and rest); arrows, scrub and\n   *  typing still change it. Default: the steppers show while the frame has room for them beside the value. */\n  steppers?: boolean;\n  locale?: string;\n  /** Intl options for the number shown (`{ minimumFractionDigits: 1 }`); the decimals shown otherwise follow `step`. */\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Shown in the empty field. */\n  placeholder?: string;\n  /** A proposed value awaiting confirmation (a machine estimate): while the field is empty it shows as the placeholder\n   *  with Use beside it; Use, Enter or a step takes it, typing replaces it. Never sent as the value until taken. */\n  suggested?: number | null;\n  /** Say when a limit is reached (\"Up to 500 seats\"); true uses a default sentence. */\n  limitHint?: boolean | ((edge: \"min\" | \"max\", limit: number) => string);\n  /** Other units a typed value may carry, each as how many of the field's own unit (its `suffix`) one of it is:\n   *  `{ mL: 1000, L: 1e6 }` in a µL field. Typing \"5 mL\" offers \"= 5,000\" beside the number; Enter, leaving the field\n   *  or the offer takes it, and the digits roll to the converted value. The value is rounded to the decimals the field shows, as any typed number is\n   *  (shown \"≈\", the exact amount said) and held at the limits (the true amount shown \"over 20,000\"); a unit it does not\n   *  know keeps the value and names the units it takes. Without `units` letters are ignored (\"5 mL\" reads as 5). */\n  units?: Record<string, number>;\n  size?: NumberFieldSize;\n  disabled?: boolean;\n  required?: boolean;\n  name?: string;\n  id?: string;\n  className?: string;\n  /** Classes for documented field parts; root style does not replace native behavior. */\n  classNames?: FieldClassNames & Partial<Record<\"input\" | \"decrease\" | \"increase\" | \"use\" | \"convert\", string>>;\n  style?: CSSProperties;\n  ref?: Ref<HTMLInputElement>;\n}\n\nconst decimals = (n: number) => (String(n).split(\".\")[1] ?? \"\").length;\n// holding a stepper: one step at once, then after a pause a run that quickens to a floor (pacing, not motion)\nconst HOLD_PAUSE = 400;\nconst HOLD_FIRST = 120;\nconst HOLD_FLOOR = 40;\n// the room the steppers need (two 24px buttons and their gaps) and a tabular digit's width, at the default type size\nconst STEPPERS = 60;\nconst DIGIT = 8;\n\n/**\n * A bounded number with − and + beside it. ↑ ↓ step, Shift or Page Up/Down take large steps, Home and End go to the\n * limits; typing is free and the value is read (in the locale) and clamped when the field is left. With `scrub` the\n * label drags the value.\n */\nexport function NumberField({\n  label,\n  hideLabel,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  onValueInput,\n  min = -Infinity,\n  max = Infinity,\n  step = 1,\n  largeStep,\n  description,\n  error,\n  prefix,\n  suffix,\n  scrub,\n  steppers = true,\n  locale: localeProp,\n  formatOptions,\n  placeholder,\n  suggested,\n  limitHint,\n  units,\n  size: sizeProp,\n  disabled,\n  required,\n  name,\n  id,\n  className,\n  classNames,\n  style,\n  ref,\n}: NumberFieldProps) {\n  const i18n = useMessages(\"number-field\", englishMessages);\n\n  const locale = useLocale(localeProp);\n\n  const scopeDefaults = useComponentDefaults<NumberFieldDefaults>(\"number-field\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n\n  // a commit that lands on the same number is not a change, so the hook holds the value and the report is guarded\n  const [value, setValue] = useControllable<number | null>(valueProp, defaultValue);\n  const fmt = useMemo(\n    () => numberFormatter(locale, { maximumFractionDigits: Math.max(decimals(step), 0), ...formatOptions }),\n    [locale, step, formatOptions],\n  );\n  const [text, setText] = useState(value == null ? \"\" : fmt.format(value));\n  const [editing, setEditing] = useState(false);\n  const [edge, setEdge] = useState<\"min\" | \"max\" | null>(null);\n  const scrubbing = useRef<{ from: number } | null>(null);\n  const scrubRef = useRef<HTMLSpanElement>(null);\n  const field = useRef<HTMLInputElement>(null);\n  const attach = useComposedRef(field, ref);\n  const frame = useRef<HTMLDivElement>(null);\n  // the value outranks the steppers: in a frame too narrow for both, the steppers fold away (arrows, scrub and typing\n  // still change it) and come back as the room grows\n  const roomy = useWidthAs(frame, (w) => w >= STEPPERS + (Math.max(text.length, 3) + 1) * DIGIT, true, text.length);\n  const latest = useRef(value);\n  useLayoutEffect(() => {\n    latest.current = value;\n  });\n  const holding = useRef<{ timer: number; stop: () => void } | null>(null);\n  // a run still going when the field leaves the page stops with it\n  useEffect(() => {\n    const run = holding;\n    return () => run.current?.stop();\n  }, []);\n  const hint =\n    edge && limitHint\n      ? typeof limitHint === \"function\"\n        ? limitHint(edge, edge === \"min\" ? min : max)\n        : edge === \"min\"\n          ? i18n(\"theLowestIs\", { value: fmt.format(min) })\n          : i18n(\"theHighestIs\", { value: fmt.format(max) })\n      : undefined;\n  // words the field could not read lead, then a limit reached, then (while editing) the units it also takes; the input\n  // is described by whichever shows\n  const [unknownUnit, setUnknownUnit] = useState(false);\n  const own = typeof suffix === \"string\" ? suffix : undefined;\n  const unitList = units\n    ? listFormatter(locale, { type: \"disjunction\" }).format([...Object.keys(units), ...(own ? [own] : [])])\n    : \"\";\n  const fieldDescription = unknownUnit\n    ? i18n(\"unknownUnit\", { units: unitList })\n    : (hint ?? (units && editing && !description ? i18n(\"alsoIn\", { units: unitList }) : description));\n  const { controlId, descriptionId, errorId, aria } = useFieldIds(id, { description: fieldDescription, error });\n  useEffect(() => {\n    if (!editing) setText(value == null ? \"\" : fmt.format(value));\n  }, [value, editing, fmt]);\n\n  const clamp = (n: number) => Math.min(max, Math.max(min, Number(n.toFixed(Math.max(decimals(step), 6)))));\n  const commit = (n: number | null) => {\n    const next = n === null ? null : clamp(n);\n    // at or past a limit, judged against the limit itself (22.1 + 0.1 is 22.200000000000003 in floating point, which\n    // the clamp tidies; that tidying is not a limit)\n    setEdge(\n      next === null || n === null ? null : n <= min || next === min ? \"min\" : n >= max || next === max ? \"max\" : null,\n    );\n    setValue(next);\n    if (next !== value) onValueChange?.(next);\n    setText(next == null ? \"\" : fmt.format(next));\n  };\n  // a suggestion stands in the empty field until it is taken (Use, Enter, a step) or replaced by typing\n  const offer = value == null && suggested != null && Number.isFinite(suggested) ? clamp(suggested) : null;\n  const suggesting = offer !== null && !text.trim();\n  // a number typed with another unit (\"5 mL\" in a µL field) is read in that unit and converted to the field's\n  const typedUnit = (s: string): { n: number; number: string; unit: string; per: number } | null => {\n    if (!units) return null;\n    const m = s.trim().match(/^([+\\-\\u2212]?[\\p{Nd}.,'\\s\\u202f\\u00a0]*\\p{Nd}(?:e[+\\-\\u2212]?\\d+)?)\\s*(\\S.*)$/iu);\n    if (!m) return null;\n    // the Greek mu (U+03BC) and the micro sign (U+00B5) look alike; either names micro\n    const token = m[2].trim().replace(/\\u03bc/g, \"\\u00b5\");\n    const table: Record<string, number> = own ? { ...units, [own]: 1 } : units;\n    let unit = token in table ? token : undefined;\n    if (!unit) {\n      // case is forgiven only where it names one unit (\"ml\" is mL; \"m\" never guesses between mL and ML), and a \"u\"\n      // stands for \"µ\", which most keyboards lack (\"5 ul\" is 5 µL)\n      for (const tok of [token, token.replace(/^u/i, \"µ\")]) {\n        const near = Object.keys(table).filter((k) => k.toLowerCase() === tok.toLowerCase());\n        if (near.length === 1) {\n          unit = near[0];\n          break;\n        }\n      }\n    }\n    const n = parseLocaleNumber(m[1], locale);\n    if (!unit || n === null || !Number.isFinite(table[unit])) return null;\n    // the typed number is said as typed: the field's precision belongs to the converted value (\"1.5 h\", not \"2 h\")\n    return { n, number: m[1].trim(), unit, per: table[unit] };\n  };\n  const typed = editing ? typedUnit(text) : null;\n  // a conversion in the field's precision: what Enter will use (the exact amount is said beside it when they differ)\n  // the decimals the field shows: what it holds is what it shows, whichever unit typed it (5.55 µL and 5550 nL both\n  // hold the 5.6 they show); rounded in decimal, after dropping binary noise, so 5.55 goes up as written\n  const places = fmt.resolvedOptions().maximumFractionDigits ?? decimals(step);\n  const toPrecision = (n: number) =>\n    Number((Math.round(Number((n * 10 ** places).toPrecision(12))) / 10 ** places).toFixed(places));\n  // the offer shows only when it changes the number: \"5 µL\" in a µL field is just 5\n  // twelve significant digits drop binary noise (1.005 × 1000 is 1004.9999999999999 in floating point)\n  const product = (t: { n: number; per: number }) => Number((t.n * t.per).toPrecision(12));\n  const exact = typed && typed.per !== 1 ? product(typed) : null;\n  const converted = exact === null ? null : clamp(toPrecision(exact));\n  // the exact amount keeps its significant digits, however small (0.00005 nL is 0.00000005 µL, never 0)\n  const loose = useMemo(() => numberFormatter(locale, { maximumSignificantDigits: 9 }), [locale]);\n  // the conversion said truly: past a limit it says so, and a rounded one says what Enter will use\n  const beyond = exact === null ? null : exact > max ? \"max\" : exact < min ? \"min\" : null;\n  const unitText = (n: number) => `${loose.format(n)}${own ? ` ${own}` : \"\"}`;\n  const conversion =\n    exact === null || !typed\n      ? \"\"\n      : beyond\n        ? i18n(beyond === \"max\" ? \"convertsOver\" : \"convertsUnder\", {\n            typed: `${typed.number} ${typed.unit}`,\n            value: unitText(exact),\n            limit: unitText(beyond === \"max\" ? max : min),\n          })\n        : converted !== exact\n          ? i18n(\"convertsRounded\", {\n              typed: `${typed.number} ${typed.unit}`,\n              value: unitText(exact),\n              used: unitText(converted!),\n            })\n          : i18n(\"convertsTo\", { typed: `${typed.number} ${typed.unit}`, value: unitText(exact) });\n  // words with letters that name no unit the field knows: nothing is guessed, the value stays and the hint says why\n  // what typing reads so far, without the state parse keeps: undefined when it can't be read yet\n  const readTyped = (s: string): number | null | undefined => {\n    if (!s.trim()) return null;\n    const t = typedUnit(s);\n    if (t) return toPrecision(t.per === 1 ? t.n : product(t));\n    const n = parseLocaleNumber(s, locale);\n    return n === null ? undefined : toPrecision(n);\n  };\n  const parse = (s: string): number | null => {\n    if (!s.trim()) return null;\n    const t = typedUnit(s);\n    // a number in exponent form (\"1e3\") is a number, not a unit\n    if (units && /\\p{L}/u.test(s) && !t && !/^\\s*[+\\u2212-]?[\\d.,]+e[+-]?\\d+\\s*$/i.test(s)) {\n      setUnknownUnit(true);\n      return value;\n    }\n    setUnknownUnit(false);\n    if (t) return toPrecision(t.per === 1 ? t.n : product(t));\n    const n = parseLocaleNumber(s, locale);\n    return n === null ? value : toPrecision(n);\n  };\n  const motion = useMotionTokens(field);\n  // a step rolls the digits: up for more, down for less (typing never rolls)\n  const by = (delta: number) => {\n    setUnknownUnit(false);\n    const before = text;\n    const from = value ?? offer ?? (Number.isFinite(min) ? min : 0);\n    commit(from + delta);\n    const to = clamp(from + delta);\n    if (motion.reduced || to === value) return;\n    const ease = motion.ease.standard;\n    rollText(field.current, before, fmt.format(to), delta > 0 ? 1 : -1, {\n      duration: motion.duration.standard * 1000,\n      // the standard curve as CSS; a curve given as a function has no CSS form and steps evenly\n      easing: cssEase(ease, \"linear\"),\n    });\n  };\n  // taking a conversion rolls the typed \"5 mL\" over to \"5,000\": the field visibly changed the number, not the user\n  const take = (n: number | null) => {\n    const before = text;\n    commit(n);\n    if (n === null || motion.reduced || !typedUnit(before) || typedUnit(before)!.per === 1) return;\n    const to = clamp(n);\n    rollText(field.current, before, fmt.format(to), to >= typedUnit(before)!.n ? 1 : -1, {\n      duration: motion.duration.standard * 1000,\n      easing: cssEase(motion.ease.standard, \"linear\"),\n    });\n  };\n  // a held stepper runs without rolling: the digits change too fast to read as travel\n  const quiet = (delta: number) => {\n    const from = latest.current ?? (Number.isFinite(min) ? min : 0);\n    const to = clamp(from + delta);\n    if (to === latest.current) return false;\n    commit(to);\n    latest.current = to;\n    return true;\n  };\n  const hold = (delta: number, e: ReactPointerEvent<HTMLButtonElement>) => {\n    if (e.button !== 0 || disabled) return;\n    // the field takes (or keeps) focus, so the arrow keys carry on from the press\n    e.preventDefault();\n    field.current?.focus({ preventScroll: true });\n    holding.current?.stop();\n    by(delta);\n    let wait = HOLD_PAUSE;\n    let next = HOLD_FIRST;\n    const stop = () => {\n      window.clearTimeout(holding.current?.timer);\n      holding.current = null;\n      window.removeEventListener(\"pointerup\", stop);\n      window.removeEventListener(\"pointercancel\", stop);\n      window.removeEventListener(\"blur\", stop);\n    };\n    const run = () => {\n      if (!quiet(delta)) return stop();\n      wait = next;\n      next = Math.max(HOLD_FLOOR, next * 0.8);\n      holding.current = { timer: window.setTimeout(run, wait), stop };\n    };\n    holding.current = { timer: window.setTimeout(run, wait), stop };\n    window.addEventListener(\"pointerup\", stop);\n    window.addEventListener(\"pointercancel\", stop);\n    window.addEventListener(\"blur\", stop);\n  };\n  const big = largeStep ?? step * 10;\n  const onKey = (e: KeyboardEvent<HTMLInputElement>) => {\n    const map: Record<string, () => void> = {\n      ArrowUp: () => by(e.shiftKey ? big : step),\n      ArrowDown: () => by(-(e.shiftKey ? big : step)),\n      PageUp: () => by(big),\n      PageDown: () => by(-big),\n      Home: () => {\n        if (Number.isFinite(min)) commit(min);\n      },\n      End: () => {\n        if (Number.isFinite(max)) commit(max);\n      },\n      Enter: () => {\n        setEditing(false);\n        if (suggesting) commit(offer);\n        else take(parse(text));\n      },\n    };\n    const run = map[e.key];\n    if (run) {\n      e.preventDefault();\n      run();\n    }\n  };\n  // the label is a handle on the shared drag (threshold, capture, Escape and a lost pointer put the value back)\n  useDrag(\n    scrubRef,\n    {\n      onPress: () => {\n        if (!scrub || disabled) return false;\n        scrubbing.current = { from: value ?? 0 };\n      },\n      onMove: (p) => {\n        const s = scrubbing.current;\n        if (s) commit(s.from + Math.round(p.dx / 4) * step * (p.shift ? 10 : 1));\n      },\n      onEnd: () => {\n        scrubbing.current = null;\n      },\n      onTap: () => {\n        scrubbing.current = null;\n      },\n      onCancel: () => {\n        const s = scrubbing.current;\n        scrubbing.current = null;\n        if (s) commit(s.from);\n      },\n      onAbandon: () => {\n        scrubbing.current = null;\n      },\n    },\n    { axis: \"x\", disabled: !scrub || disabled },\n  );\n  return (\n    <Field\n      label={\n        scrub ? (\n          // the label is a handle: a sideways arrow arrives beside it with attention (the field hovered or focused),\n          // so dragging is discoverable without a tooltip\n          <span\n            ref={scrubRef}\n            data-slot=\"number-field-scrub\"\n            className={styles.scrub}\n            // a press on the handle neither selects its words nor focuses the field through the label\n            onPointerDown={(e) => e.button === 0 && e.preventDefault()}\n          >\n            {label}\n            <MoveHorizontal aria-hidden className={cn(\"reveal\", styles.scrubMark)} />\n          </span>\n        ) : (\n          label\n        )\n      }\n      hideLabel={hideLabel}\n      description={fieldDescription}\n      error={error}\n      required={required}\n      requiredOnControl\n      controlId={controlId}\n      descriptionId={descriptionId}\n      errorId={errorId}\n      className={cn(scrub && \"reveal-host\", className)}\n      classNames={classNames}\n      style={style}\n    >\n      <FieldFrame\n        ref={frame}\n        classNames={classNames}\n        size={size}\n        invalid={!!error}\n        disabled={disabled}\n        leading={prefix}\n        trailing={\n          <span className=\"flex items-center gap-1\">\n            {converted !== null && (\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                data-slot=\"number-field-convert\"\n                data-beyond={beyond ?? undefined}\n                // \"= 5,000\" is a formula: it reads left to right in every writing direction\n                dir=\"ltr\"\n                aria-label={i18n(\"useConverted\", { value: `${fmt.format(converted)}${own ? ` ${own}` : \"\"}` })}\n                disabled={disabled}\n                onPointerDown={(e) => e.preventDefault()}\n                onClick={() => {\n                  setEditing(false);\n                  take(converted);\n                }}\n                className={cn(styles.use, styles.convert, \"animate-enter-fade\", classNames?.convert)}\n              >\n                <span aria-hidden>{beyond || converted === exact ? \"=\" : \"≈\"}</span>\n                <span className=\"font-numeric tabular-nums\">{loose.format(beyond ? exact! : converted)}</span>\n                {beyond && (\n                  <span data-slot=\"number-field-convert-limit\" className={styles.convertLimit}>\n                    {i18n(beyond === \"max\" ? \"overLimit\" : \"underLimit\", {\n                      limit: fmt.format(beyond === \"max\" ? max : min),\n                    })}\n                  </span>\n                )}\n              </button>\n            )}\n            {suffix && <span className=\"me-1\">{suffix}</span>}\n            {suggesting && (\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                data-slot=\"number-field-use\"\n                aria-label={i18n(\"useTheSuggested\", { value: fmt.format(offer!) })}\n                disabled={disabled}\n                onPointerDown={(e) => e.preventDefault()}\n                onClick={() => commit(offer)}\n                className={cn(styles.use, \"animate-enter-fade\", classNames?.use)}\n              >\n                {i18n(\"use\")}\n              </button>\n            )}\n            {roomy && steppers && (\n              <>\n                <button\n                  type=\"button\"\n                  tabIndex={-1}\n                  data-slot=\"number-field-decrease\"\n                  aria-label={i18n(\"decrease\")}\n                  disabled={disabled || (value ?? -Infinity) <= min}\n                  onPointerDown={(e) => hold(-step, e)}\n                  // a pointer steps on press (and repeats while held); a click without a pointer (assistive tech) steps once\n                  onClick={(e) => e.detail === 0 && by(-step)}\n                  className={cn(styles.stepper, classNames?.decrease)}\n                >\n                  <Minus />\n                </button>\n                <button\n                  type=\"button\"\n                  tabIndex={-1}\n                  data-slot=\"number-field-increase\"\n                  aria-label={i18n(\"increase\")}\n                  disabled={disabled || (value ?? Infinity) >= max}\n                  onPointerDown={(e) => hold(step, e)}\n                  onClick={(e) => e.detail === 0 && by(step)}\n                  className={cn(styles.stepper, classNames?.increase)}\n                >\n                  <Plus />\n                </button>\n              </>\n            )}\n          </span>\n        }\n      >\n        <input\n          ref={attach}\n          data-slot=\"number-field\"\n          {...aria}\n          name={name}\n          required={required}\n          disabled={disabled}\n          inputMode={decimals(step) ? \"decimal\" : \"numeric\"}\n          autoComplete=\"off\"\n          role=\"spinbutton\"\n          aria-valuenow={value ?? undefined}\n          aria-valuemin={Number.isFinite(min) ? min : undefined}\n          aria-valuemax={Number.isFinite(max) ? max : undefined}\n          aria-valuetext={\n            value == null\n              ? undefined\n              : `${typeof prefix === \"string\" ? prefix : \"\"}${fmt.format(value)}${typeof suffix === \"string\" ? ` ${suffix}` : \"\"}`\n          }\n          value={text}\n          placeholder={suggesting ? fmt.format(offer!) : placeholder}\n          data-suggested={suggesting ? \"\" : undefined}\n          aria-description={\n            suggesting ? i18n(\"suggestedEnterUsesIt\", { value: fmt.format(offer!) }) : conversion || undefined\n          }\n          // typed with a unit, the text keeps its own direction (\"5 mL\" in a right-to-left page)\n          dir={units ? \"auto\" : undefined}\n          onChange={(e) => {\n            setEditing(true);\n            setText(e.target.value);\n            setUnknownUnit(false);\n            if (onValueInput) {\n              const typed = readTyped(e.target.value);\n              if (typed !== undefined) onValueInput(typed);\n            }\n          }}\n          onFocus={() => setEditing(true)}\n          onBlur={() => {\n            setEditing(false);\n            take(parse(text));\n          }}\n          onKeyDown={onKey}\n          className={cn(fieldControlClass, \"font-numeric tabular-nums\", classNames?.input)}\n        />\n      </FieldFrame>\n      {/* the conversion is announced as it appears (the description alone is read only on focus) */}\n      {units && (\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {conversion}\n        </span>\n      )}\n    </Field>\n  );\n}\n\nexport default NumberField;\n"},{"path":"beamline/components/spinner/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/defaults.ts","content":"/** Pure allowed presentation defaults for Spinner; no component or runtime imports. */\nexport interface SpinnerDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/spinner/messages.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  label: \"Loading\",\n  done: \"Done\",\n  error: \"Failed\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/spinner/spinner.module.css","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* With a delay the ring waits unseen and then fades in, so quick work never flashes it; the result (a check or a\n     cross) always shows at once. */\n  .root[data-delay] {\n    animation: appear var(--pui-fade-in) var(--pui-ease-default) var(--_delay) both;\n  }\n  @keyframes appear {\n    from {\n      opacity: 0;\n    }\n  }\n  /* The turn runs on the compositor (a CSS animation on the svg box) and costs no JS per frame. When the work ends the\n     arc closes into a full ring over the standard duration, so stopping the turn is invisible, and the check or cross\n     draws itself a beat later. All timing comes from the motion tokens; the loop period follows --pui-motion-scale. */\n  .svg[data-state=\"loading\"] {\n    animation: turn calc(0.8s * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1)) linear infinite;\n  }\n  .track {\n    stroke-opacity: 0.18;\n  }\n  .arc {\n    stroke-dasharray: 0.3 1;\n    transition: stroke-dasharray var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .svg:not([data-state=\"loading\"]) .arc {\n    stroke-dasharray: 1 1;\n  }\n  .mark {\n    stroke-dasharray: 1 1;\n    animation: draw var(--pui-duration-standard) var(--pui-ease-enter) var(--pui-duration-fast) both;\n  }\n  @keyframes turn {\n    to {\n      transform: rotate(1turn);\n    }\n  }\n  @keyframes draw {\n    from {\n      stroke-dashoffset: 1;\n    }\n    to {\n      stroke-dashoffset: 0;\n    }\n  }\n  .svg[data-paused] {\n    animation-play-state: paused;\n  }\n  .svg[data-reduced],\n  .svg[data-reduced] .mark {\n    animation: none;\n  }\n  /* Reduction retains the loading/result shape without a decorative loop. */\n  @media (prefers-reduced-motion: reduce) {\n    .svg[data-state=\"loading\"],\n    .mark {\n      animation: none;\n    }\n  }\n}\n"},{"path":"beamline/components/spinner/spinner.tsx","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SpinnerDefaults } from \"./defaults\";\nexport type { SpinnerDefaults } from \"./defaults\";\n\nimport { useRef, type CSSProperties, type Ref } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useActive } from \"../../hooks/use-active\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./spinner.module.css\";\n\nexport interface SpinnerClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the svg part; no override by default. */\n  svg?: string;\n  /** Classes for the track part; no override by default. */\n  track?: string;\n  /** Classes for the arc part; no override by default. */\n  arc?: string;\n  /** Classes for the mark part; no override by default. */\n  mark?: string;\n}\n\nexport interface SpinnerProps {\n  /** `success` draws a check out of the ring, `error` a cross; both stop spinning. */\n  state?: \"loading\" | \"success\" | \"error\";\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Accessible name while loading. */\n  label?: string;\n  /** ms to wait before it shows: work that ends sooner never flashes a spinner (about 300 is a good start). Default 0. */\n  delay?: number;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLSpanElement>;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: SpinnerClassNames;\n}\n\n/** Icon box from the foundation icon steps (§14.2): sm 14, md 16, lg 20. */\nconst PX = { sm: \"var(--pui-icon-sm)\", md: \"var(--pui-icon-md)\", lg: \"var(--pui-icon-lg)\" } as const;\n\n/**\n * A ring that spins while work runs and closes into a drawn check (or cross) when it ends — the result is a shape as\n * well as a colour. With reduced motion the loading ring holds still.\n */\nexport function Spinner({\n  state = \"loading\",\n  size: sizeProp,\n  label: labelProp,\n  delay = 0,\n  className,\n  classNames,\n  style,\n  ref,\n}: SpinnerProps) {\n  const i18n = useMessages(\"spinner\", englishMessages);\n  const label = labelProp === undefined ? i18n(\"label\") : labelProp;\n\n  const scopeDefaults = useComponentDefaults<SpinnerDefaults>(\"spinner\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n  const host = useRef<HTMLSpanElement>(null);\n  const attach = useComposedRef(host, ref);\n  const active = useActive(host);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = tokens.reduced || managedReduced;\n\n  const box = PX[size];\n  const done = state !== \"loading\";\n  const tone = state === \"success\" ? \"text-green\" : state === \"error\" ? \"text-red\" : \"text-ink-2\";\n  return (\n    <span\n      ref={attach}\n      data-slot=\"spinner\"\n      data-state={state}\n      data-delay={delay > 0 && state === \"loading\" ? \"\" : undefined}\n      role={done ? \"img\" : \"status\"}\n      aria-label={state === \"success\" ? i18n(\"done\") : state === \"error\" ? i18n(\"error\") : label}\n      className={cn(styles.root, \"relative inline-grid shrink-0 place-items-center\", tone, classNames?.root, className)}\n      style={{ width: box, height: box, \"--_delay\": `${delay}ms`, ...style } as CSSProperties}\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        width=\"100%\"\n        height=\"100%\"\n        fill=\"none\"\n        aria-hidden=\"true\"\n        data-state={state}\n        data-slot=\"spinner-svg\"\n        data-reduced={reduced ? \"\" : undefined}\n        data-paused={active ? undefined : \"\"}\n        className={cn(styles.svg, classNames?.svg)}\n      >\n        <circle\n          data-slot=\"spinner-track\"\n          className={cn(styles.track, classNames?.track)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n        />\n        <circle\n          data-slot=\"spinner-arc\"\n          className={cn(styles.arc, classNames?.arc)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          pathLength={1}\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n          strokeLinecap=\"round\"\n          transform=\"rotate(-90 12 12)\"\n        />\n        {state === \"success\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M7.5 12.5l3 3 6-6.5\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        )}\n        {state === \"error\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M8.5 8.5l7 7M15.5 8.5l-7 7\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n          />\n        )}\n      </svg>\n    </span>\n  );\n}\n\nexport default Spinner;\n"},{"path":"beamline/hooks/use-active.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-active.ts","content":"\"use client\";\n\nimport { useContext, useEffect, useState, type RefObject } from \"react\";\n\nimport { ActivityContext } from \"../runtime/activity\";\n\n/**\n * Whether an animated element should be doing work: it is (nearly) on screen and the tab is visible. Every\n * continuously animated component gates its loop, shader or CSS animation on this, so an effect scrolled out of view,\n * behind another tab, or in a collapsed panel costs nothing. One IntersectionObserver per element; no polling.\n */\nexport function useActive(\n  ref: RefObject<Element | null>,\n  {\n    margin = \"120px\",\n    key,\n    idle = true,\n  }: {\n    margin?: string;\n    /** Re-attach when this changes (an element that mounts later). */\n    key?: unknown;\n    /** Pause continuous work at the host idle boundary. Finite sequences set false so they finish. */\n    idle?: boolean;\n  } = {},\n) {\n  const active = useContext(ActivityContext);\n  const [inView, setInView] = useState(false);\n  const [visible, setVisible] = useState(\n    () => typeof document === \"undefined\" || document.visibilityState === \"visible\",\n  );\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { rootMargin: margin });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, margin, key]);\n  useEffect(() => {\n    const on = () => setVisible(document.visibilityState === \"visible\");\n    document.addEventListener(\"visibilitychange\", on);\n    return () => document.removeEventListener(\"visibilitychange\", on);\n  }, []);\n  return (!idle || active) && inView && visible;\n}\n"},{"path":"beamline/hooks/use-composed-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-composed-ref.ts","content":"\"use client\";\n\nimport { useCallback, type Ref, type RefObject } from \"react\";\n\n/** Keep a control's measurement/focus ref and the caller's ref, including React 19 callback cleanup. */\nexport function useComposedRef<T>(internal: RefObject<T | null>, external?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      internal.current = node;\n      if (typeof external === \"function\") {\n        const cleanup = external(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            internal.current = null;\n            cleanup();\n          };\n      } else if (external) external.current = node;\n    },\n    [internal, external],\n  );\n}\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-media-query.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-media-query.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// one MediaQueryList per query: React reads the snapshot on every render, and a part that renders thirty times a\n// second (a playing audio player's clock) would otherwise ask the browser for a new list each time\nconst lists = new Map<string, MediaQueryList>();\nconst list = (query: string) => {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return undefined;\n  let m = lists.get(query);\n  if (!m) lists.set(query, (m = window.matchMedia(query)));\n  return m;\n};\n\n/** True while the media query matches; false on the server and in the first client render of SSR output. */\nexport function useMediaQuery(query: string): boolean {\n  return useSyncExternalStore(\n    (notify) => {\n      const m = list(query);\n      m?.addEventListener(\"change\", notify);\n      return () => m?.removeEventListener(\"change\", notify);\n    },\n    () => list(query)?.matches ?? false,\n    () => false,\n  );\n}\n\n/** Phone-width layouts: below 768px. */\nexport const usePhone = () => useMediaQuery(\"(max-width: 767px)\");\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-reduced-motion-safe.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-reduced-motion-safe.ts","content":"\"use client\";\n\nimport { useMediaQuery } from \"./use-media-query\";\nimport { useScopeMotion } from \"../runtime/appearance\";\n\n/** Managed motion policy plus the OS preference, with a stable server snapshot. */\nexport function useReducedMotionSafe() {\n  const requested = useScopeMotion();\n  const os = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  return requested !== \"auto\" || os;\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/number.ts","type":"registry:file","target":"src/ui/beamline/lib/number.ts","content":"import { createFormatters, FALLBACK_LOCALE } from \"../runtime/messages\";\n/** Reads a number typed in `locale` (\"1.234,5\" in de, \"1,234.5\" in en): group separators dropped, the locale's decimal\n *  mark read as the point, currency signs and units ignored. Empty or unreadable text is null; nothing is clamped. */\nexport function parseLocaleNumber(text: string, locale?: string): number | null {\n  if (!text.trim()) return null;\n  // exponent form: \"1e3\" is a thousand and \"2.5e-3\" a fraction (the digits are never run together into 13)\n  const exp = text.trim().match(/^(.*\\d)\\s*e\\s*([+\\u2212-]?\\d+)$/i);\n  if (exp) {\n    const base = parseLocaleNumber(exp[1], locale);\n    return base === null ? null : Number((base * 10 ** Number(exp[2].replace(\"\\u2212\", \"-\"))).toPrecision(15));\n  }\n  const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);\n  const group = parts.find((p) => p.type === \"group\")?.value ?? \",\";\n  const decimal = parts.find((p) => p.type === \"decimal\")?.value ?? \".\";\n  let ascii = text;\n  // one grouping mark that cannot group (not three digits after it) and no decimal mark: the decimal was written the\n  // other locale's way (\"1,5\" in English is one and a half, never fifteen)\n  const marks = text.split(group).length - 1;\n  if (marks === 1 && !text.includes(decimal) && !new RegExp(`\\\\${group}\\\\d{3}(?!\\\\d)`).test(text))\n    ascii = text.replace(group, decimal);\n  if (/[^\\p{ASCII}]/u.test(text)) {\n    const digits = new Intl.NumberFormat(locale, { useGrouping: false });\n    for (let value = 0; value < 10; value++) ascii = ascii.replaceAll(digits.format(value), String(value));\n  }\n  const normalized = ascii\n    .replaceAll(group, \"\")\n    .replaceAll(\"\\u202f\", \"\")\n    .replace(decimal, \".\")\n    .replace(/[\\u2212\\u2013]/g, \"-\")\n    .replace(/[^\\d.-]/g, \"\");\n  if (!/\\d/.test(normalized)) return null;\n  const n = Number(normalized);\n  return Number.isFinite(n) ? n : null;\n}\n\nconst formatters = new Map<string, Intl.NumberFormat>();\n/** A number written from serializable settings, so a server component can pass them where a `format` function cannot\n *  cross: Intl options in a locale, with words before and after (\"$\" … \" / mo\"). One Intl.NumberFormat per setting. */\nexport function formatNumber(\n  value: number,\n  { locale, formatOptions, prefix = \"\", suffix = \"\" }: NumberFormatSettings = {},\n): string {\n  const key = `${locale ?? \"\"}|${JSON.stringify(formatOptions ?? {})}`;\n  let f = formatters.get(key);\n  if (!f) formatters.set(key, (f = new Intl.NumberFormat(locale, formatOptions)));\n  return `${prefix}${f.format(value)}${suffix}`;\n}\n\nexport interface NumberFormatSettings {\n  locale?: string;\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Written before the number: \"$\". */\n  prefix?: string;\n  /** Written after the number: \" ms\", \" / mo\". */\n  suffix?: string;\n}\n\n/** Binary-scaled file sizes; the digits and unit names follow the supplied locale. */\nexport function formatByteSize(bytes: number, locale = FALLBACK_LOCALE, maximumUnit = 3, fractionDigits?: number) {\n  const units = [\"byte\", \"kilobyte\", \"megabyte\", \"gigabyte\"];\n  let value = bytes,\n    unit = 0;\n  while (value >= 1024 && unit < maximumUnit) {\n    value /= 1024;\n    unit++;\n  }\n  const digits = fractionDigits ?? (value < 10 && unit > 0 ? 1 : 0);\n  return createFormatters(locale).number(value, {\n    style: \"unit\",\n    unit: units[unit],\n    unitDisplay: \"short\",\n    minimumFractionDigits: digits,\n    maximumFractionDigits: digits,\n  });\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":"Number field is in src/ui/beamline/components/number-field/number-field.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/number-field"}}