{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"allocation-editor","type":"registry:component","title":"Allocation editor","description":"Parts of a fixed whole edited as transfers: an ordered strip whose boundaries drag or step between neighbours and a roster of exact amounts where typing names the part that gives or takes; locks, limits and your rules refuse with words, nothing is redistributed out of sight, and Save waits for your handler.","categories":["forms"],"dependencies":["@radix-ui/react-direction@1.1.4","@radix-ui/react-label@2.1.15","@radix-ui/react-select@2.3.7","@radix-ui/react-slot@1.3.3","clsx@^2.1.1","lucide-react@^1.48.0","motion@^13.4.4","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/glide.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/glide.tsx","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type RefObject } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport overlay from \"./overlay.module.css\";\n\n/**\n * The gliding highlight for a list whose active row is tracked in state (command menu, listbox): one plate travels to\n * the active row for the arrow keys and the pointer alike, over --pui-duration-fast. It appears in place (no travel)\n * the first time and whenever `reset` changes (a new page or a new query), so typing never sends it flying.\n * The list must be the plate's offset parent (position: relative); rows stay above it (overlay.item is z-index 1).\n */\nexport function GlidePlate({\n  list,\n  selector,\n  reset,\n  axis = \"y\",\n  className,\n}: {\n  list: RefObject<HTMLElement | null>;\n  /** Finds the active row inside `list`; null when nothing is active. */\n  selector: string | null;\n  reset?: unknown;\n  /** \"y\" for a list (the plate spans the width); \"xy\" for a row or grid of cells (it takes each cell's size). */\n  axis?: \"y\" | \"xy\";\n  className?: string;\n}) {\n  const [plate, setPlate] = useState<{ x: number; y: number; w: number; h: number; jump: boolean } | null>(null);\n  const lastReset = useRef(reset);\n  const self = useRef<HTMLSpanElement>(null);\n  // eslint-disable-next-line react-hooks/exhaustive-deps -- every render on purpose; setPlate bails out when unchanged\n  useLayoutEffect(() => {\n    // the list holds the plate, and React attaches a parent's ref only after its children's layout effects: on the\n    // first commit the list is the plate's own parent (it placed itself only when something rendered again later)\n    const host = list.current ?? self.current?.parentElement;\n    const row = selector ? host?.querySelector<HTMLElement>(selector) : null;\n    const fresh = lastReset.current !== reset;\n    lastReset.current = reset;\n    if (!row) {\n      setPlate((p) => (p && p.h ? { ...p, h: 0 } : p));\n      return;\n    }\n    const x = row.offsetLeft,\n      y = row.offsetTop,\n      w = row.offsetWidth,\n      h = row.offsetHeight;\n    setPlate((p) =>\n      p && p.x === x && p.y === y && p.w === w && p.h === h && !fresh ? p : { x, y, w, h, jump: fresh || !p?.h },\n    );\n    // every render: rows can arrive or change height without the selector changing; an unchanged place keeps the state\n  });\n  return (\n    <span\n      ref={self}\n      aria-hidden\n      data-slot=\"list-highlight\"\n      className={cn(overlay.glide, className)}\n      data-on={plate?.h ? \"\" : undefined}\n      data-jump={plate?.jump ? \"\" : undefined}\n      data-axis={axis}\n      style={\n        !plate\n          ? undefined\n          : axis === \"y\"\n            ? { transform: `translateY(${plate.y}px)`, height: plate.h || undefined }\n            : { transform: `translate(${plate.x}px, ${plate.y}px)`, width: plate.w, height: plate.h || undefined }\n      }\n    />\n  );\n}\n\n/**\n * A GlidePlate for lists whose highlight is the pointer or keyboard focus rather than state (time picker, plain option\n * lists): it follows the row under the pointer or holding focus, and fades when neither is in the list. Rows drop\n * their own hover and focus tint.\n */\nexport function FollowPlate({\n  list,\n  rows,\n  className,\n}: {\n  list: RefObject<HTMLElement | null>;\n  /** Matches a row inside `list`, e.g. \"[role=option]\". */\n  rows: string;\n  className?: string;\n}) {\n  const [hot, setHot] = useState<string | null>(null);\n  // after the commit, when the list's ref is attached (a layout effect here ran before it and never listened)\n  useEffect(() => {\n    const el = list.current;\n    if (!el) return;\n    let n = 0;\n    const mark = (target: EventTarget | null) => {\n      const row = (target as HTMLElement | null)?.closest?.<HTMLElement>(rows);\n      if (!row || !el.contains(row)) return;\n      row.dataset.glideKey ??= String(++n);\n      setHot(row.dataset.glideKey);\n    };\n    const over = (e: Event) => mark(e.target);\n    const leave = () => {\n      if (!el.contains(document.activeElement)) setHot(null);\n    };\n    const blur = (e: FocusEvent) => {\n      if (!el.contains(e.relatedTarget as Node)) setHot(null);\n    };\n    el.addEventListener(\"pointerover\", over);\n    el.addEventListener(\"focusin\", over);\n    el.addEventListener(\"pointerleave\", leave);\n    el.addEventListener(\"focusout\", blur);\n    return () => {\n      el.removeEventListener(\"pointerover\", over);\n      el.removeEventListener(\"focusin\", over);\n      el.removeEventListener(\"pointerleave\", leave);\n      el.removeEventListener(\"focusout\", blur);\n    };\n  }, [list, rows]);\n  return <GlidePlate list={list} selector={hot ? `[data-glide-key=\"${hot}\"]` : null} className={className} />;\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/overlay.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/overlay.module.css","content":"@layer properties, theme, base, components, utilities;\n/* the source morph's moving parts: the clip's four sides, the radius and the offset back to the trigger */\n@property --ci-t {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-r {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-b {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-l {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-x {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --ci-y {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@layer components {\n  /* Every floating layer in the system: one surface (painted by the overlay role of the look in force), one menu-item\n   look, one scrim, one enter and exit. A layer placed against a trigger opens out of it (the source morph below); one\n   without a placement arrives in --pui-motion-in scaling from its origin. Each leaves in --pui-motion-out; Radix keeps\n   it mounted until the exit animation ends. Reduced motion makes both instant (the duration tokens become 1ms). */\n  .surface {\n    z-index: var(--pui-z-popover);\n    min-width: 8rem;\n    /* never past the screen: as wide and as tall as the room beside the trigger (Radix measures it) */\n    max-width: var(--radix-popper-available-width, none);\n    max-height: var(--radix-popper-available-height, 70vh);\n    overflow: auto;\n    border-radius: var(--pui-overlay-radius, var(--pui-radius-card));\n    color: var(--pui-ink);\n    transform-origin: var(--radix-popper-transform-origin, var(--radix-dropdown-menu-content-transform-origin, center));\n    outline: none;\n  }\n  .surface[data-state=\"open\"],\n  .surface[data-state=\"delayed-open\"],\n  .surface[data-state=\"instant-open\"] {\n    animation: pop-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .surface[data-state=\"closed\"] {\n    animation: pop-out var(--pui-motion-out) var(--pui-ease-default) forwards;\n  }\n\n  /* The source morph (research/morphing-interfaces-2026-10-04, M3): a layer placed against a trigger does not fade in\n     beside it, it opens out of it. It starts as the trigger's own box (Radix gives its size and the side and alignment\n     it was placed on), offset back onto the trigger, and grows: the side nearest the trigger is there at once, the\n     sides across follow, the far side arrives last (~200 ms), so the rows by the trigger are readable within the\n     ruled 90 ms while the outline settles. Content fades in over those 90 ms. It closes back into the trigger in the\n     ruled 180 ms. The clip ends 48px outside the box so the layer's shadow is never cut. Reduced motion: instant. */\n  .surface[data-side] {\n    --_aw: var(--radix-popper-anchor-width, 0px);\n    --_ah: var(--radix-popper-anchor-height, 0px);\n    --_gap: 6px;\n    --from-t: -48px;\n    --from-r: -48px;\n    --from-b: -48px;\n    --from-l: -48px;\n    --from-x: 0px;\n    --from-y: 0px;\n    --d-near: calc(var(--pui-duration-standard) * 0.55);\n    --d-cross: calc(var(--pui-duration-standard) * 0.85);\n    --d-far: var(--pui-duration-standard);\n    clip-path: inset(\n      var(--ci-t) var(--ci-r) var(--ci-b) var(--ci-l) round var(--pui-overlay-radius, var(--pui-radius-card))\n    );\n    translate: var(--ci-x) var(--ci-y);\n  }\n  .surface[data-side=\"bottom\"] {\n    --from-t: 0px;\n    --from-b: calc(100% - var(--_ah));\n    --from-y: calc(-1 * (var(--_ah) + var(--_gap)));\n    --d-t: var(--d-near);\n    --d-b: var(--d-far);\n    --d-l: var(--d-cross);\n    --d-r: var(--d-cross);\n  }\n  .surface[data-side=\"top\"] {\n    --from-b: 0px;\n    --from-t: calc(100% - var(--_ah));\n    --from-y: calc(var(--_ah) + var(--_gap));\n    --d-b: var(--d-near);\n    --d-t: var(--d-far);\n    --d-l: var(--d-cross);\n    --d-r: var(--d-cross);\n  }\n  .surface[data-side=\"right\"] {\n    --from-l: 0px;\n    --from-r: calc(100% - var(--_aw));\n    --from-x: calc(-1 * (var(--_aw) + var(--_gap)));\n    --d-l: var(--d-near);\n    --d-r: var(--d-far);\n    --d-t: var(--d-cross);\n    --d-b: var(--d-cross);\n  }\n  .surface[data-side=\"left\"] {\n    --from-r: 0px;\n    --from-l: calc(100% - var(--_aw));\n    --from-x: calc(var(--_aw) + var(--_gap));\n    --d-r: var(--d-near);\n    --d-l: var(--d-far);\n    --d-t: var(--d-cross);\n    --d-b: var(--d-cross);\n  }\n  /* alignment along the trigger: which part of the layer the trigger sits over */\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"start\"] {\n    --from-l: 0px;\n    --from-r: calc(100% - var(--_aw));\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"end\"] {\n    --from-l: calc(100% - var(--_aw));\n    --from-r: 0px;\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"start\"]:dir(rtl) {\n    --from-l: calc(100% - var(--_aw));\n    --from-r: 0px;\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"end\"]:dir(rtl) {\n    --from-l: 0px;\n    --from-r: calc(100% - var(--_aw));\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"center\"] {\n    --from-l: calc((100% - var(--_aw)) / 2);\n    --from-r: calc((100% - var(--_aw)) / 2);\n  }\n  .surface:is([data-side=\"left\"], [data-side=\"right\"])[data-align=\"start\"] {\n    --from-t: 0px;\n    --from-b: calc(100% - var(--_ah));\n  }\n  .surface:is([data-side=\"left\"], [data-side=\"right\"])[data-align=\"end\"] {\n    --from-t: calc(100% - var(--_ah));\n    --from-b: 0px;\n  }\n  .surface:is([data-side=\"left\"], [data-side=\"right\"])[data-align=\"center\"] {\n    --from-t: calc((100% - var(--_ah)) / 2);\n    --from-b: calc((100% - var(--_ah)) / 2);\n  }\n  .surface[data-side]:is([data-state=\"open\"], [data-state=\"delayed-open\"], [data-state=\"instant-open\"]) {\n    animation:\n      grow-t var(--d-t) var(--pui-ease-out-strong),\n      grow-r var(--d-r) var(--pui-ease-out-strong),\n      grow-b var(--d-b) var(--pui-ease-out-strong),\n      grow-l var(--d-l) var(--pui-ease-out-strong),\n      grow-xy var(--d-cross) var(--pui-ease-out-strong),\n      settling var(--d-far) step-end;\n  }\n  .surface[data-side]:is([data-state=\"open\"], [data-state=\"delayed-open\"], [data-state=\"instant-open\"]) > * {\n    animation: content-in var(--pui-motion-in) var(--pui-ease-default) both;\n  }\n  /* one duration for every part of the exit: the layer unmounts when the first of them ends */\n  .surface[data-side][data-state=\"closed\"] {\n    animation: shrink var(--pui-motion-out) var(--pui-ease-exit) forwards;\n  }\n  .surface[data-side][data-state=\"closed\"] > * {\n    animation: content-out var(--pui-motion-in) var(--pui-ease-default) forwards;\n  }\n  @keyframes grow-t {\n    from {\n      --ci-t: var(--from-t);\n    }\n  }\n  @keyframes grow-r {\n    from {\n      --ci-r: var(--from-r);\n    }\n  }\n  @keyframes grow-b {\n    from {\n      --ci-b: var(--from-b);\n    }\n  }\n  @keyframes grow-l {\n    from {\n      --ci-l: var(--from-l);\n    }\n  }\n  @keyframes grow-xy {\n    from {\n      --ci-x: var(--from-x);\n      --ci-y: var(--from-y);\n    }\n  }\n  /* it starts over its own trigger: until it has grown, the press that opened it must not land on a row */\n  @keyframes settling {\n    0%,\n    100% {\n      pointer-events: none;\n    }\n  }\n  @keyframes shrink {\n    to {\n      --ci-t: var(--from-t);\n      --ci-r: var(--from-r);\n      --ci-b: var(--from-b);\n      --ci-l: var(--from-l);\n      --ci-x: var(--from-x);\n      --ci-y: var(--from-y);\n      opacity: 0;\n    }\n  }\n  @keyframes content-in {\n    from {\n      opacity: 0;\n    }\n  }\n  @keyframes content-out {\n    to {\n      opacity: 0;\n    }\n  }\n  .padded {\n    padding: var(--pui-pad-popover);\n  }\n  .menu {\n    padding: var(--pui-space-1);\n  }\n  @keyframes pop-in {\n    from {\n      opacity: 0;\n      transform: scale(0.97);\n    }\n  }\n  @keyframes pop-out {\n    to {\n      opacity: 0;\n      transform: scale(0.98);\n    }\n  }\n\n  /* rows (and the gliding plate) nest in the list: its radius minus its padding, never rounder than a chip; on the\n     row itself, so any list that uses .item gets it */\n  .item,\n  .glide {\n    --_item-radius: min(\n      var(--pui-radius-chip),\n      calc(var(--pui-overlay-radius, var(--pui-radius-card)) - var(--pui-space-1))\n    );\n  }\n\n  /* menu rows: shared by dropdown, context menu, menubar, select, combobox lists */\n  .item {\n    position: relative;\n    z-index: 1;\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-2);\n    min-height: var(--pui-control-height-sm);\n    padding: 0 var(--pui-space-2);\n    /* nested in the list: the overlay's radius minus its padding, never rounder than a chip */\n    border-radius: var(--_item-radius);\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    cursor: default;\n    user-select: none;\n    outline: none;\n  }\n  /* on a touch screen a row is a finger's target: the touch height (44px) from the tokens; a mouse keeps the dense row */\n  @media (pointer: coarse) {\n    .item {\n      min-height: var(--pui-control-height-touch);\n    }\n  }\n  /* a destructive row speaks in the danger ink only while it can be used: disabled wins (muted ink, no alarm for an\n     action that cannot happen) */\n  .item[data-tone=\"danger\"]:not([data-disabled]) {\n    color: var(--pui-red);\n  }\n  .item[data-disabled] {\n    color: var(--pui-ink-3);\n    pointer-events: none;\n  }\n  .item :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n  }\n  .item[data-tone=\"danger\"]:not([data-disabled]) :where(svg) {\n    color: currentColor;\n  }\n  .item[data-highlighted]:not([data-glide]) {\n    background: var(--pui-hover-2);\n  }\n  /* a press is acknowledged at once, over the plate, before the menu closes or the row toggles */\n  .item:active:not([data-disabled]) {\n    background: var(--pui-hover);\n  }\n  .shortcut {\n    margin-inline-start: auto;\n    padding-inline-start: var(--pui-space-4);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    letter-spacing: var(--pui-tracking-mono-caps);\n  }\n  .heading {\n    padding: var(--pui-space-1-5) var(--pui-space-2) var(--pui-space-1);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    font-weight: var(--pui-weight-medium);\n  }\n  .separator {\n    height: 1px;\n    margin: var(--pui-space-1) calc(var(--pui-space-1) * -1);\n    background: var(--pui-line);\n  }\n  .check {\n    margin-inline-start: auto;\n    color: var(--pui-accent) !important;\n  }\n  /* a check arrives in its row as the value changes (at once): it grows from the middle over the arrival */\n  .check :where(svg) {\n    animation: check-in var(--pui-motion-in) var(--pui-ease-out-strong);\n  }\n  @keyframes check-in {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.5 * var(--pui-motion-travel, 1));\n    }\n  }\n  /* the gliding highlight: one plate moves between rows for the pointer and the arrow keys; it appears in place */\n  .glide {\n    position: absolute;\n    z-index: 0;\n    inset-inline: var(--pui-space-1);\n    top: 0;\n    border-radius: var(--_item-radius);\n    background: var(--pui-hover-2);\n    pointer-events: none;\n    opacity: 0;\n    transition: opacity var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .glide[data-on] {\n    opacity: 1;\n    transition:\n      transform var(--pui-duration-fast) var(--pui-ease-standard),\n      height var(--pui-duration-fast) var(--pui-ease-standard),\n      opacity 0ms;\n  }\n  .glide[data-jump] {\n    transition: opacity 0ms;\n  }\n  /* a plate over a row or grid of cells takes each cell's size and travels both ways */\n  .glide[data-axis=\"xy\"] {\n    inset-inline: auto;\n    left: 0;\n  }\n  .glide[data-axis=\"xy\"][data-on] {\n    transition:\n      transform var(--pui-duration-fast) var(--pui-ease-standard),\n      width var(--pui-duration-fast) var(--pui-ease-standard),\n      height var(--pui-duration-fast) var(--pui-ease-standard),\n      opacity 0ms;\n  }\n\n  /* a layer that has begun to close is out of the way at once: for the length of its exit it takes no pointer, so a tap\n     meant for the page behind (the next row, the button the panel covered) lands there, never on the leaving ghost.\n     Focus has already gone back (Radix) and it unmounts when the exit ends. */\n  .scrim[data-state=\"closed\"],\n  .surface[data-state=\"closed\"],\n  .dialog[data-state=\"closed\"],\n  .sheet[data-state=\"closed\"] {\n    pointer-events: none;\n  }\n\n  /* modal layers */\n  .scrim {\n    position: fixed;\n    inset: 0;\n    z-index: var(--pui-z-overlay);\n    background: var(--pui-scrim);\n    backdrop-filter: blur(var(--pui-blur-overlay));\n  }\n  .scrim[data-state=\"open\"] {\n    animation: fade-in var(--pui-fade-in) var(--pui-ease-default);\n  }\n  .scrim[data-state=\"closed\"] {\n    animation: fade-out var(--pui-fade-out) var(--pui-ease-default) forwards;\n  }\n  @keyframes fade-in {\n    from {\n      opacity: 0;\n    }\n  }\n  @keyframes fade-out {\n    to {\n      opacity: 0;\n    }\n  }\n  .dialog {\n    /* the content answers to the dialog's own width (a footer's buttons share the row on a phone) */\n    container-type: inline-size;\n    position: fixed;\n    z-index: var(--pui-z-modal);\n    left: 50%;\n    top: 50%;\n    width: min(calc(100vw - var(--pui-space-8)), var(--pui-dialog-width, var(--_dialog-width, 440px)));\n    max-height: calc(100dvh - var(--pui-space-12));\n    overflow: hidden;\n    translate: -50% -50%;\n    border-radius: var(--pui-dialog-radius, var(--pui-radius-window));\n    color: var(--pui-ink);\n    outline: none;\n    /* a size chosen while open (a step that needs more room) eases the width in place, centred */\n    transition: width var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  .dialog[data-state=\"open\"] {\n    animation: dialog-in var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .dialog[data-state=\"closed\"] {\n    animation: dialog-out var(--pui-motion-out) var(--pui-ease-default) forwards;\n  }\n  /* A dialog opened from something (use-open-from.ts) grows out of it: it starts as the opener's box, offset back onto\n     it, and grows to its own place over the considered duration while its content fades in; it closes back into the\n     opener in the ruled 180 ms. Shares the source morph's moving parts and guard with .surface. */\n  .dialog[data-open-from] {\n    clip-path: inset(\n      var(--ci-t) var(--ci-r) var(--ci-b) var(--ci-l) round var(--pui-dialog-radius, var(--pui-radius-window))\n    );\n    transform: translate(var(--ci-x), var(--ci-y));\n  }\n  .dialog[data-open-from][data-state=\"open\"] {\n    animation:\n      grow-t var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-r var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-b var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-l var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-xy calc(var(--pui-duration-considered) * 0.9) var(--pui-ease-out-strong),\n      settling var(--pui-duration-considered) step-end;\n  }\n  .dialog[data-open-from][data-state=\"open\"] > * {\n    animation: content-in var(--pui-duration-standard) var(--pui-ease-default) calc(var(--pui-duration-standard) * 0.35)\n      both;\n  }\n  .dialog[data-open-from][data-state=\"closed\"] {\n    animation: shrink var(--pui-motion-out) var(--pui-ease-exit) forwards;\n  }\n  .dialog[data-open-from][data-state=\"closed\"] > * {\n    animation: content-out var(--pui-motion-in) var(--pui-ease-default) forwards;\n  }\n  /* A sheet: a panel from an edge. It slides in from its edge over the considered duration on the drawer curve and\n     goes back out to that edge in the ruled departure (a layer closes back into where it came from). The edge is\n     logical: an end (\"right\") sheet comes from the left in a right-to-left page. Its content answers to its width. */\n  .sheet {\n    --_sx: 0px;\n    --_sy: 0px;\n    container-type: inline-size;\n    /* where a finger has swiped it toward its edge (sheet.tsx); it settles back over the departure */\n    translate: var(--_swipe, 0px) 0;\n  }\n  /* a side panel leaves sideways pans to the swipe and keeps vertical scrolling native, in its body too */\n  .sheet:is([data-side=\"left\"], [data-side=\"right\"]),\n  .sheet:is([data-side=\"left\"], [data-side=\"right\"]) [data-slot=\"sheet-body\"] {\n    touch-action: pan-y;\n  }\n  .sheet:is([data-side=\"left\"], [data-side=\"right\"]) {\n    transition:\n      var(--m-transition),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .sheet[data-swiping] {\n    transition: none;\n  }\n  .sheet[data-side=\"right\"] {\n    --_sx: 100%;\n  }\n  .sheet[data-side=\"left\"] {\n    --_sx: -100%;\n  }\n  .sheet[data-side=\"right\"]:dir(rtl) {\n    --_sx: -100%;\n  }\n  .sheet[data-side=\"left\"]:dir(rtl) {\n    --_sx: 100%;\n  }\n  .sheet[data-side=\"top\"] {\n    --_sy: -100%;\n  }\n  .sheet[data-side=\"bottom\"] {\n    --_sy: 100%;\n  }\n  .sheet[data-state=\"open\"] {\n    animation: sheet-in var(--pui-duration-considered) var(--pui-ease-drawer) both;\n  }\n  .sheet[data-state=\"closed\"] {\n    animation: sheet-out var(--pui-motion-out) var(--pui-ease-exit) forwards;\n  }\n  @keyframes sheet-in {\n    from {\n      translate: var(--_sx) var(--_sy);\n    }\n  }\n  @keyframes sheet-out {\n    to {\n      translate: var(--_sx) var(--_sy);\n    }\n  }\n  @keyframes dialog-in {\n    from {\n      opacity: 0;\n      transform: scale(0.96);\n    }\n  }\n  @keyframes dialog-out {\n    to {\n      opacity: 0;\n      transform: scale(0.98);\n    }\n  }\n}\n\n/* Reduced motion: surfaces and dialogs fade without scaling or growing, over the fade tokens (restrained under reduced,\n   1ms under none; APPEARANCE A06). */\n@media (prefers-reduced-motion: reduce) {\n  .surface,\n  .dialog,\n  .sheet {\n    animation-name: fade-in !important;\n    animation-duration: var(--pui-fade-in) !important;\n  }\n  .surface[data-state=\"closed\"],\n  .dialog[data-state=\"closed\"],\n  .sheet[data-state=\"closed\"] {\n    animation-name: fade-out !important;\n    animation-duration: var(--pui-fade-out) !important;\n  }\n}\n@layer components {\n  :global(.pui[data-motion=\"reduced\"]) .surface,\n  :global(.pui[data-motion=\"none\"]) .surface,\n  :global(.pui[data-motion=\"reduced\"]) .dialog,\n  :global(.pui[data-motion=\"none\"]) .dialog,\n  :global(.pui[data-motion=\"reduced\"]) .sheet,\n  :global(.pui[data-motion=\"none\"]) .sheet {\n    animation-name: fade-in;\n    animation-duration: var(--pui-fade-in);\n  }\n  :global(.pui[data-motion=\"reduced\"]) .surface[data-state=\"closed\"],\n  :global(.pui[data-motion=\"none\"]) .surface[data-state=\"closed\"],\n  :global(.pui[data-motion=\"reduced\"]) .dialog[data-state=\"closed\"],\n  :global(.pui[data-motion=\"none\"]) .dialog[data-state=\"closed\"],\n  :global(.pui[data-motion=\"reduced\"]) .sheet[data-state=\"closed\"],\n  :global(.pui[data-motion=\"none\"]) .sheet[data-state=\"closed\"] {\n    animation-name: fade-out;\n    animation-duration: var(--pui-fade-out);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.surface {\n  composes: m-overlay from global;\n}\n.dialog {\n  composes: m-overlay from global;\n}\n"},{"path":"beamline/components/alert/alert.module.css","type":"registry:file","target":"src/ui/beamline/components/alert/alert.module.css","content":"@layer properties, theme, base, components, utilities;\n/* the tone's tint, registered so a change of tone blends instead of switching */\n@property --pui-alert-tint {\n  syntax: \"<color>\";\n  inherits: false;\n  initial-value: transparent;\n}\n@layer components {\n  /* An alert is a surface like any other, so every look frames it (blueprint's rule, pop's ink outline, ink's pen,\n     mono's dashes, metal's pocket). The tone is a tint composed over the role's own fill, never a replacement, plus\n     the icon in the tone's colour and the words: colour is never the only signal. */\n  .alert {\n    display: flex;\n    gap: var(--pui-space-3);\n    padding: var(--pui-space-3);\n    border-radius: var(--pui-radius-card);\n    background: linear-gradient(var(--pui-alert-tint), var(--pui-alert-tint)), var(--m-surface-bg);\n    color: var(--pui-ink-2);\n    /* a change of tone (warning → resolved) blends the tint instead of switching it */\n    transition:\n      var(--m-transition),\n      --pui-alert-tint var(--pui-duration-standard) var(--pui-ease-default);\n  }\n  .alert[data-tone=\"info\"] {\n    --_tone: var(--pui-blue);\n  }\n  .alert[data-tone=\"success\"] {\n    --_tone: var(--pui-green);\n  }\n  .alert[data-tone=\"warning\"] {\n    --_tone: var(--pui-orange);\n  }\n  .alert[data-tone=\"danger\"] {\n    --_tone: var(--pui-red);\n  }\n  .alert[data-tone=\"neutral\"] {\n    --_tone: var(--pui-ink-3);\n  }\n  .alert:not([data-tone=\"neutral\"]) {\n    --pui-alert-tint: color-mix(in oklab, var(--_tone) 9%, transparent);\n  }\n  .icon {\n    display: grid;\n    flex: none;\n    margin-top: var(--pui-space-0-5);\n    color: var(--_tone);\n    transition: color var(--pui-duration-standard) var(--pui-ease-default);\n  }\n  /* a new tone's glyph arrives from a little smaller, so the change of meaning is seen as well as the colour */\n  .icon[data-changed] > svg {\n    animation: glyph var(--pui-duration-standard) var(--pui-ease-default) both;\n  }\n  @keyframes glyph {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.4 * var(--pui-motion-travel, 1));\n    }\n  }\n  .action {\n    margin-top: var(--pui-space-2);\n  }\n  /* a wide alert (from 36rem) keeps its action on the message's line, at the end; a narrow one puts it underneath */\n  .alert[data-wide] > .body {\n    display: grid;\n    grid-template-columns: minmax(0, 1fr) auto;\n    column-gap: var(--pui-space-4);\n    align-items: center;\n  }\n  .alert[data-wide] > .body > :not(.action) {\n    grid-column: 1;\n  }\n  .alert[data-wide] > .body > .action {\n    grid-column: 2;\n    grid-row: 1 / span var(--_rows, 1);\n    margin-top: 0;\n    justify-content: flex-end;\n  }\n  .icon > svg {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n  }\n  /* the fold: rows collapse to zero and the alert fades, then it unmounts; it arrives the same way, growing to its\n     height where it belongs (so what sits below glides down instead of jumping) */\n  .wrap {\n    display: grid;\n    grid-template-rows: 1fr;\n    transition:\n      grid-template-rows var(--pui-duration-standard) var(--pui-ease-standard),\n      opacity var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  @starting-style {\n    .wrap[data-state=\"open\"] {\n      grid-template-rows: 0fr;\n      opacity: 0;\n    }\n  }\n  .wrap[data-state=\"closed\"] {\n    grid-template-rows: 0fr;\n    opacity: 0;\n  }\n  /* clips the fold, with room for the look's outer edge and shadow (pop's lip, soft's drop) so they are not cut */\n  .clip {\n    min-height: 0;\n    margin: calc(var(--pui-space-1-5) * -1);\n    padding: var(--pui-space-1-5);\n    overflow: hidden;\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.alert {\n  composes: m-surface from global;\n}\n"},{"path":"beamline/components/alert/alert.tsx","type":"registry:file","target":"src/ui/beamline/components/alert/alert.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport type { CSSProperties, Ref } from \"react\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { AlertDefaults } from \"./defaults\";\nexport type { AlertDefaults } from \"./defaults\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from \"lucide-react\";\nimport { Button } from \"../button/button\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useWidthAs } from \"../../hooks/use-content-width\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport { focusAfterRemoval } from \"../../lib/focus\";\nimport styles from \"./alert.module.css\";\n\nexport type AlertTone = \"info\" | \"success\" | \"warning\" | \"danger\" | \"neutral\";\n\nexport interface AlertClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the wrap part; no override by default. */\n  wrap?: string;\n  /** Classes for the clip part; no override by default. */\n  clip?: string;\n  /** Classes for the icon part; no override by default. */\n  icon?: string;\n  /** Classes for the body part; no override by default. */\n  body?: string;\n  /** Classes for the title part; no override by default. */\n  title?: string;\n  /** Classes for the description part; no override by default. */\n  description?: string;\n  /** Classes for the action part; no override by default. */\n  action?: string;\n  /** Classes for the dismiss part; no override by default. */\n  dismiss?: string;\n}\n\nexport interface AlertProps {\n  /** Semantic colour of the displayed content. Defaults to \"info\". */\n  tone?: AlertTone;\n  /** Heading shown above the content. Defaults to no heading. */\n  title?: ReactNode;\n  /** What happened and what to do. */\n  children?: ReactNode;\n  /** Buttons or links: Retry, Update card. */\n  action?: ReactNode;\n  /** Replace the tone's icon, or false for none. */\n  icon?: ReactNode | false;\n  /** Controlled visibility; omitted by default to keep visibility locally. */\n  open?: boolean;\n  /** Initial visibility when uncontrolled; defaults to true. */\n  defaultOpen?: boolean;\n  /** Reports a request to close; no callback by default. */\n  onOpenChange?: (open: boolean) => void;\n  /** Shows a close button; defaults to whether onDismiss or onOpenChange is supplied. */\n  dismissible?: boolean;\n  /** Adds a close button; the alert folds away when open turns false. */\n  onDismiss?: () => void;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\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?: AlertClassNames;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n}\n\nconst ICON: Record<AlertTone, ReactNode> = {\n  info: <Info />,\n  success: <CheckCircle2 />,\n  warning: <AlertTriangle />,\n  danger: <AlertCircle />,\n  neutral: <Info />,\n};\n\n/** A message that stays until it is dealt with: what happened, what to do, and the action. Danger and warning are\n *  announced as they appear; a dismissed alert folds its height away. */\nexport function Alert({\n  tone: toneProp,\n  title,\n  children,\n  action,\n  icon,\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  dismissible,\n  onDismiss,\n  className,\n  style,\n  classNames,\n  ref,\n}: AlertProps) {\n  const i18n = useMessages(\"alert\", englishMessages);\n\n  const scopeDefaults = useComponentDefaults<AlertDefaults>(\"alert\", [\"tone\"]);\n  const tone = toneProp ?? scopeDefaults.tone ?? \"info\";\n\n  // an older caller that only listens to onDismiss decides the removal itself, so closing is guarded and reported beside\n  const [open, setOpen] = useControllable(openProp, defaultOpen);\n  const canDismiss = dismissible ?? !!(onDismiss || onOpenChange);\n  const [shown, setShown] = useState(open);\n  useEffect(() => {\n    if (open) setShown(true);\n  }, [open]);\n  const host = useRef<HTMLDivElement>(null);\n  const attach = useComposedRef(host, ref);\n  // a wide alert keeps its action on the message's line (read once per change of that answer)\n  const wide = useWidthAs(host, (w) => w >= 576, false, shown);\n  // the glyph of a tone it changed to arrives visibly; the first one is simply there\n  const firstTone = useRef(tone);\n  if (!shown) return null;\n  return (\n    <div\n      data-slot=\"alert-wrap\"\n      data-state={open ? \"open\" : \"closed\"}\n      className={cn(styles.wrap, classNames?.wrap)}\n      onTransitionEnd={(e) => {\n        if (!open && e.target === e.currentTarget) setShown(false);\n      }}\n    >\n      <div className={cn(styles.clip, classNames?.clip)} data-slot=\"alert-clip\">\n        <div\n          ref={attach}\n          style={{ \"--_rows\": (title ? 1 : 0) + (children ? 1 : 0) || 1, ...style } as CSSProperties}\n          data-slot=\"alert\"\n          data-tone={tone}\n          data-wide={wide && action ? \"\" : undefined}\n          role={tone === \"danger\" || tone === \"warning\" ? \"alert\" : \"status\"}\n          className={cn(styles.alert, \"text-sm leading-relaxed\", classNames?.root, className)}\n        >\n          {icon !== false && (\n            <span\n              key={tone}\n              aria-hidden\n              data-slot=\"alert-icon\"\n              data-changed={tone !== firstTone.current ? \"\" : undefined}\n              className={cn(styles.icon, classNames?.icon)}\n            >\n              {icon ?? ICON[tone]}\n            </span>\n          )}\n          <div data-slot=\"alert-body\" className={cn(styles.body, \"grid min-w-0 flex-1 gap-0.5\", classNames?.body)}>\n            {title && (\n              <p data-slot=\"alert-title\" className={cn(\"font-medium text-ink\", classNames?.title)}>\n                {title}\n              </p>\n            )}\n            {children && (\n              <div data-slot=\"alert-description\" className={classNames?.description}>\n                {children}\n              </div>\n            )}\n            {action && (\n              <div data-slot=\"alert-action\" className={cn(styles.action, \"flex flex-wrap gap-2\", classNames?.action)}>\n                {action}\n              </div>\n            )}\n          </div>\n          {canDismiss && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              aria-label={i18n(\"dismiss\")}\n              data-slot=\"alert-dismiss\"\n              className={cn(\"-me-1.5 -mt-1 shrink-0 text-ink-3\", classNames?.dismiss)}\n              onClick={(e) => {\n                focusAfterRemoval(e.currentTarget.closest(\"[data-slot=alert-wrap]\"));\n                // Existing event-only callers still decide when removal happens; the new state API can close locally.\n                if (dismissible !== undefined || onOpenChange !== undefined) setOpen(false);\n                onOpenChange?.(false);\n                onDismiss?.();\n              }}\n            >\n              <X />\n            </Button>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default Alert;\n"},{"path":"beamline/components/alert/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/alert/defaults.ts","content":"/** Pure allowed presentation defaults for Alert; no component or runtime imports. */\nexport interface AlertDefaults {\n  /** Shared tone setting; omit to inherit the surrounding scope or the component default. */\n  tone?: \"danger\" | \"success\" | \"warning\" | \"info\" | \"neutral\";\n}\n"},{"path":"beamline/components/alert/messages.ts","type":"registry:file","target":"src/ui/beamline/components/alert/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  dismiss: \"Dismiss\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/allocation-editor/allocation-editor.module.css","type":"registry:file","target":"src/ui/beamline/components/allocation-editor/allocation-editor.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* The editor has no frame of its own: it sits in the caller's card or dialog like a field. */\n  /* a size container: the footer's buttons share a narrow room (see the end) */\n  .root {\n    container-type: inline-size;\n    display: grid;\n    gap: var(--pui-gap-stack);\n    min-width: 0;\n    outline: none;\n  }\n  .root[data-disabled] {\n    opacity: var(--pui-opacity-disabled);\n  }\n\n  .header {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: baseline;\n    column-gap: var(--pui-space-3);\n    row-gap: var(--pui-space-1);\n  }\n  .total {\n    margin-inline-start: auto;\n    font-family: var(--pui-font-numeric);\n    font-variant-numeric: tabular-nums;\n    font-size: var(--pui-text-sm);\n    color: var(--pui-ink);\n    unicode-bidi: isolate;\n  }\n  .description {\n    flex-basis: 100%;\n    font-size: var(--pui-text-xs);\n    color: var(--pui-ink-3);\n  }\n\n  /* ── strip ─────────────────────────────────────────────────────────────────────────────────────────────────────── */\n  .strip {\n    display: grid;\n    gap: var(--pui-space-1-5);\n    padding-block-start: var(--pui-space-3); /* room for the lock glyph above a fixed seam */\n  }\n  .track {\n    position: relative;\n    height: var(--pui-control-height-sm);\n    border-radius: var(--pui-radius-control);\n    font-size: var(--pui-text-xs);\n    isolation: isolate;\n  }\n  .segment {\n    position: absolute;\n    inset-block: 0;\n    display: flex;\n    align-items: center;\n    overflow: hidden;\n    background: color-mix(in oklab, var(--_fill, transparent) 78%, var(--pui-surface));\n    cursor: default;\n  }\n  .segment:first-child {\n    border-start-start-radius: inherit;\n    border-end-start-radius: inherit;\n  }\n  /* 2 px internal seams between neighbours, drawn on each segment's inline-end edge */\n  .segment + .segment {\n    box-shadow: inset 2px 0 0 var(--pui-line-soft);\n  }\n  .segment + .segment:dir(rtl) {\n    box-shadow: inset -2px 0 0 var(--pui-line-soft);\n  }\n  .segment[data-kind=\"gap\"],\n  .segment[data-kind=\"unallocated\"] {\n    background: transparent;\n    color: var(--pui-ink-2);\n  }\n  .segment[data-kind=\"unallocated\"] {\n    background-image: repeating-linear-gradient(\n      135deg,\n      transparent 0 6px,\n      color-mix(in oklab, var(--pui-orange) 45%, transparent) 6px 7px\n    );\n    background-color: color-mix(in oklab, var(--pui-orange) 10%, transparent);\n  }\n  /* the excess sits over the last part's own end: opaque, so it reads red whatever that part's fill */\n  .over {\n    background: var(--pui-unmeasured-hatch), color-mix(in oklab, var(--pui-red) 34%, var(--pui-surface));\n  }\n  .limitMark {\n    position: absolute;\n    inset-block: -4px;\n    width: 2px;\n    margin-inline-start: -1px;\n    background: var(--pui-red);\n    border-radius: var(--pui-radius-pill);\n  }\n  .segmentLabel {\n    display: flex;\n    min-width: 0;\n    align-items: baseline;\n    gap: var(--pui-space-1);\n    padding-inline: var(--pui-space-2);\n    white-space: nowrap;\n    color: var(--pui-ink);\n    text-overflow: ellipsis;\n  }\n  /* label ink follows the fill it sits on: black or white, whichever reads better, chosen by the fill's luminance (y)\n     at WCAG's crossover 0.179, where both give 4.58:1, so 12px text keeps 4.5:1 on any series in any look (a perceptual\n     lightness cut put white on mid blues and teals at 3.65–4.44); browsers without relative colour keep the ink above */\n  @supports (color: color(from red xyz-d65 x y z)) {\n    .segment[data-kind=\"part\"] .segmentLabel {\n      color: color(\n        from color-mix(in oklab, var(--_fill, transparent) 78%, var(--pui-surface)) xyz-d65\n          calc(0.9505 * round(0.679 - y)) round(0.679 - y) calc(1.089 * round(0.679 - y))\n      );\n    }\n  }\n  .segment[data-kind=\"unallocated\"] .segmentLabel,\n  .segment[data-kind=\"gap\"] .segmentLabel {\n    color: var(--pui-ink-2);\n  }\n  .segmentLock {\n    width: 12px;\n    height: 12px;\n    flex-shrink: 0;\n    align-self: center;\n  }\n  .segmentName {\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n  .figure {\n    font-family: var(--pui-font-numeric);\n    font-variant-numeric: tabular-nums;\n    unicode-bidi: isolate;\n    direction: ltr;\n  }\n\n  .seam {\n    position: absolute;\n    inset-block: 0;\n    width: 2px;\n    margin-inline-start: -1px;\n    background: var(--pui-line-strong);\n    z-index: 2;\n  }\n  .seamLock {\n    position: absolute;\n    inset-block-start: -16px;\n    inset-inline-start: -5px;\n    width: 12px;\n    height: 12px;\n    color: var(--pui-ink-3);\n  }\n  .baseline {\n    position: absolute;\n    inset-block-start: calc(100% + 1px);\n    width: 2px;\n    height: 6px;\n    margin-inline-start: -1px;\n    background: var(--pui-ink-3);\n    border-radius: var(--pui-radius-pill);\n  }\n\n  /* the handle: a grip centred on the boundary with a hit box of at least 24 px (44 on a coarse pointer) */\n  .boundary {\n    --_hit: var(--pui-hit-min);\n    position: absolute;\n    inset-block: -4px;\n    width: var(--_hit);\n    margin-inline-start: calc(var(--_hit) / -2);\n    display: grid;\n    place-items: center;\n    z-index: 3;\n    cursor: ew-resize;\n    touch-action: pan-y;\n    outline: none;\n  }\n  @media (pointer: coarse) {\n    .boundary {\n      --_hit: 44px;\n    }\n  }\n  .grip {\n    position: relative;\n    width: 6px;\n    height: calc(100% - 4px);\n    border-radius: var(--pui-radius-pill);\n  }\n  .boundary:focus-visible .grip {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: 2px;\n  }\n  .boundary[data-limit] .grip {\n    box-shadow: 0 0 0 var(--pui-focus-width) color-mix(in oklab, var(--pui-red) 55%, transparent);\n  }\n\n  /* an applied entry, Cancel or an adopted save move widths without overshoot; keys and drags place at once */\n  .root[data-ease] :is(.segment, .boundary, .seam, .baseline) {\n    transition:\n      inset-inline-start var(--pui-duration-standard) var(--pui-ease-standard),\n      width var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n\n  .scale {\n    position: relative;\n    height: 16px;\n    font-family: var(--pui-font-numeric);\n    font-variant-numeric: tabular-nums;\n    font-size: var(--pui-text-2xs);\n    color: var(--pui-ink-3);\n  }\n  .tick {\n    position: absolute;\n    inset-block-start: 2px;\n    translate: -50% 0;\n    white-space: nowrap;\n    unicode-bidi: isolate;\n  }\n  .tick:dir(rtl) {\n    translate: 50% 0;\n  }\n  .tick[data-edge=\"start\"] {\n    translate: 0 0;\n  }\n  .tick[data-edge=\"end\"] {\n    translate: -100% 0;\n  }\n  .tick[data-edge=\"end\"]:dir(rtl) {\n    translate: 100% 0;\n  }\n\n  /* ── roster: unframed rows with soft seams ─────────────────────────────────────────────────────────────────────── */\n  .roster {\n    display: grid;\n    margin: 0;\n    padding: 0;\n    list-style: none;\n  }\n  .row {\n    display: grid;\n    gap: var(--pui-space-1);\n    padding-block: var(--pui-space-2);\n  }\n  .row + .row {\n    border-top: 1px solid var(--pui-line-soft);\n  }\n  .rowMain {\n    display: grid;\n    grid-template-columns: minmax(0, 1fr) 112px;\n    align-items: center;\n    gap: var(--pui-space-3);\n    min-height: 40px;\n  }\n  .rowLabel {\n    display: flex;\n    min-width: 0;\n    align-items: center;\n    gap: var(--pui-space-2);\n    font-size: var(--pui-text-sm);\n    color: var(--pui-ink);\n  }\n  .swatch {\n    width: 8px;\n    height: 8px;\n    flex-shrink: 0;\n    /* a legend swatch: a third of the chip radius, so it follows roundness without turning into a dot */\n    border-radius: calc(var(--pui-radius-chip) / 3);\n  }\n  .lock {\n    width: 14px;\n    height: 14px;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n  }\n  .amountFrame {\n    width: 112px;\n  }\n  .amountInput {\n    text-align: end;\n    font-family: var(--pui-font-numeric);\n    font-variant-numeric: tabular-nums;\n    unicode-bidi: plaintext;\n  }\n  .unit {\n    font-size: var(--pui-text-xs);\n    color: var(--pui-ink-3);\n  }\n  .amountText {\n    display: flex;\n    align-items: center;\n    justify-content: flex-end;\n    gap: var(--pui-space-1-5);\n    min-height: var(--pui-control-height-md);\n    font-family: var(--pui-font-numeric);\n    font-variant-numeric: tabular-nums;\n    font-size: var(--pui-text-sm);\n    color: var(--pui-ink-2);\n    unicode-bidi: isolate;\n  }\n  .notSet {\n    color: var(--pui-ink-3);\n  }\n  .rowSub {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: baseline;\n    column-gap: var(--pui-space-3);\n    row-gap: var(--pui-space-0-5);\n    font-size: var(--pui-text-xs);\n    line-height: 1.4;\n  }\n  .change {\n    display: inline-flex;\n    align-items: center;\n    gap: var(--pui-space-1);\n    font-size: var(--pui-text-xs);\n    color: var(--pui-ink-2);\n    animation: change-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  /* a change mark arrives in --pui-motion-in, rising 2px (a fade under reduced motion) */\n  @keyframes change-in {\n    from {\n      opacity: 0;\n      translate: 0 calc(2px * var(--pui-motion-travel, 1));\n    }\n  }\n  .arrow {\n    width: 12px;\n    height: 12px;\n    color: var(--pui-ink-3);\n  }\n  .arrow:dir(rtl) {\n    transform: scaleX(-1);\n  }\n  .delta {\n    font-family: var(--pui-font-numeric);\n    font-variant-numeric: tabular-nums;\n    color: var(--pui-ink);\n    unicode-bidi: isolate;\n    direction: ltr;\n  }\n  .notSetWord {\n    color: var(--pui-ink-3);\n  }\n  .note {\n    display: inline-flex;\n    align-items: baseline;\n    gap: var(--pui-space-1);\n    color: var(--pui-ink-3);\n  }\n  .noteError {\n    color: var(--pui-red);\n  }\n  .noteIcon {\n    position: relative;\n    inset-block-start: 2px;\n    width: 14px;\n    height: 14px;\n    flex-shrink: 0;\n  }\n\n  .partner {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    gap: var(--pui-space-2);\n    padding-block-start: var(--pui-space-1);\n  }\n  .partnerPrompt {\n    font-size: var(--pui-text-xs);\n    color: var(--pui-ink-2);\n  }\n\n  .status {\n    display: flex;\n    align-items: baseline;\n    gap: var(--pui-space-1-5);\n    margin: 0;\n    font-size: var(--pui-text-xs);\n    color: var(--pui-ink-2);\n  }\n  .statusError {\n    color: var(--pui-red);\n  }\n  .statusWarning {\n    color: var(--pui-orange);\n  }\n\n  .footer {\n    display: flex;\n    justify-content: flex-end;\n    gap: var(--pui-space-2);\n  }\n  @container (max-width: 26rem) {\n    .footerButton {\n      flex: 1;\n    }\n  }\n\n  .skeleton {\n    display: grid;\n    gap: var(--pui-space-2);\n  }\n  .skeletonTrack,\n  .skeletonRow {\n    display: block;\n    border-radius: var(--pui-radius-control);\n    background: var(--pui-surface-2);\n  }\n  .skeletonTrack {\n    height: var(--pui-control-height-sm);\n  }\n  .skeletonRow {\n    height: 40px;\n  }\n}\n"},{"path":"beamline/components/allocation-editor/allocation-editor.tsx","type":"registry:file","target":"src/ui/beamline/components/allocation-editor/allocation-editor.tsx","content":"\"use client\";\nimport { type Translator } from \"../../runtime/messages\";\nimport { numberFormatter, useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useLocale } from \"../../runtime/localization\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { AlertCircle, ArrowRight, Lock } from \"lucide-react\";\nimport { Alert } from \"../alert/alert\";\nimport { Button } from \"../button/button\";\nimport { Select } from \"../select/select\";\nimport { FieldFrame, fieldControlClass } from \"../_shared/field\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useDrag } from \"../../hooks/use-drag\";\nimport { useElementRef } from \"../../hooks/use-element-ref\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { parseLocaleNumber } from \"../../lib/number\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport { useElapsed } from \"../../hooks/use-elapsed\";\nimport {\n  UNALLOCATED,\n  applyTransfer,\n  candidates,\n  netTransfers,\n  partyLabel,\n  reach,\n  remainder,\n  sumOf,\n  tidy,\n  walk,\n  type AllocationValue,\n  type Model,\n  type Transfer,\n} from \"./allocation-model\";\nimport styles from \"./allocation-editor.module.css\";\n\nexport { UNALLOCATED } from \"./allocation-model\";\nexport type { AllocationValue } from \"./allocation-model\";\n\n/** A part of the whole, in display order. Order is meaningful only when `order=\"sequence\"`. */\nexport interface AllocationItem {\n  /** Stable identity, never derived from the label; must not equal UNALLOCATED. */\n  id: string;\n  /** Text displayed for this item. Required. */\n  label: string;\n  /** \"part\" (default) is a share people plan; \"gap\" is caller-declared unassigned time at this position (\"Break\"). */\n  kind?: \"part\" | \"gap\";\n  /** Least amount. Default 0. */\n  min?: number;\n  /** Greatest amount. Default `total`. */\n  max?: number;\n  /** A locked part keeps its amount: no handle beside it, no exact field, never a partner. */\n  locked?: boolean;\n  /** Why it is locked, shown in its row at rest. */\n  lockReason?: ReactNode;\n  /** One line under the row's label. */\n  description?: ReactNode;\n  /** Data colour token name (\"series-2\"), for a part the user matches elsewhere (a chart's legend). Default: one hue in\n   *  steps by position, so no part reads as a status; a locked part is a neutral ink tone. Gaps take none. */\n  color?: string;\n}\n\n/** One zero-sum change. `amount` is a positive whole multiple of `step`. */\nexport type AllocationTransfer = Transfer;\n\nexport interface AllocationChange {\n  /** Stable identifier used to match this record across updates. Required. */\n  id: string;\n  /** Amount before the proposed change; null means unset. Required. */\n  from: number | null;\n  /** Amount after the proposed change; null means unset. Required. */\n  to: number | null;\n}\n\nexport interface AllocationSaveRequest {\n  /** Complete allocation amounts proposed for saving. Required. */\n  value: AllocationValue;\n  /** Items whose amount differs from the saved baseline, in display order. */\n  changes: AllocationChange[];\n  /** Transfers since the baseline, netted per ordered pair. */\n  transfers: Omit<AllocationTransfer, \"via\">[];\n}\n\n/** Throw this (or any Error) from onSave to refuse the save; `itemErrors` mark rows invalid with their own words. */\nexport interface AllocationSaveError extends Error {\n  /** Field-specific errors keyed by allocation item id. Omitted by default. */\n  itemErrors?: Record<string, ReactNode>;\n}\n\nexport interface AllocationMessages {\n  /** Builds the allocated message; defaults to the built-in English wording. */\n  allocated: (sum: string, total: string) => string;\n  /** Builds the set trades any message; defaults to the built-in English wording. */\n  setTradesAny: () => string;\n  /** Builds the no neighbour message; defaults to the built-in English wording. */\n  noNeighbour: () => string;\n  /** Builds the takes from unallocated message; defaults to the built-in English wording. */\n  takesFromUnallocated: () => string;\n  /** Builds the not neighbours message; defaults to the built-in English wording. */\n  notNeighbours: (a: string, b: string) => string;\n  /** Builds the not set part message; defaults to the built-in English wording. */\n  notSetPart: (label: string) => string;\n  /** Builds the enter amount message; defaults to the built-in English wording. */\n  enterAmount: (label: string) => string;\n  /** Builds the cannot give away message; defaults to the built-in English wording. */\n  cannotGiveAway: (label: string) => string;\n  /** Builds the choose partner spoken message; defaults to the built-in English wording. */\n  choosePartnerSpoken: (amount: string, partners: string[], direction: \"take\" | \"give\") => string;\n  /** Builds the not applied message; defaults to the built-in English wording. */\n  notApplied: (label: string) => string;\n  /** Builds the assign before save message; defaults to the built-in English wording. */\n  assignBeforeSave: (amount: string) => string;\n  /** Builds the remove before save message; defaults to the built-in English wording. */\n  removeBeforeSave: (amount: string) => string;\n  /** Builds the over by message; defaults to the built-in English wording. */\n  overBy: (amount: string) => string;\n  /** Builds the no changes message; defaults to the built-in English wording. */\n  noChanges: () => string;\n  /** Builds the changed elsewhere message; defaults to the built-in English wording. */\n  changedElsewhere: (summary: string) => string;\n  /** Builds the items changed message; defaults to the built-in English wording. */\n  itemsChanged: () => string;\n  /** Builds the saved with changes message; defaults to the built-in English wording. */\n  savedWithChanges: (summary: string) => string;\n  /** Builds the saving message; defaults to the built-in English wording. */\n  saving: (seconds?: number) => string;\n  /** Builds the boundary label message; defaults to the built-in English wording. */\n  boundaryLabel: (a: string, b: string) => string;\n  /** Builds the pair value message; defaults to the built-in English wording. */\n  pairValue: (a: string, b: string) => string;\n  /** Builds the roster label message; defaults to the built-in English wording. */\n  rosterLabel: () => string;\n  /** Builds the trades with message; defaults to the built-in English wording. */\n  tradesWith: (partners: string[]) => string;\n  /** Builds the take from message; defaults to the built-in English wording. */\n  takeFrom: (amount: string, partner: string) => string;\n  /** Builds the give to message; defaults to the built-in English wording. */\n  giveTo: (amount: string, partner: string) => string;\n  /** Builds the choose partner message; defaults to the built-in English wording. */\n  choosePartner: (amount: string, direction: \"take\" | \"give\") => string;\n  /** Builds the moved message; defaults to the built-in English wording. */\n  moved: (amount: string, from: string, to: string) => string;\n  /** Builds the at min message; defaults to the built-in English wording. */\n  atMin: (label: string, amount: string) => string;\n  /** Builds the at max message; defaults to the built-in English wording. */\n  atMax: (label: string, amount: string) => string;\n  /** Builds the locked message; defaults to the built-in English wording. */\n  locked: (label: string) => string;\n  /** Builds the not step message; defaults to the built-in English wording. */\n  notStep: (step: string) => string;\n  /** Builds the not number message; defaults to the built-in English wording. */\n  notNumber: () => string;\n  /** Builds the unallocated message; defaults to the built-in English wording. */\n  unallocated: () => string;\n  /** Builds the over message; defaults to the built-in English wording. */\n  over: () => string;\n  /** Builds the not set message; defaults to the built-in English wording. */\n  notSet: () => string;\n  /** Builds the discarded message; defaults to the built-in English wording. */\n  discarded: () => string;\n  /** Builds the saved message; defaults to the built-in English wording. */\n  saved: () => string;\n  /** Builds the save failed message; defaults to the built-in English wording. */\n  saveFailed: () => string;\n}\n\nfunction localizedMESSAGES(i18n: Translator<typeof englishMessages>): AllocationMessages {\n  return {\n    allocated: (sum, total) => i18n(\"ofAllocated\", { sum: sum, total: total }),\n    setTradesAny: () => i18n(\"tradesWithAnyUnlockedPart\"),\n    noNeighbour: () => i18n(\"noUnlockedNeighbour\"),\n    takesFromUnallocated: () => i18n(\"takesFromUnallocated\"),\n    notNeighbours: (a, b) => i18n(\"andAreNotNeighbours\", { a: a, b: b }),\n    notSetPart: (label) => i18n(\"isNotSet\", { label: label }),\n    enterAmount: (label) => i18n(\"enterAnAmountFor\", { label: label }),\n    cannotGiveAway: (label) => i18n(\"canTGiveTimeAwayHere\", { label: label }),\n    choosePartnerSpoken: (amount, partners, direction) =>\n      direction === \"take\"\n        ? i18n(\"chooseWhereComeFrom\", { amount: amount, value: i18n.list(partners, { type: \"disjunction\" }) })\n        : i18n(\"chooseWhereGo\", { amount: amount, value: i18n.list(partners, { type: \"disjunction\" }) }),\n    notApplied: (label) => i18n(\"changeNotApplied\", { label: label }),\n    assignBeforeSave: (amount) => i18n(\"assignTheUnallocatedBeforeSaving\", { amount: amount }),\n    removeBeforeSave: (amount) => i18n(\"removeBeforeSaving\", { amount: amount }),\n    overBy: (amount) => i18n(\"overBy\", { amount: amount }),\n    noChanges: () => i18n(\"noChangesToSave\"),\n    changedElsewhere: (summary) => i18n(\"thisPlanChangedElsewhereCancelToUse\", { summary: summary }),\n    itemsChanged: () => i18n(\"thePartsChangedYourUnsavedChangesWere\"),\n    savedWithChanges: (summary) => i18n(\"savedWithChanges\", { summary: summary }),\n    saving: (seconds) => (seconds ? i18n(\"savingS\", { seconds: seconds }) : i18n(\"saving\")),\n    boundaryLabel: (a, b) => i18n(\"betweenAnd\", { a: a, b: b }),\n    pairValue: (a, b) => `${a}, ${b}`,\n    rosterLabel: () => i18n(\"amounts\"),\n    tradesWith: (partners) => i18n(\"tradesWith\", { value: i18n.list(partners, { type: \"disjunction\" }) }),\n    takeFrom: (amount, partner) => i18n(\"from\", { amount: amount, partner: partner }),\n    giveTo: (amount, partner) => i18n(\"to\", { amount: amount, partner: partner }),\n    choosePartner: (amount, direction) =>\n      direction === \"take\" ? i18n(\"takeFrom\", { amount: amount }) : i18n(\"giveTo\", { amount: amount }),\n    moved: (amount, from, to) => i18n(\"movedFromTo\", { amount: amount, from: from, to: to }),\n    atMin: (label, amount) => i18n(\"canTGoBelow\", { label: label, amount: amount }),\n    atMax: (label, amount) => i18n(\"canTGoAbove\", { label: label, amount: amount }),\n    locked: (label) => i18n(\"isLocked\", { label: label }),\n    notStep: (step) => i18n(\"changeInStepsOf\", { step: step }),\n    notNumber: () => i18n(\"enterANumber\"),\n    unallocated: () => i18n(\"unallocated\"),\n    over: () => i18n(\"over\"),\n    notSet: () => i18n(\"notSet\"),\n    discarded: () => i18n(\"changesDiscarded\"),\n    saved: () => i18n(\"saved\"),\n    saveFailed: () => i18n(\"couldnTSave\"),\n  };\n}\n\nexport type AllocationSlot =\n  | \"root\"\n  | \"header\"\n  | \"title\"\n  | \"total\"\n  | \"description\"\n  | \"strip\"\n  | \"track\"\n  | \"segment\"\n  | \"segmentLabel\"\n  | \"boundary\"\n  | \"seam\"\n  | \"baseline\"\n  | \"scale\"\n  | \"tick\"\n  | \"roster\"\n  | \"row\"\n  | \"rowLabel\"\n  | \"amount\"\n  | \"change\"\n  | \"note\"\n  | \"partner\"\n  | \"partnerOption\"\n  | \"status\"\n  | \"live\"\n  | \"error\"\n  | \"footer\"\n  | \"cancel\"\n  | \"save\";\n\nexport interface AllocationEditorProps {\n  /** The whole's name and the group's accessible name: \"Session plan\". */\n  label: string;\n  /** Supporting text beneath the label or heading. Defaults to no supporting text. */\n  description?: ReactNode;\n  /** Items rendered in the component. Required. */\n  items: AllocationItem[];\n  /** The fixed whole, in your unit. Never changed by the component. */\n  total: number;\n  /** Smallest transfer; every transfer is a whole multiple. Default 1. */\n  step?: number;\n  /** Shift+arrow / Page keys. Default 5 × step. */\n  largeStep?: number;\n  /** Unit printed after allocation amounts; omitted by default to show amounts without a unit. */\n  unit?: string;\n  /** Formats allocation amounts; defaults to locale-aware numbers followed by unit when supplied. */\n  format?: (amount: number) => string;\n  /** Labels at boundaries from the cumulative offset, e.g. clock times. false hides. Default true. */\n  scale?: boolean | ((offset: number) => string);\n  /** Locale used for number, date or text formatting. Defaults to the runtime locale. */\n  locale?: string;\n  /** \"sequence\" (default): neighbours trade, boundaries drag. \"set\": no handles; an entry asks who gives. */\n  order?: \"sequence\" | \"set\";\n  /** Controlled amounts keyed by allocation item id; omit to use defaultValue. */\n  value?: AllocationValue;\n  /** Initial amounts when uncontrolled; defaults to savedValue, leaving absent item amounts unset. */\n  defaultValue?: AllocationValue;\n  /** Reports amounts keyed by allocation item id after an edit; no callback by default. */\n  onValueChange?: (value: AllocationValue, transfer: AllocationTransfer | null) => void;\n  /** Once per completed gesture: drag release, key press, applied entry, Cancel, adopted save. */\n  onValueCommit?: (value: AllocationValue) => void;\n  /** The persisted baseline; Cancel returns to it. Default: the first draft, moved by an accepted save. */\n  savedValue?: AllocationValue;\n  /** Your rule for one candidate transfer: true allows, a string refuses with those words. */\n  canTransfer?: (transfer: AllocationTransfer, next: AllocationValue) => true | string;\n  /** Shows Cancel and Save. Resolve with the persisted value (or nothing); throw to refuse and keep the draft. */\n  onSave?: (request: AllocationSaveRequest) => Promise<AllocationValue | void>;\n  /** Runs the cancellation action; no handler by default. */\n  onCancel?: () => void;\n  /** Text on the save action. Defaults to \"Save\". */\n  saveLabel?: string;\n  /** Text after a successful save. Defaults to \"Saved\". */\n  savedLabel?: string;\n  /** Text on the cancel action. Defaults to \"Cancel\". */\n  cancelLabel?: string;\n  /** Error message shown in place of the usual supporting text. Defaults to no error message. */\n  error?: ReactNode;\n  /** Shows the loading state while data or an operation is pending. Defaults to false. */\n  loading?: boolean;\n  /** Message shown when no data is available. Defaults to \"Nothing to allocate yet.\". */\n  emptyLabel?: ReactNode;\n  /** Displays the value while preventing edits. Defaults to false. */\n  readOnly?: boolean;\n  /** Prevents interaction with the control. Defaults to false. */\n  disabled?: boolean;\n  /** Hidden inputs `${name}.${id}` carry the draft in a native form submit. */\n  name?: string;\n  /** Functions producing allocation feedback text; defaults to the built-in English messages. */\n  messages?: Partial<AllocationMessages>;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\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?: Partial<Record<AllocationSlot, string>>;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n}\n\nconst decimals = (n: number) => (String(n).split(\".\")[1] ?? \"\").length;\nconst same = (a: AllocationValue, b: AllocationValue, ids: string[]) =>\n  ids.every((id) => (a[id] ?? null) === (b[id] ?? null));\nconst fill = (items: AllocationItem[], value: AllocationValue | undefined) =>\n  Object.fromEntries(items.map((item) => [item.id, value?.[item.id] ?? null])) as AllocationValue;\n\nlet measureCanvas: CanvasRenderingContext2D | null = null;\n/** Width of a label at the strip's caption size, to decide whether it fits inside its segment. */\nfunction textWidth(text: string, font: string) {\n  if (typeof document === \"undefined\") return text.length * 7;\n  measureCanvas ??= document.createElement(\"canvas\").getContext(\"2d\");\n  if (!measureCanvas) return text.length * 7;\n  measureCanvas.font = font;\n  return measureCanvas.measureText(text).width;\n}\n\ntype Pending = {\n  id: string;\n  amount: number;\n  direction: \"take\" | \"give\";\n  options: { party: string; transfer: Transfer }[];\n};\ntype Drag = { boundary: number; started: boolean; from: AllocationValue; limited: boolean };\n\n/**\n * Parts of a fixed whole, edited as transfers. Every change moves an amount between two named parts, so the whole is\n * right after every operation: drag or step a boundary between neighbours, or type an exact amount and the editor names\n * the part that gives or takes (and asks when more than one could). Locks, limits and your `canTransfer` refuse with\n * words; nothing is clamped, spread or zeroed out of sight, and Save waits for your handler.\n */\nexport function AllocationEditor({\n  label,\n  description,\n  items,\n  total,\n  step = 1,\n  largeStep,\n  unit,\n  format: formatProp,\n  scale = true,\n  locale: localeProp,\n  order = \"sequence\",\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  savedValue,\n  canTransfer,\n  onSave,\n  onCancel,\n  saveLabel: saveLabelProp,\n  savedLabel: savedLabelProp,\n  cancelLabel: cancelLabelProp,\n  error,\n  loading,\n  emptyLabel: emptyLabelProp,\n  readOnly,\n  disabled,\n  name,\n  messages: messagesProp,\n  className,\n  style,\n  classNames,\n  ref,\n}: AllocationEditorProps) {\n  const i18n = useMessages(\"allocation-editor\", englishMessages);\n  const saveLabel = saveLabelProp === undefined ? i18n(\"saveLabel\") : saveLabelProp;\n  const savedLabel = savedLabelProp === undefined ? i18n(\"savedLabel\") : savedLabelProp;\n  const cancelLabel = cancelLabelProp === undefined ? i18n(\"cancelLabel\") : cancelLabelProp;\n  const emptyLabel = emptyLabelProp === undefined ? i18n(\"emptyLabel\") : emptyLabelProp;\n\n  const locale = useLocale(localeProp);\n\n  const text = useMemo(() => ({ ...localizedMESSAGES(i18n), ...messagesProp }), [messagesProp, i18n]);\n  const ids = useMemo(() => items.map((item) => item.id), [items]);\n  const idKey = ids.join(\"\\u0000\");\n  const numberFormat = useMemo(\n    () => numberFormatter(locale, { maximumFractionDigits: Math.max(decimals(step), 2) }),\n    [locale, step],\n  );\n  const format = useCallback(\n    (amount: number) => formatProp?.(amount) ?? `${numberFormat.format(amount)}${unit ? ` ${unit}` : \"\"}`,\n    [formatProp, numberFormat, unit],\n  );\n  const big = Math.max(1, Math.round((largeStep ?? step * 5) / step));\n  const model: Model = useMemo(\n    () => ({\n      items,\n      total,\n      step,\n      order,\n      format,\n      canTransfer,\n      text: {\n        locked: text.locked,\n        atMin: text.atMin,\n        atMax: text.atMax,\n        notNeighbours: text.notNeighbours,\n        notSetPart: text.notSetPart,\n        cannotGiveAway: text.cannotGiveAway,\n        unallocated: text.unallocated,\n        over: text.over,\n      },\n    }),\n    [items, total, step, order, format, canTransfer, text],\n  );\n\n  // ── state: the draft, its baseline and the transfers between them ─────────────────────────────────────────────\n  // a change reports the transfer that made it, and a new set of parts resets the draft without a report: the hook\n  // holds the draft and the report is made beside it. The baseline follows savedValue, or what the last save adopted.\n  const [held, setHeld] = useControllable<AllocationValue>(valueProp, () => fill(items, defaultValue ?? savedValue));\n  const value = useMemo(() => fill(items, held), [items, held]);\n  const [heldBaseline, setBaseline] = useControllable<AllocationValue>(savedValue, () =>\n    fill(items, savedValue ?? defaultValue),\n  );\n  const baseline = useMemo(() => fill(items, heldBaseline), [items, heldBaseline]);\n  const log = useRef<Transfer[]>([]);\n  const dirty = !same(value, baseline, ids);\n  const [saving, setSaving] = useState(false);\n  const [savedNote, setSavedNote] = useState<string | null>(null);\n  const [saveError, setSaveError] = useState<{ message: ReactNode; items: Record<string, ReactNode> } | null>(null);\n  const [reason, setReason] = useState<{ text: string; boundary?: number; row?: string } | null>(null);\n  const [limit, setLimit] = useState<{ boundary: number; edge: \"start\" | \"end\" } | null>(null);\n  const [live, setLive] = useState(\"\");\n  const [drafts, setDrafts] = useState<Record<string, string>>({});\n  const [pending, setPending] = useState<Pending | null>(null);\n  const [partnerChoice, setPartnerChoice] = useState(\"\");\n  const [ease, setEase] = useState(false);\n  const [elsewhere, setElsewhere] = useState<string | null>(null);\n  const [drag, setDrag] = useState<Drag | null>(null);\n  const reduced = useReducedMotionSafe();\n  const inert = readOnly || disabled || saving;\n\n  const root = useRef<HTMLDivElement>(null);\n  const attach = useComposedRef(root, ref);\n  // the track appears once items have loaded: the boundaries' drag attaches to it then\n  const [track, attachTrack] = useElementRef<HTMLDivElement>();\n  const fields = useRef(new Map<string, HTMLInputElement>());\n  const boundaryEls = useRef(new Map<number, HTMLDivElement>());\n  const focusOption = useRef(false);\n  const [trackWidth, setTrackWidth] = useState(0);\n  const [font, setFont] = useState(\"12px sans-serif\");\n  useLayoutEffect(() => {\n    const el = track.current;\n    if (!el) return;\n    const style = getComputedStyle(el);\n    setFont(`12px ${style.fontFamily}`);\n    const observer = new ResizeObserver(() => setTrackWidth(el.getBoundingClientRect().width));\n    observer.observe(el);\n    setTrackWidth(el.getBoundingClientRect().width);\n    return () => observer.disconnect();\n  }, [track, loading, items.length, i18n]);\n\n  // the set of parts changed: an unsaved draft for other parts would be meaningless\n  const lastIds = useRef(idKey);\n  useEffect(() => {\n    if (lastIds.current === idKey) return;\n    lastIds.current = idKey;\n    log.current = [];\n    setDrafts({});\n    setPending(null);\n    setHeld(fill(items, heldBaseline));\n    setLive(text.itemsChanged());\n  }, [idKey]); // eslint-disable-line react-hooks/exhaustive-deps -- only a change of parts resets\n\n  // the baseline moved elsewhere: keep the person's draft and say so; follow it when there is no draft\n  const lastSaved = useRef(savedValue);\n  useEffect(() => {\n    const previous = lastSaved.current;\n    lastSaved.current = savedValue;\n    if (!savedValue || !previous || same(fill(items, previous), fill(items, savedValue), ids)) return;\n    if (same(value, fill(items, previous), ids)) {\n      setHeld(fill(items, savedValue));\n      setElsewhere(null);\n    } else\n      setElsewhere(\n        items\n          .map((item) => (savedValue[item.id] == null ? text.notSet() : numberFormat.format(savedValue[item.id]!)))\n          .join(\" / \"),\n      );\n  }, [savedValue]); // eslint-disable-line react-hooks/exhaustive-deps -- reacts to the caller's baseline only\n\n  const elapsed = useElapsed(!!saving);\n\n  useEffect(() => {\n    if (!focusOption.current || !pending) return;\n    focusOption.current = false;\n    root.current?.querySelector<HTMLElement>(\"[data-slot=allocation-partner-option]\")?.focus();\n  }, [pending]);\n\n  const commitValue = (next: AllocationValue, transfer: Transfer | null) => {\n    setHeld(next);\n    onValueChange?.(next, transfer);\n  };\n  const labelOf = (id: string, at: AllocationValue = value) => partyLabel(model, id, at);\n  const amountText = (id: string, at: AllocationValue) => {\n    const amount = at[id];\n    return amount == null ? text.notSet() : format(amount);\n  };\n  const pairText = (a: string, b: string, at: AllocationValue) =>\n    text.pairValue(\n      `${labelOf(a, at)} ${a === UNALLOCATED ? format(Math.abs(remainder(model, at))) : amountText(a, at)}`,\n      `${labelOf(b, at)} ${b === UNALLOCATED ? format(Math.abs(remainder(model, at))) : amountText(b, at)}`,\n    );\n  const everything = (at: AllocationValue) =>\n    items.map((item) => `${item.label} ${amountText(item.id, at)}`).join(\", \");\n  /** Any operation other than finishing the pending entry discards it, and says so. */\n  const discardPending = (except?: string) => {\n    if (!pending || pending.id === except) return null;\n    const message = text.notApplied(labelOf(pending.id));\n    setPending(null);\n    setDrafts((all) => {\n      const next = { ...all };\n      delete next[pending.id];\n      return next;\n    });\n    return message;\n  };\n  /** Shared by every input: record the transfers, leave the resolved/saved state, move the draft. */\n  const move = (next: AllocationValue, transfers: Transfer[], gentle: boolean) => {\n    log.current.push(...transfers);\n    setSavedNote(null);\n    setEase(gentle && !reduced);\n    commitValue(next, transfers.length === 1 ? transfers[0] : (transfers.at(-1) ?? null));\n  };\n\n  // ── the strip: segments, boundaries (pairs of neighbours) and seams ──────────────────────────────────────────────\n  const rest = remainder(model, value);\n  const sum = sumOf(items, value);\n  const scaleTotal = Math.max(total, sum);\n  const pct = (amount: number) => `${(amount / scaleTotal) * 100}%`;\n  const drawn = items.filter((item) => value[item.id] != null);\n  const offsets = new Map<string, number>();\n  {\n    let at = 0;\n    for (const item of drawn) {\n      offsets.set(item.id, at);\n      at = tidy(at + (value[item.id] ?? 0));\n    }\n  }\n  type Boundary = { index: number; a: string; b: string; offset: number };\n  const boundaries: Boundary[] = [];\n  const seams: { offset: number; locked: string }[] = [];\n  if (order === \"sequence\") {\n    drawn.forEach((item, i) => {\n      const next = drawn[i + 1];\n      const offset = tidy((offsets.get(item.id) ?? 0) + (value[item.id] ?? 0));\n      if (next) {\n        const neighbours = Math.abs(items.indexOf(item) - items.indexOf(next)) === 1;\n        if (item.locked || next.locked) seams.push({ offset, locked: item.locked ? item.id : next.id });\n        else if (neighbours) boundaries.push({ index: boundaries.length, a: item.id, b: next.id, offset });\n      } else if (rest > 0 && !item.locked)\n        boundaries.push({ index: boundaries.length, a: item.id, b: UNALLOCATED, offset });\n    });\n  }\n  const stepsInWhole = Math.ceil(scaleTotal / step) + 1;\n  const baselineOffsets = useMemo(() => {\n    if (!dirty) return [];\n    let at = 0;\n    const marks: number[] = [];\n    for (const item of items) {\n      if (baseline[item.id] == null) continue;\n      at = tidy(at + (baseline[item.id] ?? 0));\n      marks.push(at);\n    }\n    return marks.filter((mark) => mark > 0 && mark < scaleTotal);\n  }, [dirty, items, baseline, scaleTotal]);\n\n  // ── directional moves: boundary keys and drag ────────────────────────────────────────────────────────────────────\n  /** Positive `steps` grow the part before the boundary (toward the end), negative shrink it. */\n  const moveBoundary = (boundary: Boundary, steps: number, from: AllocationValue, via: Transfer[\"via\"]) => {\n    const grow = steps > 0;\n    const result = walk(\n      model,\n      from,\n      grow ? boundary.b : boundary.a,\n      grow ? boundary.a : boundary.b,\n      Math.abs(steps),\n      via,\n    );\n    const transfers =\n      result.taken > 0\n        ? [\n            {\n              from: grow ? boundary.b : boundary.a,\n              to: grow ? boundary.a : boundary.b,\n              amount: tidy(result.taken * step),\n              via,\n            },\n          ]\n        : [];\n    return { ...result, transfers, edge: grow ? (\"end\" as const) : (\"start\" as const) };\n  };\n  const rtl = () => (root.current ? getComputedStyle(root.current).direction === \"rtl\" : false);\n\n  const onBoundaryKey = (boundary: Boundary) => (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Escape\" && drag) {\n      e.preventDefault();\n      e.stopPropagation();\n      commitValue(drag.from, null);\n      setDrag(null);\n      return;\n    }\n    if (inert) return;\n    const visual = rtl() ? -1 : 1;\n    const amountOf = (n: number) => (e.shiftKey ? n * big : n);\n    const map: Record<string, number> = {\n      ArrowRight: amountOf(visual),\n      ArrowLeft: amountOf(-visual),\n      ArrowUp: amountOf(1),\n      ArrowDown: amountOf(-1),\n      PageUp: big,\n      PageDown: -big,\n      End: stepsInWhole,\n      Home: -stepsInWhole,\n    };\n    const steps = map[e.key];\n    if (steps === undefined) return;\n    e.preventDefault();\n    const discarded = discardPending();\n    const result = moveBoundary(boundary, steps, value, \"keyboard\");\n    if (result.transfers.length) move(result.value, result.transfers, false);\n    if (result.reason) {\n      setLimit({ boundary: boundary.index, edge: result.edge });\n      setReason({ text: result.reason, boundary: boundary.index });\n      setLive(result.reason);\n    } else {\n      setLimit(null);\n      setReason(null);\n      setLive(discarded ?? \"\");\n    }\n    if (discarded && result.reason) setLive(`${discarded} ${result.reason}`);\n    if (result.transfers.length) onValueCommit?.(result.value);\n  };\n\n  // every boundary on one shared drag over the track (the boundary found from the press): focus moves to it, the value\n  // follows in steps, and Escape or a lost pointer puts back the value from before the press\n  const dragging = useRef<Drag | null>(null);\n  const hold = (d: Drag | null) => {\n    dragging.current = d;\n    setDrag(d);\n  };\n  const boundaryOf = (d: Drag) => boundaries.find((candidate) => candidate.index === d.boundary);\n  useDrag(\n    track,\n    {\n      onPress: (_, e) => {\n        const el = (e.target as Element).closest<HTMLElement>(\"[data-slot=allocation-boundary]\");\n        if (inert || dragging.current || !el?.dataset.index) return false;\n        el.focus();\n        hold({ boundary: Number(el.dataset.index), started: false, from: value, limited: false });\n      },\n      onMove: (p) => {\n        const d = dragging.current;\n        const boundary = d && boundaryOf(d);\n        if (!d || !boundary) return;\n        const discarded = d.started ? null : discardPending();\n        if (discarded) setLive(discarded);\n        const width = track.current?.getBoundingClientRect().width || 1;\n        const steps = Math.round(((p.dx * (rtl() ? -1 : 1)) / width) * (scaleTotal / step));\n        const result = moveBoundary(boundary, steps, d.from, \"drag\");\n        if (!same(result.value, value, ids)) {\n          setSavedNote(null);\n          setEase(false);\n          commitValue(result.value, result.transfers[0] ?? null);\n        }\n        if (result.reason && !d.limited) {\n          setReason({ text: result.reason, boundary: boundary.index });\n          setLive(result.reason);\n        }\n        setLimit(result.reason ? { boundary: boundary.index, edge: result.edge } : null);\n        hold({ ...d, started: true, limited: d.limited || !!result.reason });\n      },\n      onEnd: () => {\n        const d = dragging.current;\n        const boundary = d && boundaryOf(d);\n        hold(null);\n        if (!d || !boundary || same(value, d.from, ids)) return;\n        // the drag moved one pair: record it as one netted transfer from where it started\n        const before = d.from[boundary.a] ?? 0;\n        const after = value[boundary.a] ?? 0;\n        const amount = tidy(after - before);\n        if (boundary.a !== UNALLOCATED && amount !== 0)\n          log.current.push(\n            amount > 0\n              ? { from: boundary.b, to: boundary.a, amount, via: \"drag\" }\n              : { from: boundary.a, to: boundary.b, amount: -amount, via: \"drag\" },\n          );\n        if (!d.limited) setLive(pairText(boundary.a, boundary.b, value).replace(/$/, \".\"));\n        onValueCommit?.(value);\n      },\n      onTap: () => hold(null),\n      onCancel: () => {\n        const d = dragging.current;\n        hold(null);\n        if (d) commitValue(d.from, null);\n      },\n      onAbandon: () => hold(null),\n    },\n    { axis: \"x\" },\n  );\n\n  // ── exact entry ──────────────────────────────────────────────────────────────────────────────────────────────────\n  type Proposal =\n    | { kind: \"none\" }\n    | { kind: \"error\"; message: string }\n    | { kind: \"one\"; transfer: Transfer; partner: string }\n    | { kind: \"many\"; options: { party: string; transfer: Transfer }[]; amount: number; direction: \"take\" | \"give\" };\n  const propose = (id: string, typed: string): Proposal => {\n    const item = items.find((candidate) => candidate.id === id);\n    if (!item) return { kind: \"none\" };\n    if (!typed.trim()) return { kind: \"error\", message: text.enterAmount(item.label) };\n    const proposal = parseLocaleNumber(typed, locale);\n    if (proposal === null) return { kind: \"error\", message: text.notNumber() };\n    const current = value[id] ?? 0;\n    const delta = tidy(proposal - current);\n    if (delta === 0 && value[id] != null) return { kind: \"none\" };\n    if (Math.abs(delta / step - Math.round(delta / step)) > 1e-6)\n      return { kind: \"error\", message: text.notStep(format(step)) };\n    if (proposal > (item.max ?? total) + 1e-9)\n      return { kind: \"error\", message: text.atMax(item.label, format(item.max ?? total)) };\n    if (proposal < (item.min ?? 0) - 1e-9)\n      return { kind: \"error\", message: text.atMin(item.label, format(item.min ?? 0)) };\n    const all = candidates(model, value, id, delta);\n    const allowed = all.filter((candidate) => !candidate.reason);\n    if (!allowed.length)\n      return {\n        kind: \"error\",\n        message: all.length ? all.map((candidate) => candidate.reason).join(\" \") : text.noNeighbour(),\n      };\n    if (allowed.length === 1) return { kind: \"one\", transfer: allowed[0].transfer, partner: allowed[0].party };\n    return {\n      kind: \"many\",\n      options: allowed.map(({ party, transfer }) => ({ party, transfer })),\n      amount: Math.abs(delta),\n      direction: delta > 0 ? \"take\" : \"give\",\n    };\n  };\n  const applyEntry = (transfer: Transfer, id: string) => {\n    const next = applyTransfer(value, transfer);\n    const discarded = discardPending(id);\n    move(next, [transfer], true);\n    setPending(null);\n    setDrafts((all) => {\n      const copy = { ...all };\n      delete copy[id];\n      return copy;\n    });\n    setReason(null);\n    setLimit(null);\n    const other = transfer.from === id ? transfer.to : transfer.from;\n    setLive(\n      [\n        discarded,\n        text.moved(format(transfer.amount), labelOf(transfer.from), labelOf(transfer.to, next)),\n        `${pairText(id, other, next)}.`,\n      ]\n        .filter(Boolean)\n        .join(\" \"),\n    );\n    onValueCommit?.(next);\n  };\n  const submitEntry = (id: string, fromBlur = false) => {\n    const typed = drafts[id];\n    if (typed === undefined) return;\n    const proposal = propose(id, typed);\n    if (proposal.kind === \"none\") {\n      setDrafts((all) => {\n        const copy = { ...all };\n        delete copy[id];\n        return copy;\n      });\n      setReason(null);\n      return;\n    }\n    if (proposal.kind === \"error\") {\n      setReason({ text: proposal.message, row: id });\n      if (!fromBlur) setLive(proposal.message);\n      return;\n    }\n    if (proposal.kind === \"one\") {\n      applyEntry(proposal.transfer, id);\n      return;\n    }\n    if (fromBlur) return;\n    const discarded = discardPending(id);\n    setReason(null);\n    setPartnerChoice(\"\");\n    setPending({ id, amount: proposal.amount, direction: proposal.direction, options: proposal.options });\n    focusOption.current = true;\n    setLive(\n      [\n        discarded,\n        text.choosePartnerSpoken(\n          format(proposal.amount),\n          proposal.options.map((option) => labelOf(option.party)),\n          proposal.direction,\n        ),\n      ]\n        .filter(Boolean)\n        .join(\" \"),\n    );\n  };\n  const reachable = (id: string, direction: 1 | -1) => {\n    const item = items.find((candidate) => candidate.id === id);\n    if (!item) return value[id] ?? 0;\n    let best = value[id] ?? 0;\n    for (const candidate of candidates(model, value, id, direction * step)) {\n      const pair = direction > 0 ? { from: candidate.party, to: id } : { from: id, to: candidate.party };\n      const result = walk(model, value, pair.from, pair.to, stepsInWhole, \"entry\");\n      const reached = result.value[id] ?? 0;\n      best = direction > 0 ? Math.max(best, reached) : Math.min(best, reached);\n    }\n    return best;\n  };\n  const onFieldKey = (id: string) => (e: ReactKeyboardEvent<HTMLInputElement>) => {\n    const current = drafts[id];\n    const base = parseLocaleNumber(current ?? \"\", locale) ?? value[id] ?? 0;\n    const set = (n: number) => {\n      e.preventDefault();\n      setDrafts((all) => ({ ...all, [id]: numberFormat.format(tidy(n)) }));\n    };\n    if (e.key === \"Enter\") {\n      e.preventDefault();\n      submitEntry(id);\n    } else if (e.key === \"Escape\") {\n      if (current === undefined && pending?.id !== id) return;\n      e.preventDefault();\n      e.stopPropagation();\n      setDrafts((all) => {\n        const copy = { ...all };\n        delete copy[id];\n        return copy;\n      });\n      if (pending?.id === id) setPending(null);\n      setReason(null);\n    } else if (e.key === \"ArrowUp\") set(base + (e.shiftKey ? big : 1) * step);\n    else if (e.key === \"ArrowDown\") set(base - (e.shiftKey ? big : 1) * step);\n    else if (e.key === \"PageUp\") set(base + big * step);\n    else if (e.key === \"PageDown\") set(base - big * step);\n    else if (e.key === \"Home\") set(reachable(id, -1));\n    else if (e.key === \"End\") set(reachable(id, 1));\n  };\n\n  // ── save and cancel ──────────────────────────────────────────────────────────────────────────────────────────────\n  const blocked =\n    rest > 0\n      ? text.assignBeforeSave(format(rest))\n      : rest < 0\n        ? `${text.overBy(format(-rest))} · ${text.removeBeforeSave(format(-rest))}`\n        : null;\n  const canSave = dirty && !blocked && !saving && !inert;\n  const changes: AllocationChange[] = items\n    .filter((item) => (value[item.id] ?? null) !== (baseline[item.id] ?? null))\n    .map((item) => ({ id: item.id, from: baseline[item.id] ?? null, to: value[item.id] ?? null }));\n  const changeSummary = (list: AllocationChange[]) =>\n    list\n      .map(\n        (change) =>\n          `${labelOf(change.id)} ${change.from == null ? text.notSet() : numberFormat.format(change.from)} → ${change.to == null ? text.notSet() : numberFormat.format(change.to)}`,\n      )\n      .join(\" · \");\n  const cancel = () => {\n    if (!dirty || saving) return;\n    discardPending();\n    log.current = [];\n    setDrafts({});\n    setPending(null);\n    setReason(null);\n    setLimit(null);\n    setSaveError(null);\n    setElsewhere(null);\n    setEase(!reduced);\n    commitValue(baseline, null);\n    setLive(`${text.discarded()} ${everything(baseline)}.`);\n    onValueCommit?.(baseline);\n    onCancel?.();\n  };\n  const save = async () => {\n    if (!onSave || !canSave) return;\n    const request: AllocationSaveRequest = { value, changes, transfers: netTransfers(log.current) };\n    setSaving(true);\n    setSaveError(null);\n    setReason(null);\n    try {\n      const persisted = await onSave(request);\n      const adopted = persisted ? fill(items, persisted) : value;\n      const differs = items\n        .filter((item) => (adopted[item.id] ?? null) !== (value[item.id] ?? null))\n        .map((item) => ({ id: item.id, from: value[item.id] ?? null, to: adopted[item.id] ?? null }));\n      log.current = [];\n      setBaseline(adopted);\n      setElsewhere(null);\n      if (differs.length) {\n        setEase(!reduced);\n        commitValue(adopted, null);\n        onValueCommit?.(adopted);\n      }\n      const summary = changeSummary(differs);\n      setSavedNote(differs.length ? text.savedWithChanges(summary) : text.saved());\n      setLive(\n        `${text.saved()} ${everything(adopted)}.${differs.length ? ` ${differs.map((d) => i18n(\"wasSavedAs\", { value: labelOf(d.id), value2: amountText(d.id, adopted) })).join(\" \")}` : \"\"}`,\n      );\n    } catch (thrown) {\n      const failure = thrown as AllocationSaveError;\n      setSaveError({ message: failure?.message || String(thrown), items: failure?.itemErrors ?? {} });\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  // ── status line ──────────────────────────────────────────────────────────────────────────────────────────────────\n  const statusError = error ?? (reason && !reason.row ? reason.text : null);\n  const status: ReactNode =\n    statusError ??\n    (saving\n      ? text.saving(elapsed >= 2 ? elapsed : undefined)\n      : (elsewhere && text.changedElsewhere(elsewhere)) ||\n        blocked ||\n        savedNote ||\n        (dirty\n          ? changeSummary(changes)\n          : `${text.allocated(format(sum), format(total))}${order === \"set\" ? ` · ${text.setTradesAny()}` : \"\"}`));\n  const statusIsError = !!statusError || (!!blocked && !saving);\n  // short of the whole is a to-do (amber); over it, or a refused save, is an error (red)\n  const statusTone = !statusIsError ? undefined : !statusError && rest > 0 ? \"warning\" : \"danger\";\n\n  // ── notes under each row (rest), the trade preview while typing, and errors ─────────────────────────────────────\n  const noteFor = (item: AllocationItem): ReactNode => {\n    if (saveError?.items[item.id]) return saveError.items[item.id];\n    if (reason?.row === item.id) return reason.text;\n    const typed = drafts[item.id];\n    if (typed !== undefined && pending?.id !== item.id) {\n      const proposal = propose(item.id, typed);\n      if (proposal.kind === \"one\") {\n        const take = proposal.transfer.to === item.id;\n        return take\n          ? text.takeFrom(format(proposal.transfer.amount), labelOf(proposal.partner))\n          : text.giveTo(format(proposal.transfer.amount), labelOf(proposal.partner));\n      }\n    }\n    if (item.locked) return item.lockReason ?? text.locked(item.label);\n    if (readOnly || disabled) return item.description;\n    if (value[item.id] == null) return text.takesFromUnallocated();\n    if (order === \"set\") return item.description;\n    const index = items.indexOf(item);\n    const partners = [items[index - 1], items[index + 1]]\n      .filter(\n        (neighbour): neighbour is AllocationItem => !!neighbour && !neighbour.locked && value[neighbour.id] != null,\n      )\n      .map((neighbour) => neighbour.label);\n    // which parts it trades with is how the strip works, not what the row says: it waits for the row's attention\n    // (hover, focus in its field, a change in flight) and is always there on touch; the description stays in sight\n    const trades = (\n      <span data-slot=\"allocation-trades\" className=\"reveal\">\n        {item.description ? \"· \" : \"\"}\n        {partners.length ? text.tradesWith(partners) : text.noNeighbour()}\n      </span>\n    );\n    return item.description ? (\n      <>\n        {item.description} {trades}\n      </>\n    ) : (\n      trades\n    );\n  };\n  const noteIsError = (id: string) => !!saveError?.items[id] || reason?.row === id;\n\n  // ── ticks: start, each boundary, end; a middle label that would collide is dropped ──────────────────────────────\n  const tickLabel = (offset: number) => (typeof scale === \"function\" ? scale(offset) : numberFormat.format(offset));\n  const ticks = useMemo(() => {\n    if (scale === false) return [];\n    const points = [0, ...drawn.map((item) => tidy((offsets.get(item.id) ?? 0) + (value[item.id] ?? 0)))];\n    if (!points.includes(total)) points.push(total);\n    const unique = [...new Set(points)].filter((point) => point <= scaleTotal).sort((a, b) => a - b);\n    const width = trackWidth || 1;\n    const measured = unique.map((offset) => ({\n      offset,\n      text: tickLabel(offset),\n      x: (offset / scaleTotal) * width,\n    }));\n    const kept: typeof measured = [];\n    const last = measured.at(-1);\n    for (const tick of measured) {\n      const half = textWidth(tick.text, font) / 2 + 6;\n      const previous = kept.at(-1);\n      const end = tick === last;\n      if (previous && tick.x - half < previous.x + textWidth(previous.text, font) / 2 + 6) {\n        if (end && kept.length > 1) kept.pop();\n        else if (!end) continue;\n      }\n      if (!end && last && tick.x + half > last.x - textWidth(last.text, font) / 2 - 6) continue;\n      kept.push(tick);\n    }\n    return kept;\n  }, [scale, drawn, offsets, value, total, scaleTotal, trackWidth, font]); // eslint-disable-line react-hooks/exhaustive-deps -- derived per render inputs\n\n  const slot = (name: AllocationSlot) => classNames?.[name];\n  /** Parts are one hue (series-1's) in lightness steps by position: a part is not a status, so it never takes a tone\n   *  of its own (a red \"Review\" read as an error). Tones stay for what the user acts on: over is red, unallocated\n   *  amber. A locked part is a neutral ink tone, and its lock glyph says why. */\n  const seriesOf = (item: AllocationItem) => {\n    if (item.color) return `var(--pui-${item.color})`;\n    if (item.locked) return \"var(--pui-ink-3)\";\n    const steps = items.filter((candidate) => candidate.kind !== \"gap\" && !candidate.locked && !candidate.color);\n    const index = steps.indexOf(item);\n    const lightness = steps.length < 2 ? 0.58 : 0.46 + (0.28 * index) / (steps.length - 1);\n    return `oklch(from var(--pui-series-1) ${lightness.toFixed(3)} c h)`;\n  };\n  /** A segment says its name and amount when both fit, then the more useful one (the amount for a part, whose name is in\n   *  its roster row; the name for a gap, the remainder or the excess), and keeps the name for assistive reading. */\n  const segmentLabel = (name: string, amount: string, width: number, nameFirst: boolean, locked = false) => {\n    const glyph = locked && width >= 28;\n    if (glyph) width -= 16;\n    const both = !!amount && textWidth(`${name} ${amount}`, font) + 16 <= width;\n    const nameFits = textWidth(name, font) + 12 <= width;\n    const amountFits = !!amount && textWidth(amount, font) + 12 <= width;\n    const showName = both || ((nameFirst || !amount) && nameFits);\n    const showAmount = both || (!showName && amountFits);\n    return (\n      <span data-slot=\"allocation-segment-label\" className={cn(styles.segmentLabel, slot(\"segmentLabel\"))}>\n        {glyph && <Lock aria-hidden className={styles.segmentLock} />}\n        <span className={showName ? styles.segmentName : \"sr-only\"}>{name}</span>\n        {showAmount && <span className={styles.figure}>{amount}</span>}\n      </span>\n    );\n  };\n\n  // ── render ───────────────────────────────────────────────────────────────────────────────────────────────────────\n  const titleId = `${useStableId()}-title`;\n  const editable = !readOnly && !disabled;\n  return (\n    <div\n      ref={attach}\n      role=\"group\"\n      aria-labelledby={titleId}\n      tabIndex={-1}\n      data-slot=\"allocation-editor\"\n      data-return-region=\"\"\n      data-order={order}\n      data-dirty={dirty ? \"\" : undefined}\n      data-loading={saving || loading ? \"\" : undefined}\n      aria-busy={saving || undefined}\n      data-error={error || saveError || rest < 0 ? \"\" : undefined}\n      data-empty={!loading && items.length === 0 ? \"\" : undefined}\n      data-readonly={readOnly ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-dragging={drag?.started ? \"\" : undefined}\n      data-ease={ease ? \"\" : undefined}\n      style={style}\n      className={cn(styles.root, slot(\"root\"), className)}\n    >\n      <div data-slot=\"allocation-header\" className={cn(styles.header, slot(\"header\"))}>\n        <span id={titleId} data-slot=\"allocation-title\" className={cn(\"text-label\", slot(\"title\"))}>\n          {label}\n        </span>\n        <span data-slot=\"allocation-total\" className={cn(styles.total, slot(\"total\"))}>\n          {format(total)}\n        </span>\n        {description && (\n          <span data-slot=\"allocation-description\" className={cn(styles.description, slot(\"description\"))}>\n            {description}\n          </span>\n        )}\n      </div>\n\n      {loading ? (\n        <div className={styles.skeleton} aria-hidden>\n          <span className={styles.skeletonTrack} />\n          <span className={styles.skeletonRow} />\n          <span className={styles.skeletonRow} />\n          <span className={styles.skeletonRow} />\n        </div>\n      ) : items.length === 0 ? (\n        <p className=\"text-sm text-ink-3\">{emptyLabel}</p>\n      ) : (\n        <>\n          <div\n            role=\"group\"\n            aria-label={`${label}, ${format(total)}`}\n            data-slot=\"allocation-strip\"\n            className={cn(styles.strip, slot(\"strip\"))}\n          >\n            <div ref={attachTrack} data-slot=\"allocation-track\" className={cn(\"m-inset\", styles.track, slot(\"track\"))}>\n              {drawn.map((item) => {\n                const amount = value[item.id] ?? 0;\n                const width = (amount / scaleTotal) * trackWidth;\n                const gap = item.kind === \"gap\";\n                return (\n                  <div\n                    key={item.id}\n                    aria-hidden\n                    data-slot=\"allocation-segment\"\n                    data-id={item.id}\n                    data-kind={gap ? \"gap\" : \"part\"}\n                    data-locked={item.locked ? \"\" : undefined}\n                    data-dirty={(value[item.id] ?? null) !== (baseline[item.id] ?? null) ? \"\" : undefined}\n                    className={cn(styles.segment, slot(\"segment\"))}\n                    style={\n                      {\n                        insetInlineStart: pct(offsets.get(item.id) ?? 0),\n                        width: pct(amount),\n                        \"--_fill\": gap ? undefined : seriesOf(item),\n                      } as CSSProperties\n                    }\n                    onPointerDown={(e) => {\n                      if (!editable || item.locked) return;\n                      e.preventDefault();\n                      const field = fields.current.get(item.id);\n                      field?.focus();\n                      field?.select();\n                    }}\n                  >\n                    {segmentLabel(item.label, numberFormat.format(amount), width, gap, item.locked)}\n                  </div>\n                );\n              })}\n              {rest > 0 && (\n                <div\n                  aria-hidden\n                  data-slot=\"allocation-segment\"\n                  data-kind=\"unallocated\"\n                  className={cn(styles.segment, slot(\"segment\"))}\n                  style={{ insetInlineStart: pct(sum), width: pct(rest) }}\n                >\n                  {segmentLabel(text.unallocated(), format(rest), (rest / scaleTotal) * trackWidth, true)}\n                </div>\n              )}\n              {rest < 0 && (\n                <>\n                  <div\n                    aria-hidden\n                    data-slot=\"allocation-segment\"\n                    data-kind=\"over\"\n                    className={cn(styles.segment, styles.over, slot(\"segment\"))}\n                    style={{ insetInlineStart: pct(total), width: pct(-rest) }}\n                  >\n                    {segmentLabel(text.overBy(format(-rest)), \"\", (-rest / scaleTotal) * trackWidth, true)}\n                  </div>\n                  <span aria-hidden className={styles.limitMark} style={{ insetInlineStart: pct(total) }} />\n                </>\n              )}\n              {seams.map((seam) => (\n                <span\n                  key={`seam-${seam.offset}`}\n                  aria-hidden\n                  data-slot=\"allocation-seam\"\n                  className={cn(styles.seam, slot(\"seam\"))}\n                  style={{ insetInlineStart: pct(seam.offset) }}\n                >\n                  <Lock className={styles.seamLock} />\n                </span>\n              ))}\n              {baselineOffsets\n                .filter((mark) => !boundaries.some((boundary) => boundary.offset === mark))\n                .map((mark) => (\n                  <span\n                    key={`base-${mark}`}\n                    aria-hidden\n                    data-slot=\"allocation-baseline\"\n                    className={cn(styles.baseline, slot(\"baseline\"))}\n                    style={{ insetInlineStart: pct(mark) }}\n                  />\n                ))}\n              {editable &&\n                boundaries.map((boundary) => {\n                  const range = reach(model, value, boundary.a, boundary.b, stepsInWhole);\n                  const atLimit = limit?.boundary === boundary.index ? limit.edge : undefined;\n                  return (\n                    <div\n                      key={`${boundary.a}|${boundary.b}`}\n                      ref={(el) => {\n                        if (el) boundaryEls.current.set(boundary.index, el);\n                        else boundaryEls.current.delete(boundary.index);\n                      }}\n                      role=\"slider\"\n                      tabIndex={0}\n                      aria-label={text.boundaryLabel(labelOf(boundary.a), labelOf(boundary.b))}\n                      aria-orientation=\"horizontal\"\n                      aria-valuenow={boundary.offset}\n                      aria-valuemin={tidy(boundary.offset - range.back * step)}\n                      aria-valuemax={tidy(boundary.offset + range.ahead * step)}\n                      aria-valuetext={pairText(boundary.a, boundary.b, value)}\n                      aria-readonly={saving || undefined}\n                      data-slot=\"allocation-boundary\"\n                      data-index={boundary.index}\n                      data-limit={atLimit}\n                      data-dragging={drag?.boundary === boundary.index && drag.started ? \"\" : undefined}\n                      className={cn(styles.boundary, slot(\"boundary\"))}\n                      style={{ insetInlineStart: pct(boundary.offset) }}\n                      onKeyDown={onBoundaryKey(boundary)}\n                    >\n                      <span className={cn(\"m-knob\", styles.grip)} />\n                    </div>\n                  );\n                })}\n            </div>\n            {ticks.length > 0 && (\n              <div aria-hidden data-slot=\"allocation-scale\" className={cn(styles.scale, slot(\"scale\"))}>\n                {ticks.map((tick, i) => (\n                  <span\n                    key={tick.offset}\n                    data-slot=\"allocation-tick\"\n                    data-edge={i === 0 ? \"start\" : i === ticks.length - 1 ? \"end\" : undefined}\n                    className={cn(styles.tick, slot(\"tick\"))}\n                    style={{ insetInlineStart: pct(tick.offset) }}\n                  >\n                    {tick.text}\n                  </span>\n                ))}\n              </div>\n            )}\n          </div>\n\n          <ul\n            aria-label={text.rosterLabel()}\n            data-slot=\"allocation-roster\"\n            className={cn(styles.roster, slot(\"roster\"))}\n          >\n            {items.map((item) => {\n              const amount = value[item.id];\n              const before = baseline[item.id] ?? null;\n              const changed = (amount ?? null) !== before;\n              const noteId = `${titleId}-${item.id}-note`;\n              const changeId = `${titleId}-${item.id}-change`;\n              const invalid = noteIsError(item.id);\n              const typed = drafts[item.id];\n              const isPending = pending?.id === item.id;\n              const note = noteFor(item);\n              return (\n                <li\n                  key={item.id}\n                  data-slot=\"allocation-row\"\n                  data-id={item.id}\n                  data-kind={item.kind ?? \"part\"}\n                  data-locked={item.locked ? \"\" : undefined}\n                  data-null={amount == null ? \"\" : undefined}\n                  data-dirty={changed ? \"\" : undefined}\n                  data-pending={isPending ? \"\" : undefined}\n                  data-invalid={invalid ? \"\" : undefined}\n                  data-selected={changed || isPending || invalid ? \"\" : undefined}\n                  className={cn(styles.row, \"reveal-host\", slot(\"row\"))}\n                >\n                  <div className={styles.rowMain}>\n                    <span data-slot=\"allocation-row-label\" className={cn(styles.rowLabel, slot(\"rowLabel\"))}>\n                      {item.kind !== \"gap\" && (\n                        <span aria-hidden className={styles.swatch} style={{ background: seriesOf(item) }} />\n                      )}\n                      {item.locked && <Lock aria-hidden className={styles.lock} />}\n                      {item.label}\n                    </span>\n                    {editable && !item.locked ? (\n                      <FieldFrame\n                        size=\"md\"\n                        invalid={invalid}\n                        readOnly={saving}\n                        className={cn(styles.amountFrame)}\n                        trailing={unit ? <span className={styles.unit}>{unit}</span> : undefined}\n                      >\n                        <input\n                          ref={(el) => {\n                            if (el) fields.current.set(item.id, el);\n                            else fields.current.delete(item.id);\n                          }}\n                          data-slot=\"allocation-amount\"\n                          role=\"spinbutton\"\n                          inputMode={decimals(step) ? \"decimal\" : \"numeric\"}\n                          autoComplete=\"off\"\n                          aria-label={item.label}\n                          aria-valuenow={amount ?? undefined}\n                          aria-valuetext={amount == null ? text.notSet() : format(amount)}\n                          aria-invalid={invalid || undefined}\n                          aria-errormessage={invalid ? noteId : undefined}\n                          aria-describedby={[noteId, changed ? changeId : null].filter(Boolean).join(\" \")}\n                          aria-readonly={saving || undefined}\n                          readOnly={saving}\n                          placeholder={amount == null ? \"—\" : undefined}\n                          value={typed ?? (amount == null ? \"\" : numberFormat.format(amount))}\n                          onChange={(e) => {\n                            const typedValue = e.target.value;\n                            setDrafts((all) => ({ ...all, [item.id]: typedValue }));\n                            if (pending?.id === item.id) setPending(null);\n                            if (reason?.row === item.id) setReason(null);\n                          }}\n                          onKeyDown={onFieldKey(item.id)}\n                          onBlur={(e) => {\n                            const next = e.relatedTarget as HTMLElement | null;\n                            if (next?.closest(`[data-slot=allocation-partner]`)) return;\n                            submitEntry(item.id, true);\n                          }}\n                          className={cn(fieldControlClass, styles.amountInput, slot(\"amount\"))}\n                        />\n                      </FieldFrame>\n                    ) : (\n                      <span\n                        data-slot=\"allocation-amount\"\n                        className={cn(styles.amountText, amount == null && styles.notSet, slot(\"amount\"))}\n                      >\n                        {item.locked && <Lock aria-hidden className={styles.lock} />}\n                        {amount == null ? (\n                          <>\n                            <span aria-hidden>{\"—\"}</span> {text.notSet()}\n                          </>\n                        ) : (\n                          format(amount)\n                        )}\n                      </span>\n                    )}\n                  </div>\n                  {(changed || note || (amount == null && editable && !item.locked)) && (\n                    <div className={styles.rowSub}>\n                      {changed && (\n                        <span id={changeId} data-slot=\"allocation-change\" className={cn(styles.change, slot(\"change\"))}>\n                          <span className={styles.figure}>{before == null ? \"—\" : numberFormat.format(before)}</span>\n                          <ArrowRight aria-hidden className={styles.arrow} />\n                          <span className={styles.figure}>{amount == null ? \"—\" : numberFormat.format(amount)}</span>\n                          <span className={cn(styles.delta, (amount == null || before == null) && \"unmeasured\")}>\n                            {(() => {\n                              if (amount == null || before == null) return i18n(\"notMeasured\");\n                              const delta = tidy(amount - before);\n                              return `${delta > 0 ? \"+\" : \"−\"}${numberFormat.format(Math.abs(delta))}`;\n                            })()}\n                          </span>\n                          <span className=\"sr-only\"> {i18n(\"changed\")}</span>\n                        </span>\n                      )}\n                      {amount == null && editable && !item.locked && (\n                        <span className={styles.notSetWord}>{text.notSet()}</span>\n                      )}\n                      {note && (\n                        <span\n                          id={noteId}\n                          data-slot=\"allocation-note\"\n                          className={cn(styles.note, invalid && styles.noteError, slot(\"note\"))}\n                        >\n                          {invalid && <AlertCircle aria-hidden className={styles.noteIcon} />}\n                          {note}\n                        </span>\n                      )}\n                    </div>\n                  )}\n                  {isPending && pending && (\n                    <div\n                      role=\"group\"\n                      aria-label={text.choosePartner(format(pending.amount), pending.direction)}\n                      data-slot=\"allocation-partner\"\n                      className={cn(styles.partner, slot(\"partner\"))}\n                    >\n                      <span className={styles.partnerPrompt}>\n                        {text.choosePartner(format(pending.amount), pending.direction)}\n                      </span>\n                      {order === \"set\" && pending.options.length > 4 ? (\n                        <>\n                          <Select\n                            label={text.choosePartner(format(pending.amount), pending.direction)}\n                            showLabel={false}\n                            size=\"sm\"\n                            value={partnerChoice}\n                            onValueChange={setPartnerChoice}\n                            options={pending.options.map((option) => ({\n                              value: option.party,\n                              label: labelOf(option.party),\n                            }))}\n                          />\n                          <Button\n                            size=\"sm\"\n                            variant=\"secondary\"\n                            data-slot=\"allocation-partner-option\"\n                            disabled={!partnerChoice}\n                            onClick={() => {\n                              const option = pending.options.find((candidate) => candidate.party === partnerChoice);\n                              if (!option) return;\n                              applyEntry(option.transfer, item.id);\n                              fields.current.get(item.id)?.focus();\n                            }}\n                          >\n                            {i18n(\"apply\")}\n                          </Button>\n                        </>\n                      ) : (\n                        pending.options.map((option) => (\n                          <Button\n                            key={option.party}\n                            size=\"sm\"\n                            variant=\"secondary\"\n                            data-slot=\"allocation-partner-option\"\n                            className={slot(\"partnerOption\")}\n                            onKeyDown={(e) => {\n                              if (e.key !== \"Escape\") return;\n                              e.preventDefault();\n                              e.stopPropagation();\n                              setPending(null);\n                              setDrafts((all) => {\n                                const copy = { ...all };\n                                delete copy[item.id];\n                                return copy;\n                              });\n                              fields.current.get(item.id)?.focus();\n                            }}\n                            onClick={() => {\n                              applyEntry(option.transfer, item.id);\n                              fields.current.get(item.id)?.focus();\n                            }}\n                          >\n                            {labelOf(option.party)}\n                          </Button>\n                        ))\n                      )}\n                    </div>\n                  )}\n                  {name && <input type=\"hidden\" name={`${name}.${item.id}`} value={amount ?? \"\"} />}\n                </li>\n              );\n            })}\n          </ul>\n\n          <p\n            data-slot=\"allocation-status\"\n            data-tone={statusTone}\n            className={cn(\n              styles.status,\n              statusTone === \"danger\" && styles.statusError,\n              statusTone === \"warning\" && styles.statusWarning,\n              slot(\"status\"),\n            )}\n          >\n            {statusIsError && <AlertCircle aria-hidden className={styles.noteIcon} />}\n            {status}\n          </p>\n        </>\n      )}\n\n      <span aria-live=\"polite\" data-slot=\"allocation-live\" className={cn(\"sr-only\", slot(\"live\"))}>\n        {live}\n      </span>\n\n      {saveError && (\n        <div data-slot=\"allocation-error\" className={slot(\"error\")}>\n          <Alert tone=\"danger\" title={text.saveFailed()}>\n            {saveError.message}\n          </Alert>\n        </div>\n      )}\n\n      {onSave && editable && !loading && items.length > 0 && (\n        <div data-slot=\"allocation-footer\" className={cn(styles.footer, slot(\"footer\"))}>\n          <Button\n            variant=\"ghost\"\n            data-slot=\"allocation-cancel\"\n            className={cn(styles.footerButton, slot(\"cancel\"))}\n            aria-disabled={!dirty || saving}\n            onClick={cancel}\n          >\n            {cancelLabel}\n          </Button>\n          <Button\n            variant=\"primary\"\n            data-slot=\"allocation-save\"\n            className={cn(styles.footerButton, slot(\"save\"))}\n            loading={saving}\n            aria-disabled={!canSave && !saving}\n            onClick={save}\n          >\n            {savedNote && !dirty ? savedLabel : saveLabel}\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** React's id without its colons, so ids derived from it stay valid in selectors too; stable across server and client. */\nfunction useStableId() {\n  return `allocation-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n}\n\nexport default AllocationEditor;\n"},{"path":"beamline/components/allocation-editor/allocation-model.ts","type":"registry:file","target":"src/ui/beamline/components/allocation-editor/allocation-model.ts","content":"// The transfer model behind AllocationEditor: every change is one zero-sum transfer between two named parties. Pure\n// functions only (no React), so drag, boundary keys and exact entry all ask the same questions and get the same answer.\n\n/** Party id of the implicit remainder: the whole minus the sum of the items (shortfall > 0, excess < 0). */\nexport const UNALLOCATED = \"unallocated\" as const;\n\nexport type AllocationValue = Record<string, number | null>;\n\nexport interface ModelItem {\n  id: string;\n  label: string;\n  kind?: \"part\" | \"gap\";\n  min?: number;\n  max?: number;\n  locked?: boolean;\n}\n\nexport interface Transfer {\n  /** Id of the party giving the amount. Required. */\n  from: string;\n  /** Id of the party receiving the amount. Required. */\n  to: string;\n  /** Amount moved between the two parties. Required. */\n  amount: number;\n  /** Input method that initiated the transfer. Required. */\n  via: \"drag\" | \"keyboard\" | \"entry\";\n}\n\nexport interface ModelText {\n  locked: (label: string) => string;\n  atMin: (label: string, amount: string) => string;\n  atMax: (label: string, amount: string) => string;\n  notNeighbours: (a: string, b: string) => string;\n  notSetPart: (label: string) => string;\n  cannotGiveAway: (label: string) => string;\n  unallocated: () => string;\n  over: () => string;\n}\n\nexport interface Model {\n  items: ModelItem[];\n  total: number;\n  step: number;\n  order: \"sequence\" | \"set\";\n  format: (amount: number) => string;\n  text: ModelText;\n  canTransfer?: (transfer: Transfer, next: AllocationValue) => true | string;\n}\n\n/** Floating error never shows: 0.1 + 0.2 reads 0.3. */\nexport const tidy = (n: number) => Number(n.toFixed(9));\n\nexport const sumOf = (items: ModelItem[], value: AllocationValue) =>\n  tidy(items.reduce((sum, item) => sum + (value[item.id] ?? 0), 0));\n\n/** Positive while part of the whole is unassigned, negative while the parts exceed it. */\nexport const remainder = (model: Model, value: AllocationValue) => tidy(model.total - sumOf(model.items, value));\n\nexport const partyLabel = (model: Model, id: string, value: AllocationValue) =>\n  id === UNALLOCATED\n    ? remainder(model, value) < 0\n      ? model.text.over()\n      : model.text.unallocated()\n    : (model.items.find((item) => item.id === id)?.label ?? id);\n\n/** The draft after a transfer; the remainder is implicit, so only item amounts move. */\nexport function applyTransfer(value: AllocationValue, transfer: Pick<Transfer, \"from\" | \"to\" | \"amount\">) {\n  const next = { ...value };\n  if (transfer.from !== UNALLOCATED) next[transfer.from] = tidy((next[transfer.from] ?? 0) - transfer.amount);\n  if (transfer.to !== UNALLOCATED) next[transfer.to] = tidy((next[transfer.to] ?? 0) + transfer.amount);\n  return next;\n}\n\n/** Why one candidate transfer is refused, in the person's words, or null when it is allowed. */\nexport function refusal(model: Model, value: AllocationValue, transfer: Transfer): string | null {\n  const { items, text, format } = model;\n  const item = (id: string) => items.find((candidate) => candidate.id === id);\n  const from = item(transfer.from);\n  const to = item(transfer.to);\n  const rest = remainder(model, value);\n  // the remainder is a repair party: it only ever moves toward zero\n  if (transfer.from === UNALLOCATED && !(rest > 0 && transfer.amount <= rest + 1e-9))\n    return to ? text.cannotGiveAway(text.unallocated()) : text.unallocated();\n  if (transfer.to === UNALLOCATED && !(rest < 0 && transfer.amount <= -rest + 1e-9))\n    return from ? text.cannotGiveAway(from.label) : text.unallocated();\n  if (from?.locked) return text.locked(from.label);\n  if (to?.locked) return text.locked(to.label);\n  if (from && value[from.id] == null) return text.notSetPart(from.label);\n  if (to && value[to.id] == null && transfer.from !== UNALLOCATED) return text.notSetPart(to.label);\n  if (from && to && model.order === \"sequence\" && Math.abs(items.indexOf(from) - items.indexOf(to)) !== 1)\n    return text.notNeighbours(from.label, to.label);\n  if (from) {\n    const min = from.min ?? 0;\n    if (tidy((value[from.id] ?? 0) - transfer.amount) < min - 1e-9) return text.atMin(from.label, format(min));\n  }\n  if (to) {\n    const max = to.max ?? model.total;\n    if (tidy((value[to.id] ?? 0) + transfer.amount) > max + 1e-9) return text.atMax(to.label, format(max));\n  }\n  if (model.canTransfer) {\n    const verdict = model.canTransfer(transfer, applyTransfer(value, transfer));\n    if (verdict !== true) return verdict;\n  }\n  return null;\n}\n\n/**\n * A directional move (drag, arrow, Page, Home/End): checked step by step from `value`, stopping at the last allowed\n * step. Returns the value reached, how many steps were taken and the reason it stopped short (if it did).\n */\nexport function walk(\n  model: Model,\n  value: AllocationValue,\n  from: string,\n  to: string,\n  steps: number,\n  via: Transfer[\"via\"],\n): { value: AllocationValue; taken: number; reason: string | null } {\n  let current = value;\n  for (let taken = 0; taken < steps; taken++) {\n    const transfer = { from, to, amount: model.step, via };\n    const reason = refusal(model, current, transfer);\n    if (reason) return { value: current, taken, reason };\n    current = applyTransfer(current, transfer);\n  }\n  return { value: current, taken: steps, reason: null };\n}\n\n/** Who may trade with `id` for an exact change `delta` (positive takes, negative gives), in item order, remainder last. */\nexport function candidates(model: Model, value: AllocationValue, id: string, delta: number) {\n  const { items } = model;\n  const index = items.findIndex((item) => item.id === id);\n  const self = items[index];\n  const rest = remainder(model, value);\n  const others =\n    value[id] == null\n      ? []\n      : model.order === \"sequence\"\n        ? [items[index - 1], items[index + 1]].filter((item): item is ModelItem => !!item)\n        : items.filter((item) => item.id !== id);\n  const parties = others.map((item) => item.id);\n  if (rest !== 0 || value[id] == null) parties.push(UNALLOCATED);\n  const amount = tidy(Math.abs(delta));\n  return parties.map((party) => {\n    const transfer: Transfer =\n      delta > 0\n        ? { from: party, to: self.id, amount, via: \"entry\" }\n        : { from: self.id, to: party, amount, via: \"entry\" };\n    return { party, transfer, reason: refusal(model, value, transfer) };\n  });\n}\n\n/** How far a boundary between `a` (before) and `b` (after) can travel each way, in steps. */\nexport function reach(model: Model, value: AllocationValue, a: string, b: string, limit: number) {\n  return {\n    back: walk(model, value, a, b, limit, \"keyboard\").taken,\n    ahead: walk(model, value, b, a, limit, \"keyboard\").taken,\n  };\n}\n\n/** Net transfers per ordered pair: A→B 10 and B→A 5 become A→B 5. */\nexport function netTransfers(log: Pick<Transfer, \"from\" | \"to\" | \"amount\">[]) {\n  const net = new Map<string, number>();\n  for (const { from, to, amount } of log) {\n    const [first, second] = from < to ? [from, to] : [to, from];\n    const key = `${first}\\u0000${second}`;\n    net.set(key, tidy((net.get(key) ?? 0) + (from === first ? amount : -amount)));\n  }\n  return [...net].flatMap(([key, amount]) => {\n    const [first, second] = key.split(\"\\u0000\");\n    if (amount === 0) return [];\n    return [amount > 0 ? { from: first, to: second, amount } : { from: second, to: first, amount: -amount }];\n  });\n}\n"},{"path":"beamline/components/allocation-editor/messages.ts","type":"registry:file","target":"src/ui/beamline/components/allocation-editor/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  saveLabel: \"Save\",\n  savedLabel: \"Saved\",\n  cancelLabel: \"Cancel\",\n  emptyLabel: \"Nothing to allocate yet.\",\n  changed: \"(changed)\",\n  apply: \"Apply\",\n  notMeasured: \"Not measured\",\n  ofAllocated: \"{sum} of {total} allocated\",\n  tradesWithAnyUnlockedPart: \"Trades with any unlocked part\",\n  noUnlockedNeighbour: \"No unlocked neighbour\",\n  takesFromUnallocated: \"Takes from Unallocated\",\n  andAreNotNeighbours: \"{a} and {b} are not neighbours.\",\n  isNotSet: \"{label} is not set.\",\n  enterAnAmountFor: \"Enter an amount for {label}.\",\n  canTGiveTimeAwayHere: \"{label} can't give time away here.\",\n  chooseWhereComeFrom: \"Choose where {amount} come from: {value}.\",\n  chooseWhereGo: \"Choose where {amount} go: {value}.\",\n  changeNotApplied: \"{label} change not applied.\",\n  assignTheUnallocatedBeforeSaving: \"Assign the {amount} unallocated before saving.\",\n  removeBeforeSaving: \"Remove {amount} before saving.\",\n  overBy: \"Over by {amount}\",\n  noChangesToSave: \"No changes to save.\",\n  thisPlanChangedElsewhereCancelToUse: \"This plan changed elsewhere: {summary}. Cancel to use it.\",\n  thePartsChangedYourUnsavedChangesWere: \"The parts changed; your unsaved changes were reset.\",\n  savedWithChanges: \"Saved with changes: {summary}.\",\n  savingS: \"Saving… {seconds} s\",\n  saving: \"Saving…\",\n  betweenAnd: \"Between {a} and {b}\",\n  amounts: \"Amounts\",\n  tradesWith: \"Trades with {value}\",\n  from: \"+{amount} from {partner}\",\n  to: \"−{amount} to {partner}\",\n  takeFrom: \"Take {amount} from\",\n  giveTo: \"Give {amount} to\",\n  movedFromTo: \"Moved {amount} from {from} to {to}.\",\n  canTGoBelow: \"{label} can't go below {amount}.\",\n  canTGoAbove: \"{label} can't go above {amount}.\",\n  isLocked: \"{label} is locked.\",\n  changeInStepsOf: \"Change in steps of {step}.\",\n  enterANumber: \"Enter a number.\",\n  unallocated: \"Unallocated\",\n  over: \"Over\",\n  notSet: \"Not set\",\n  changesDiscarded: \"Changes discarded.\",\n  saved: \"Saved.\",\n  couldnTSave: \"Couldn't save.\",\n  wasSavedAs: \"{value} was saved as {value2}.\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/button/button-variants.ts","type":"registry:file","target":"src/ui/beamline/components/button/button-variants.ts","content":"import { cn } from \"../../lib/utils\";\nimport styles from \"./button.module.css\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"danger\" | \"accent\";\n/** sm 32 · md 36 · lg 40 · touch 44 (a bottom action bar on a phone). */\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"touch\";\n/** Market side: buy fills with --pui-up, sell with --pui-down (a red-up market flips the two tokens, not the code). */\nexport type ButtonTone = \"up\" | \"down\";\n\n/** Which paint role paints each variant: filled variants are solids, the quiet ones keep their own light paint. */\nconst ROLE: Record<ButtonVariant, string | undefined> = {\n  primary: \"m-solid\",\n  accent: \"m-solid\",\n  secondary: \"m-control\",\n  outline: undefined,\n  ghost: undefined,\n  danger: undefined,\n};\n\n/** A variant's paint without the button's layout, for a group that wears one button's look (SplitButton paints the\n *  pair as one button and leaves its halves transparent). */\nexport function buttonPaint(variant: ButtonVariant = \"primary\", tone?: ButtonTone) {\n  return cn(ROLE[variant], styles[variant], tone && styles[tone]);\n}\n\n/** The classes that make any element look like a Button, for parts that render their own element. */\nexport function buttonClassName({\n  variant = \"primary\",\n  size = \"md\",\n  tone,\n  className,\n}: { variant?: ButtonVariant; size?: ButtonSize; tone?: ButtonTone; className?: string } = {}) {\n  return cn(styles.root, ROLE[variant], styles[variant], tone && styles[tone], styles[size], className);\n}\n"},{"path":"beamline/components/button/button.module.css","type":"registry:file","target":"src/ui/beamline/components/button/button.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Button. Every colour, size and duration is a token; a variant only sets the four local properties below. */\n  .root {\n    --_button-bg: transparent;\n    --_button-bg-hover: var(--pui-hover);\n    --_button-fg: var(--pui-ink);\n    --_button-edge: none;\n    --_button-height: var(--pui-control-height-md);\n    --_button-pad: 14px;\n    position: relative;\n    isolation: isolate;\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    gap: var(--pui-space-1-5);\n    flex-shrink: 0;\n    height: var(--_button-height);\n    min-width: var(--_button-height);\n    /* never wider than its room: in a narrow card the label ends in an ellipsis instead of spilling (the full words\n       stay the accessible name) */\n    max-width: 100%;\n    padding-inline: var(--_button-pad);\n    border: 0;\n    /* Nested context: inside a frame that sets --pui-nest-radius and --pui-nest-inset (and --pui-nest-ceiling for pills)\n       the corner is the shared nested corner (.nest-corner, utilities.css: radius − inset, or the button's own radius\n       where that would fall under the chip radius); elsewhere the control radius. --pui-button-radius wins over both. */\n    border-radius: var(--pui-button-radius, var(--pui-nested, var(--pui-radius-control)));\n    color: var(--_button-fg);\n    font-family: var(--pui-font-body);\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-medium);\n    line-height: 1;\n    letter-spacing: var(--pui-tracking-body);\n    white-space: nowrap;\n    text-decoration: none;\n    cursor: pointer;\n    user-select: none;\n    -webkit-tap-highlight-color: transparent;\n    /* frequency law: hover arrives at 0ms (below), leaves over the ruled departure; opacity and translate are listed\n       so a button that waits for attention (.reveal) fades away like every reveal instead of vanishing */\n    transition:\n      var(--m-transition),\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  /* the quiet variants paint themselves; filled and secondary variants are painted by their look role (m-solid,\n   m-control), which these zero-specificity rules can never override */\n  :where(.root) {\n    background: var(--_button-bg);\n    box-shadow: var(--_button-edge);\n  }\n  :where(.root:hover:not(:disabled, [aria-disabled=\"true\"])) {\n    background: var(--_button-bg-hover);\n  }\n  .root:hover:not(:disabled, [aria-disabled=\"true\"]) {\n    transition-duration: 0ms;\n  }\n  /* the asChild form has no motion: a CSS press instead */\n  .root:not(button):active {\n    transform: scale(0.97);\n  }\n  .root:disabled,\n  .root[aria-disabled=\"true\"]:not([data-loading]) {\n    opacity: var(--pui-opacity-disabled);\n    cursor: not-allowed;\n  }\n  .root[data-loading] {\n    cursor: progress;\n  }\n\n  /* Icons: 16px unless the icon says otherwise (zero specificity, so size-5 or an inline size wins) */\n  :where(.root) :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n    flex-shrink: 0;\n  }\n\n  .sm {\n    --_button-height: var(--pui-control-height-sm);\n    --_button-pad: var(--pui-space-2-5);\n    /* the base gap (--pui-space-1-5); 5px had no scale step, and 6px also lets the loader's negative margin cancel\n       the gap exactly at every size */\n  }\n  .md {\n    --_button-height: var(--pui-control-height-md);\n    --_button-pad: 14px;\n  }\n  .lg {\n    --_button-height: var(--pui-control-height-lg);\n    --_button-pad: var(--pui-space-4);\n    font-size: var(--pui-text-base);\n  }\n  /* a bottom action bar on a phone: the 44 px touch target (a platform minimum, not a spacing step, so it holds when\n     the spacing parameter changes — O1 §14.2), the large label */\n  .touch {\n    --_button-height: calc(var(--pui-control-height-lg) + 0.25rem);\n    --_button-pad: var(--pui-space-5);\n    font-size: var(--pui-text-base);\n  }\n  .root[data-icon-only] {\n    padding-inline: 0;\n    width: var(--_button-height);\n  }\n\n  /* the main action: ink on the page, inverted per theme */\n  .primary {\n    --m-color: var(--pui-ink);\n    --_button-fg: var(--pui-ink-inverse);\n  }\n  .secondary {\n    --_button-fg: var(--pui-ink);\n  }\n  .outline {\n    --_button-edge: inset 0 0 0 1px var(--pui-line-strong);\n    --_button-bg-hover: var(--pui-hover);\n  }\n  .ghost {\n    --_button-fg: var(--pui-ink-2);\n    --_button-bg-hover: var(--pui-hover);\n  }\n  .ghost:hover:not(:disabled) {\n    color: var(--pui-ink);\n  }\n  .danger {\n    --_button-fg: var(--pui-red);\n    --_button-edge: inset 0 0 0 1px color-mix(in oklab, var(--pui-red) 42%, transparent);\n    --_button-bg-hover: var(--pui-red-tint);\n  }\n  .accent {\n    --m-color: var(--pui-accent);\n    --_button-fg: var(--pui-accent-foreground);\n  }\n\n  /* Market side. Declared after the variants so it wins: a filled variant takes the side's colour and its on-colour\n     ink; a quiet variant keeps its paint and colours the label. */\n  .up {\n    --m-color: var(--pui-up);\n    --_button-fg: var(--pui-up);\n  }\n  .down {\n    --m-color: var(--pui-down);\n    --_button-fg: var(--pui-down);\n  }\n  .ghost:is(.up, .down):hover:not(:disabled) {\n    color: var(--_button-fg);\n  }\n  .primary.up,\n  .accent.up {\n    --_button-fg: var(--pui-on-up);\n  }\n  .primary.down,\n  .accent.down {\n    --_button-fg: var(--pui-on-down);\n  }\n\n  /* The main actions' light, where the look provides one, as a spectrum. Glow spills a soft light in the brand's\n     spectrum from under the button's bottom edge (clipped at it, so it never tints the face) that brightens under the\n     pointer. Prism keeps it inside the glass (--m-solid-glow-fit: 1): the face the light leaves by, the bottom edge,\n     splits it into a crisp line with a faint wash rising into the face, only while pointed at. Every other look\n     leaves --m-solid-glow at none, so the layer does not exist. */\n  .primary::after,\n  .accent::after {\n    --_fit: var(--m-solid-glow-fit, 0);\n    content: var(--m-solid-glow);\n    position: absolute;\n    z-index: -1;\n    top: calc((1 - var(--_fit)) * (100% + var(--m-solid-glow-gap, 0px)));\n    inset-inline: var(--m-solid-glow-inset, 14%);\n    height: var(--m-solid-glow-height, 14px);\n    border-radius: calc(\n      var(--_fit) * var(--pui-button-radius, var(--pui-nested, var(--pui-radius-control))) + (1 - var(--_fit)) *\n        var(--pui-radius-pill)\n    );\n    background: var(--m-spectrum);\n    background-size: var(--m-spectrum-size, 100% 100%);\n    /* the part of the spectrum that shows: this button's own angle (--m-prism-seed, new at every press), turned by\n       where the pointer is across the face (--m-prism-x) in a look that turns it (--m-solid-glow-turn) */\n    background-position: calc((var(--m-prism-seed, 0.3) + var(--m-prism-x, 0.5) * var(--m-solid-glow-turn, 0)) * 100%) 0;\n    filter: blur(var(--m-solid-glow-blur, 9px));\n    clip-path: var(--m-solid-glow-clip, inset(0 -32px -32px -32px));\n    mask: var(--m-solid-glow-mask, none);\n    mask-position:\n      0 0,\n      calc(var(--m-prism-seed, 0.3) * 100%) 0;\n    mask-composite: intersect;\n    opacity: var(--m-solid-glow-rest, 0.75);\n    scale: var(--m-solid-glow-scale, 1) 1;\n    pointer-events: none;\n    transition:\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      scale var(--pui-motion-out) var(--pui-ease-default),\n      background-position var(--pui-duration-considered) var(--pui-ease-default),\n      mask-position var(--pui-duration-considered) var(--pui-ease-default);\n  }\n  /* pointed at: the light brightens (glow) or grows out from the centre and drifts along while the pointer stays\n     (prism) */\n  .primary:hover::after,\n  .accent:hover::after {\n    opacity: 1;\n    scale: 1 1;\n    transition-duration: var(--m-solid-glow-in, 0ms);\n    animation: var(--m-solid-glow-flow, none);\n  }\n  /* A change of word or colour (Save → Saved, a tone turning green) passes a band of split light across the face once,\n     in a look that has one (prism: --m-solid-shift-light; the pen hook's ::before is free on a solid otherwise). */\n  .primary[data-shift]::before,\n  .accent[data-shift]::before {\n    content: var(--m-solid-shift-light, none);\n    position: absolute;\n    inset: 0;\n    z-index: -1;\n    border: 0;\n    border-radius: inherit;\n    background: var(--m-spectrum) 0 0 / var(--m-spectrum-size, 100% 100%);\n    mask: linear-gradient(105deg, transparent 39%, #000 47.5%, #000 52.5%, transparent 61%) 0 0 / 300% 100% no-repeat;\n    opacity: 0;\n    animation: prism-shift calc(var(--pui-duration-considered) * 2.2) var(--pui-ease-default);\n    pointer-events: none;\n  }\n  @keyframes prism-shift {\n    from {\n      mask-position: 100% 0;\n      opacity: 0.25;\n    }\n    35% {\n      opacity: 0.55;\n    }\n    to {\n      mask-position: 0 0;\n      opacity: 0;\n    }\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .primary::after,\n    .accent::after {\n      transition: none;\n      animation: none !important;\n    }\n    .primary[data-shift]::before,\n    .accent[data-shift]::before {\n      content: none;\n    }\n  }\n  .root:disabled::after,\n  .root[aria-disabled=\"true\"]::after {\n    content: none;\n  }\n\n  /* The loader opens its own width, and the negative margin cancels the gap so the label does not jump */\n  .loader {\n    display: inline-flex;\n    overflow: hidden;\n    margin-inline-end: calc(var(--pui-space-1-5) * -1);\n  }\n  .loader > * {\n    margin-inline-end: var(--pui-space-1-5);\n  }\n  .spinner {\n    color: currentColor;\n  }\n  .root[data-icon-only] .loader {\n    margin-inline-end: 0;\n  }\n  .root[data-icon-only] .loader > * {\n    margin-inline-end: 0;\n  }\n\n  /* The label holds its place while the text inside swaps; the leaving text pops out of flow against it */\n  .label {\n    position: relative;\n    display: inline-flex;\n    justify-content: center;\n    min-width: 0;\n    overflow: visible;\n  }\n  /* the clip box is let out by a little over a line so descenders are never cut, without changing the layout */\n  .text {\n    display: inline-block;\n    max-width: 100%;\n    margin-block: -0.2em;\n    padding-block: 0.2em;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .label[data-morphing] > .text {\n    overflow: visible;\n    text-overflow: clip;\n  }\n}\n\n@layer components {\n  :global(.pui[data-motion=\"reduced\"]) .root:not(button):active,\n  :global(.pui[data-motion=\"none\"]) .root:not(button):active {\n    transform: none;\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .root:not(button):active {\n      transform: none;\n    }\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.root {\n  composes: nest-corner from global;\n}\n"},{"path":"beamline/components/button/button.tsx","type":"registry:file","target":"src/ui/beamline/components/button/button.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport {\n  Children,\n  Fragment,\n  isValidElement,\n  useLayoutEffect,\n  useRef,\n  type ButtonHTMLAttributes,\n  type PointerEvent,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  type ComponentProps,\n} from \"react\";\nimport { AnimatePresence, LazyMotion, m, spring, useIsPresent } from \"motion/react\";\nimport { animate } from \"motion/mini\";\nimport * as Slot from \"@radix-ui/react-slot\";\nimport { Spinner } from \"../spinner/spinner\";\nimport type { BuiltInLook as Look } from \"../../runtime/preset\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./button.module.css\";\n\nexport type { ButtonVariant, ButtonSize, ButtonTone } from \"./button-variants\";\nexport { buttonPaint, buttonClassName } from \"./button-variants\";\nimport { buttonClassName, type ButtonVariant, type ButtonSize, type ButtonTone } from \"./button-variants\";\nimport type { ButtonDefaults } from \"./defaults\";\nexport type { ButtonDefaults } from \"./defaults\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\n\ntype NativeProps = Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\"\n>;\n\nexport interface ButtonProps extends NativeProps {\n  /** Visual weight: primary (ink-filled), secondary, outline, ghost, danger, or accent (filled with the brand). */\n  variant?: ButtonVariant;\n  /** Height 32 / 36 / 40 px; touch 44 px for a bottom action bar on a phone. */\n  size?: ButtonSize;\n  /** Market side. On primary/accent the fill becomes --pui-up (buy) or --pui-down (sell); on the quiet variants the\n   *  label takes that colour. Pair it with a word or arrow: colour is never the only signal. */\n  tone?: ButtonTone;\n  /** Shows a loader in front of the label, sets aria-busy and ignores clicks; size and focus stay. */\n  loading?: boolean;\n  classNames?: Partial<Record<\"root\" | \"label\" | \"loader\", string>>;\n  /** Paint this button in another look than the one in force around it (DESIGN-LANGUAGE.md \"Looks\"). */\n  look?: Look;\n  /** Render the single child (a link, a Radix trigger) with the button's look instead of a <button>. */\n  asChild?: boolean;\n  /** A square button holding only an icon (give it an aria-label). Decided by the children when left out: icon-only when\n   *  no words sit anywhere among them (an icon, or a frame of icons), so `<span>Close</span>` is a label. */\n  iconOnly?: boolean;\n  ref?: Ref<HTMLButtonElement>;\n}\n\n// Motion's animation features arrive in a deferred chunk (AT11): the button renders and works at once, and its press,\n// loader and label motion run once they land, a frame or two after mount. Buttons that mount after that get the\n// loaded features directly, so they render once instead of again when a lazy load resolves.\ntype Features = typeof import(\"./motion-features\").default;\nlet loaded: Features | null = null;\nconst features = () =>\n  import(\"./motion-features\").then((module) => {\n    loaded = module.default;\n    return loaded;\n  });\n\n/** Children with fragments opened up, so `{busy ? <><Icon />Saving</> : …}` reads as icon plus text. */\nconst flat = (children: ReactNode): ReactNode[] =>\n  Children.toArray(children).flatMap((c) =>\n    isValidElement<{ children?: ReactNode }>(c) && c.type === Fragment ? flat(c.props.children) : [c],\n  );\n\n/** Whether any words sit anywhere inside (an icon, or a frame of icons, has none). */\nfunction hasText(node: ReactNode): boolean {\n  if (typeof node === \"string\") return node.trim() !== \"\";\n  if (typeof node === \"number\") return true;\n  if (Array.isArray(node)) return node.some(hasText);\n  return isValidElement<{ children?: ReactNode }>(node) && hasText(node.props.children);\n}\n\n/** Splits children into the text run (the label that can change) and everything else (icons), in order, and says\n *  whether any element carries content of its own (a `<span>Close</span>` label, unlike an icon). */\nfunction split(children: ReactNode) {\n  const parts = flat(children);\n  const text = parts\n    .filter((p) => typeof p === \"string\" || typeof p === \"number\")\n    .join(\"\")\n    .trim();\n  const firstText = parts.findIndex((p) => typeof p === \"string\" || typeof p === \"number\");\n  const before = firstText === -1 ? parts : parts.slice(0, firstText).filter(isValidElement);\n  const after = firstText === -1 ? [] : parts.slice(firstText).filter(isValidElement);\n  const content = parts.some((p) => isValidElement<{ children?: ReactNode }>(p) && hasText(p.props.children));\n  return { text, before, after, content };\n}\n\n/**\n * The action control. Hover answers at once and lets go over --pui-motion-out; a press scales down at once, or swells\n * on the snappy spring where the look's --pui-press-scale is above 1 (glass), never on a trigger that anchors a\n * floating layer, which would measure a resized box; a changed label slides in while the\n * width follows on a critically damped spring; loading keeps the size and the focus.\n */\nexport function Button({\n  variant: variantProp,\n  size: sizeProp,\n  tone: toneProp,\n  loading = false,\n  look,\n  asChild = false,\n  iconOnly: iconOnlyProp,\n  className,\n  classNames,\n  children,\n  type,\n  onClick,\n  onPointerMove,\n  onPointerDown,\n  disabled,\n  ref,\n  ...props\n}: ButtonProps) {\n  const i18n = useMessages(\"button\", englishMessages);\n\n  const defaults = useComponentDefaults<ButtonDefaults>(\"button\", [\"variant\", \"size\", \"tone\"]);\n  const variant = variantProp ?? defaults.variant ?? \"primary\";\n  const size = sizeProp ?? defaults.size ?? \"md\";\n  const tone = toneProp ?? defaults.tone;\n  const host = useRef<HTMLButtonElement>(null);\n  const attach = useComposedRef(host, ref);\n  const motionTokens = useMotionTokens(host);\n  const requestedReduced = useReducedMotionSafe();\n  const reduced = requestedReduced || motionTokens.reduced;\n  // Reduced motion keeps a restrained fade; none removes it (APPEARANCE A06).\n  const fade = motionTokens.mode === \"none\" ? 0 : motionTokens.fade.in;\n  // a caller's aria-disabled keeps the button focusable (focus never drops after Save resolves) but inert to presses\n  const softDisabled = props[\"aria-disabled\"] === true || props[\"aria-disabled\"] === \"true\";\n  const { text, before, after, content } = split(children);\n  const prism = usePrismLight(host, variant === \"primary\" || variant === \"accent\", `${text}|${tone}|${variant}`);\n  // with asChild the text sits inside the child (the link), not among our own children\n  const inner = asChild && isValidElement<{ children?: ReactNode }>(children) ? split(children.props.children) : null;\n  const iconOnly = iconOnlyProp ?? (inner ? !inner.text && !inner.content : !text && !content);\n  const anchor =\n    (props[\"aria-haspopup\"] != null && props[\"aria-haspopup\"] !== false && props[\"aria-haspopup\"] !== \"false\") ||\n    \"data-state\" in props;\n  const shared = {\n    \"data-slot\": \"button\",\n    \"data-variant\": variant,\n    \"data-size\": size,\n    \"data-tone\": tone,\n    \"data-icon-only\": iconOnly ? \"\" : undefined,\n    \"data-loading\": loading ? \"\" : undefined,\n    \"data-look\": look,\n    className: buttonClassName({ variant, size, tone, className: cn(classNames?.root, className) }),\n  };\n\n  if (asChild)\n    return (\n      <Slot.Root {...shared} {...props} ref={attach}>\n        {children}\n      </Slot.Root>\n    );\n\n  const button = (\n    <m.button\n      ref={attach}\n      type={type ?? \"button\"}\n      {...shared}\n      {...props}\n      disabled={disabled}\n      aria-busy={loading || undefined}\n      aria-disabled={loading || softDisabled || undefined}\n      onPointerMove={(e) => {\n        prism.turn(e);\n        onPointerMove?.(e);\n      }}\n      onPointerDown={(e) => {\n        prism.press();\n        onPointerDown?.(e);\n      }}\n      onClick={(e) => {\n        if (loading || softDisabled) {\n          e.preventDefault();\n          return;\n        }\n        onClick?.(e);\n      }}\n      whileTap={\n        reduced || anchor || disabled || loading || softDisabled\n          ? undefined\n          : {\n              scale: motionTokens.pressScale,\n              // a press that sinks lands at once; one that swells toward the finger (glass) grows on the spring\n              transition: motionTokens.pressScale > 1 ? motionTokens.spring.snappy : { duration: 0 },\n            }\n      }\n      transition={{ scale: motionTokens.spring.snappy }}\n    >\n      <AnimatePresence initial={false}>\n        {loading && (\n          <m.span\n            key=\"loader\"\n            data-slot=\"button-loader\"\n            className={cn(styles.loader, classNames?.loader)}\n            initial={reduced ? { opacity: 0 } : { width: 0, opacity: 0 }}\n            animate={{ width: \"auto\", opacity: 1 }}\n            exit={reduced ? { opacity: 0 } : { width: 0, opacity: 0 }}\n            transition={{ duration: reduced ? fade : motionTokens.duration.standard, ease: motionTokens.ease.default }}\n          >\n            <Spinner size=\"sm\" label={i18n(\"working\")} className={styles.spinner} />\n          </m.span>\n        )}\n      </AnimatePresence>\n      {iconOnly ? !loading && before : before}\n      {!iconOnly && text && (\n        <Label text={text} reduced={!!reduced} fade={fade} motionTokens={motionTokens} className={classNames?.label} />\n      )}\n      {after}\n    </m.button>\n  );\n  return <LazyMotion features={loaded ?? features}>{button}</LazyMotion>;\n}\n\n/**\n * The main action's light inside the glass, in a look that draws one (prism): its own angle from the start, turned by\n * where the pointer is across the face, a new angle at every press, and a sweep of split light (data-shift) when the\n * button's word, tone or variant changes after it first appears. Writes two style properties and one attribute on the\n * button; nothing runs at rest. Looks without this light ignore all three.\n */\nfunction usePrismLight(host: RefObject<HTMLButtonElement | null>, solid: boolean, state: string) {\n  const frame = useRef(0);\n  const shown = useRef(state);\n  const ending = useRef(0);\n  useLayoutEffect(() => {\n    const el = host.current;\n    if (el && solid) el.style.setProperty(\"--m-prism-seed\", Math.random().toFixed(3));\n  }, [host, solid]);\n  useLayoutEffect(() => {\n    const el = host.current;\n    if (!el || !solid || shown.current === state) return;\n    shown.current = state;\n    // restart the sweep if it is still running\n    delete el.dataset.shift;\n    void el.offsetWidth;\n    el.dataset.shift = \"\";\n    clearTimeout(ending.current);\n    ending.current = window.setTimeout(() => delete el.dataset.shift, 1200);\n  }, [host, solid, state]);\n  useLayoutEffect(\n    () => () => {\n      clearTimeout(ending.current);\n      cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n  return {\n    turn: (e: PointerEvent<HTMLButtonElement>) => {\n      if (!solid || e.pointerType !== \"mouse\" || frame.current) return;\n      const el = e.currentTarget;\n      const x = e.clientX;\n      frame.current = requestAnimationFrame(() => {\n        frame.current = 0;\n        const r = el.getBoundingClientRect();\n        if (r.width) el.style.setProperty(\"--m-prism-x\", ((x - r.left) / r.width).toFixed(3));\n      });\n    },\n    press: () => {\n      if (solid) host.current?.style.setProperty(\"--m-prism-seed\", Math.random().toFixed(3));\n    },\n  };\n}\n\n/** The text run. When it changes, the old words leave upward and the new ones rise in, while the width moves from the\n *  old measure to the new one; no measuring happens while the label is unchanged. */\nfunction Label({\n  text,\n  reduced,\n  fade,\n  motionTokens,\n  className,\n}: {\n  text: string;\n  reduced: boolean;\n  /** Seconds of the label crossfade under reduced motion (0 under none). */\n  fade: number;\n  /** The button's own, resolved at the button: the label sits inside it, in the same look and motion. */\n  motionTokens: ReturnType<typeof useMotionTokens>;\n  className?: string;\n}) {\n  const slot = useRef<HTMLSpanElement>(null);\n  const latest = useRef(motionTokens);\n  useLayoutEffect(() => {\n    latest.current = motionTokens;\n  }, [motionTokens]);\n  const last = useRef<{ text: string; width: number } | null>(null);\n  useLayoutEffect(() => {\n    const el = slot.current;\n    if (!el) return;\n    const prev = last.current;\n    // first mount: read the width after paint, when layout is already clean, instead of forcing a layout per button\n    if (!prev) {\n      last.current = { text, width: Number.NaN };\n      const id = requestAnimationFrame(() => {\n        if (last.current?.text === text) last.current = { text, width: el.getBoundingClientRect().width };\n      });\n      return () => cancelAnimationFrame(id);\n    }\n    el.style.width = \"\";\n    const width = el.getBoundingClientRect().width;\n    last.current = { text, width };\n    // a label that is not rendered (a hidden toolbar on a phone) has nothing to animate\n    if (prev.text === text || reduced || Number.isNaN(prev.width) || !el.getClientRects().length) return;\n    // The same spring, played natively: motion/mini samples the generator into a linear() easing.\n    const { visualDuration, bounce } = latest.current.spring.morph;\n    // while the width eases the words are clipped, not cut short with an ellipsis (that is for a room too narrow)\n    el.dataset.morphing = \"\";\n    const run = animate(el, { width: [`${prev.width}px`, `${width}px`] }, { type: spring, visualDuration, bounce });\n    run.then(() => {\n      el.style.width = \"\";\n      delete el.dataset.morphing;\n    });\n    // cancel, not stop: stop commits the running value, which throws once the element stops being rendered, and the\n    // next change measures afresh anyway\n    return () => {\n      run.cancel();\n      el.style.width = \"\";\n      delete el.dataset.morphing;\n    };\n  }, [text, reduced]);\n  // A label that changes faster than its words can rise in (a count read live while a hand drags) shows each new\n  // value at once: otherwise every value would start invisible and the button would read blank for the whole drag.\n  const rapid = useRapid(text, motionTokens.duration.standard * 1000);\n  const enter = reduced ? { opacity: 0 } : { opacity: 0, y: \"0.45em\", filter: `blur(${motionTokens.blur.subtle}px)` };\n  const leave = reduced\n    ? { opacity: 0 }\n    : {\n        opacity: 0,\n        y: \"-0.45em\",\n        filter: `blur(${motionTokens.blur.subtle}px)`,\n        transition: { duration: motionTokens.duration.fast },\n      };\n  return (\n    <span ref={slot} data-slot=\"button-label\" className={cn(styles.label, className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={rapid}>\n        <Words\n          key={text}\n          className={styles.text}\n          custom={rapid}\n          variants={{\n            enter: (fast: boolean) => (fast ? { opacity: 1, y: \"0em\", filter: \"blur(0px)\" } : enter),\n            shown: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n            leave: (fast: boolean) => (fast ? { opacity: 0, transition: { duration: 0 } } : leave),\n          }}\n          initial=\"enter\"\n          animate=\"shown\"\n          exit=\"leave\"\n          transition={{ duration: reduced ? fade : motionTokens.duration.standard, ease: motionTokens.ease.default }}\n        >\n          {text}\n        </Words>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Whether `text` changed again within `ms` of its last change. Read in render: the answer belongs to this text. */\nfunction useRapid(text: string, ms: number) {\n  const seen = useRef({ text, at: Number.NEGATIVE_INFINITY, rapid: false });\n  if (seen.current.text !== text) {\n    const at = performance.now();\n    seen.current = { text, at, rapid: at - seen.current.at < ms };\n  }\n  return seen.current.rapid;\n}\n\n/** One label's words. Leaving, they are already gone for assistive tech: the button's name is the new label at once,\n *  not both while the old words animate away (or for good, where time stands still in a test). */\nfunction Words(props: ComponentProps<typeof m.span>) {\n  const present = useIsPresent();\n  return <m.span {...props} aria-hidden={present ? undefined : true} />;\n}\n\nexport default Button;\n"},{"path":"beamline/components/button/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/button/defaults.ts","content":"import type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-variants\";\n/** Scoped design choices only; no loading, events or domain state. */\nexport interface ButtonDefaults {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  tone?: ButtonTone;\n}\n"},{"path":"beamline/components/button/messages.ts","type":"registry:file","target":"src/ui/beamline/components/button/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  working: \"Working\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/button/motion-features.ts","type":"registry:file","target":"src/ui/beamline/components/button/motion-features.ts","content":"// REASON: Motion's DOM animation features (tween, spring, presence, press) are loaded after the first paint, so a\n// Button costs a small part of Motion on first load; LazyMotion in button.tsx renders until they arrive.\nimport { domAnimation } from \"motion/react\";\n\nexport default domAnimation;\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/select/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/select/defaults.ts","content":"export interface SelectDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/select/messages.ts","type":"registry:file","target":"src/ui/beamline/components/select/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  placeholder: \"Select\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/select/select.module.css","type":"registry:file","target":"src/ui/beamline/components/select/select.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* The trigger is a field frame (paint, hover, focus edge, invalid, disabled at --pui-opacity-disabled all come from\n     _shared/field); it adds only what a button-like frame needs. */\n  .trigger {\n    width: 100%;\n    justify-content: space-between;\n    text-align: start;\n    cursor: pointer;\n  }\n  .trigger:focus-visible {\n    outline: none;\n  }\n  .trigger:disabled {\n    cursor: not-allowed;\n  }\n  .trigger[data-placeholder] {\n    color: var(--pui-ink-3);\n  }\n  /* the value line: label, then the detail on the same line or wrapped out of sight */\n  .value {\n    display: flex;\n    min-width: 0;\n    flex: 1;\n    flex-wrap: wrap;\n    align-items: baseline;\n    column-gap: var(--pui-space-2);\n    height: 1lh;\n    overflow: hidden;\n  }\n  .valueDetail {\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    white-space: nowrap;\n  }\n  .chevron {\n    flex-shrink: 0;\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n    color: var(--pui-ink-3);\n  }\n\n  /* list rows: a detail at the end, the check after it at the inline end */\n  .detail {\n    flex-shrink: 0;\n    margin-inline-start: auto;\n    padding-inline-start: var(--pui-space-3);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n  }\n  .check {\n    position: absolute;\n    inset-inline-end: var(--pui-space-2);\n    display: inline-flex;\n  }\n  .check > svg {\n    color: var(--pui-accent);\n  }\n}\n"},{"path":"beamline/components/select/select.tsx","type":"registry:file","target":"src/ui/beamline/components/select/select.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useDeprecatedProp } from \"../../hooks/use-deprecated-prop\";\n\nimport { Fragment, useRef, type CSSProperties, type ReactNode, type Ref } from \"react\";\nimport { FollowPlate } from \"../_shared/glide\";\nimport { Check, ChevronsUpDown } from \"lucide-react\";\nimport * as RadixSelect from \"@radix-ui/react-select\";\nimport { Field, useFieldIds, type FieldSize, type FieldClassNames } from \"../_shared/field\";\nimport { usePortalContainer, useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SelectDefaults } from \"./defaults\";\nexport type { SelectDefaults } from \"./defaults\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport field from \"../_shared/field.module.css\";\nimport overlay from \"../_shared/overlay.module.css\";\nimport styles from \"./select.module.css\";\n\nexport interface SelectOption {\n  /** Value represented by this item. Required. */\n  value: string;\n  /** Text displayed for this item. Required. */\n  label: ReactNode;\n  /** Icon element displayed with this item. Omitted by default. */\n  icon?: ReactNode;\n  /** A short detail at the end of the row (an offset, a plan); the field shows it beside the chosen label while it has\n   *  room for it whole. */\n  description?: ReactNode;\n  /** Whether this option is unavailable for selection. Omitted by default. */\n  disabled?: boolean;\n}\nexport interface SelectGroup {\n  /** Text displayed for this item. Required. */\n  label: string;\n  /** Choices available for this question or field. Required. */\n  options: SelectOption[];\n}\n\nexport interface SelectProps {\n  /** Visible label or accessible name for the control. Required. */\n  label: ReactNode;\n  /** Shows the label visually while keeping its accessible name; defaults to true. Takes precedence over hideLabel. */\n  showLabel?: boolean;\n  /** Hides the label; defaults to false. @deprecated Use showLabel={false} instead. */\n  hideLabel?: boolean;\n  /** Supporting text beneath the label or heading. Defaults to no supporting text. */\n  description?: ReactNode;\n  /** Error message shown in place of the usual supporting text. Defaults to no error message. */\n  error?: ReactNode;\n  /** Text shown before a value is entered or selected. Defaults to \"Select\". */\n  placeholder?: string;\n  /** Options, or groups of options with a heading. */\n  options: (SelectOption | SelectGroup)[];\n  /** Controlled value of the chosen option; omit to use defaultValue. */\n  value?: string;\n  /** Initial value of the chosen option when uncontrolled. Defaults to no selected option. */\n  defaultValue?: string;\n  /** Reports value of the chosen option after an edit; no callback by default. */\n  onValueChange?: (value: string) => void;\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: FieldSize;\n  /** Prevents interaction with the control. Defaults to false. */\n  disabled?: boolean;\n  /** Marks the field as required for form submission. Defaults to false. */\n  required?: boolean;\n  /** Field name used in native form submission. Defaults to no form field name. */\n  name?: string;\n  /** DOM id used to connect the control and its label. Defaults to an automatically generated control id. */\n  id?: string;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\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?: FieldClassNames &\n    Partial<Record<\"trigger\" | \"value\" | \"icon\" | \"list\" | \"viewport\" | \"item\" | \"groupLabel\" | \"separator\", string>>;\n  /** Controlled open state; omit to use defaultOpen. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Defaults to false. */\n  defaultOpen?: boolean;\n  /** Reports open state after a change; no callback by default. */\n  onOpenChange?: (open: boolean) => void;\n  /** Receives the button or trigger; no ref by default. */\n  ref?: Ref<HTMLButtonElement>;\n}\n\nconst isGroup = (o: SelectOption | SelectGroup): o is SelectGroup => \"options\" in o;\n\n/** One choice from a short known list, in the same frame as every text field. Type to jump; the list opens under the\n *  field at its width. */\nexport function Select({\n  label,\n  hideLabel: hideLabelProp,\n  showLabel,\n  description,\n  error,\n  placeholder: placeholderProp,\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  size: sizeProp,\n  disabled,\n  required,\n  name,\n  id,\n  className,\n  classNames,\n  style,\n  open,\n  defaultOpen,\n  onOpenChange,\n  ref,\n}: SelectProps) {\n  const i18n = useMessages(\"select\", englishMessages);\n  const placeholder = placeholderProp === undefined ? i18n(\"placeholder\") : placeholderProp;\n\n  useDeprecatedProp(\"Select\", \"hideLabel\", \"showLabel={false}\", hideLabelProp !== undefined);\n  const hideLabel = showLabel === undefined ? hideLabelProp : !showLabel;\n  const defaults = useComponentDefaults<SelectDefaults>(\"select\", [\"size\"]);\n  const size = sizeProp ?? defaults.size ?? \"md\";\n  const { controlId, descriptionId, errorId, aria } = useFieldIds(id, { description, error });\n  const container = usePortalContainer();\n  const viewport = useRef<HTMLDivElement>(null);\n  // the choice, kept here too (Radix keeps its own), so the field can show the chosen option's detail\n  const [current, setCurrent] = useControllable(value, defaultValue, onValueChange);\n  const chosen = options.flatMap((o) => (isGroup(o) ? o.options : [o])).find((o) => o.value === current);\n  const item = (o: SelectOption) => (\n    <RadixSelect.Item\n      key={o.value}\n      value={o.value}\n      disabled={o.disabled}\n      data-menu-item=\"\"\n      data-glide=\"\"\n      data-slot=\"select-item\"\n      className={cn(overlay.item, \"pe-8\", classNames?.item)}\n    >\n      {o.icon}\n      <span className=\"min-w-0 truncate\">\n        <RadixSelect.ItemText>{o.label}</RadixSelect.ItemText>\n      </span>\n      {o.description && <span className={styles.detail}>{o.description}</span>}\n      <RadixSelect.ItemIndicator className={styles.check}>\n        <Check />\n      </RadixSelect.ItemIndicator>\n    </RadixSelect.Item>\n  );\n  return (\n    <Field\n      label={label}\n      hideLabel={hideLabel}\n      description={description}\n      error={error}\n      required={required}\n      requiredOnControl\n      controlId={controlId}\n      descriptionId={descriptionId}\n      errorId={errorId}\n      className={className}\n      classNames={classNames}\n      style={style}\n    >\n      <RadixSelect.Root\n        open={open}\n        defaultOpen={defaultOpen}\n        onOpenChange={onOpenChange}\n        value={value}\n        defaultValue={defaultValue}\n        onValueChange={(v) => {\n          setCurrent(v);\n        }}\n        disabled={disabled}\n        required={required}\n        name={name}\n      >\n        <RadixSelect.Trigger\n          ref={ref}\n          data-slot=\"select\"\n          {...aria}\n          className={cn(field.frame, field[size], styles.trigger, classNames?.trigger)}\n          data-invalid={error ? \"\" : undefined}\n        >\n          {/* the label, then the chosen option's detail (UTC+3, Pro plan): the detail wraps out of sight (one line,\n              clipped) when the field is too narrow to show it whole, so a narrow field shows the label alone */}\n          <span data-slot=\"select-value\" className={cn(styles.value, classNames?.value)}>\n            <span className=\"max-w-full truncate\">\n              <RadixSelect.Value placeholder={placeholder} />\n            </span>\n            {chosen?.description && (\n              <span data-slot=\"select-detail\" className={styles.valueDetail}>\n                {chosen.description}\n              </span>\n            )}\n          </span>\n          <RadixSelect.Icon data-slot=\"select-icon\" className={classNames?.icon}>\n            <ChevronsUpDown className={styles.chevron} />\n          </RadixSelect.Icon>\n        </RadixSelect.Trigger>\n        {container !== null && (\n          <RadixSelect.Portal container={container}>\n            <RadixSelect.Content\n              data-slot=\"select-list\"\n              position=\"popper\"\n              sideOffset={6}\n              collisionPadding={8}\n              className={cn(\n                overlay.surface,\n                overlay.menu,\n                classNames?.list,\n                \"max-h-[min(360px,var(--radix-select-content-available-height))] w-[var(--radix-select-trigger-width)]\",\n              )}\n            >\n              <RadixSelect.Viewport\n                ref={viewport}\n                data-slot=\"select-viewport\"\n                className={cn(\"relative\", classNames?.viewport)}\n              >\n                <FollowPlate list={viewport} rows=\"[data-slot=select-item]\" />\n                {options.map((o, i) =>\n                  isGroup(o) ? (\n                    <Fragment key={o.label}>\n                      {i > 0 && (\n                        <RadixSelect.Separator\n                          data-slot=\"select-separator\"\n                          className={cn(overlay.separator, classNames?.separator)}\n                        />\n                      )}\n                      <RadixSelect.Group>\n                        <RadixSelect.Label\n                          data-slot=\"select-group-label\"\n                          className={cn(overlay.heading, classNames?.groupLabel)}\n                        >\n                          {o.label}\n                        </RadixSelect.Label>\n                        {o.options.map(item)}\n                      </RadixSelect.Group>\n                    </Fragment>\n                  ) : (\n                    item(o)\n                  ),\n                )}\n              </RadixSelect.Viewport>\n            </RadixSelect.Content>\n          </RadixSelect.Portal>\n        )}\n      </RadixSelect.Root>\n    </Field>\n  );\n}\n\nexport default Select;\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-deprecated-prop.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-deprecated-prop.ts","content":"\"use client\";\n\nimport { useEffect } from \"react\";\n\n// Bundlers replace NODE_ENV for browser builds; this declaration emits no Node dependency.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nconst warned = new Set<string>();\n\n/** Report a legacy prop once after mount in development; production and server renders stay silent. */\nexport function useDeprecatedProp(component: string, prop: string, replacement: string, supplied: boolean) {\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"development\" || !supplied) return;\n    const key = `${component}.${prop}`;\n    if (warned.has(key)) return;\n    warned.add(key);\n    console.warn(`[Beamline] ${key} is deprecated. Use ${replacement} instead; the legacy prop still works.`);\n  }, [component, prop, replacement, supplied]);\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-elapsed.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-elapsed.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/** Whole seconds since `active` became true (or since `since`, a Date.now() time), ticking each second for \"Saving…\n *  4s\"; 0 while inactive. */\nexport function useElapsed(active: boolean, since?: number) {\n  const [seconds, setSeconds] = useState(0);\n  useEffect(() => {\n    if (!active) return;\n    const start = since ?? Date.now();\n    const id = setInterval(() => setSeconds(Math.floor((Date.now() - start) / 1000)), 1000);\n    return () => {\n      clearInterval(id);\n      setSeconds(0);\n    };\n  }, [active, since]);\n  return seconds;\n}\n"},{"path":"beamline/hooks/use-element-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-element-ref.ts","content":"\"use client\";\n\nimport { useCallback, useMemo, useRef, useState, type Ref, type RefObject } from \"react\";\nimport { useComposedRef } from \"./use-composed-ref\";\n\n/**\n * A ref for an element that mounts after its component, for the engine hooks that attach in an effect (`useDrag`):\n * the returned object changes when the element appears or goes, so the hook attaches to it then (a sheet's panel,\n * mounted when it opens; a track shown once its data has loaded). A plain `useRef` would leave the hook attached to\n * nothing. Spread `attach` as the element's `ref`; `external` is the caller's ref, kept in step.\n */\nexport function useElementRef<T extends Element>(external?: Ref<T>): [RefObject<T | null>, (node: T | null) => void] {\n  const [node, setNode] = useState<T | null>(null);\n  const object = useMemo(() => ({ current: node }), [node]);\n  const inner = useRef<T>(null);\n  const forward = useComposedRef(inner, external);\n  const attach = useCallback(\n    (el: T | null) => {\n      setNode(el);\n      return forward(el);\n    },\n    [forward],\n  );\n  return [object, attach];\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/focus.ts","type":"registry:file","target":"src/ui/beamline/lib/focus.ts","content":"/**\n * Keeping a keyboard user's place when something disappears. Removing the element that has focus (a dismissed alert,\n * a removed chip or file, a deleted row) drops focus on <body>, and the next Tab starts at the top of the page.\n */\n\nconst TABBABLE = \"a[href], button, input:not([type=hidden]), select, textarea, [tabindex], [contenteditable=true]\";\n\nfunction tabbables(scope: Element) {\n  // a modal layer (menu, dialog) marks the rest of the page aria-hidden while it is open (data-aria-hidden): that\n  // content is still where focus should land once the layer closes\n  return [...scope.querySelectorAll<HTMLElement>(TABBABLE)].filter(\n    (e) =>\n      e.tabIndex >= 0 &&\n      !e.matches(\":disabled\") &&\n      !e.closest(\"[inert], [aria-hidden=true]:not([data-aria-hidden])\") &&\n      e.getClientRects().length > 0,\n  );\n}\n\n/** The control after `item` in reading order (outside it), or the one before it. */\nfunction neighbour(item: Element, scope: Element) {\n  const all = tabbables(scope).filter((e) => !item.contains(e));\n  return (\n    all.find((e) => item.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_FOLLOWING) ??\n    all.reverse().find((e) => item.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_PRECEDING) ??\n    null\n  );\n}\n\n/**\n * Call just before `item` is removed or hidden: if focus is inside it, focus moves to the next control after it (or\n * the one before). `scope` keeps the search inside a widget (a toolbar, a field); `fallback` is used when the scope has\n * nothing else to focus. Does nothing when focus is elsewhere, so a removal the user did not start never steals focus.\n */\nexport function focusAfterRemoval(\n  item: Element | null | undefined,\n  { scope, fallback }: { scope?: Element | null; fallback?: HTMLElement | null } = {},\n) {\n  if (!item || !item.contains(document.activeElement)) return;\n  (neighbour(item, scope ?? document.body) ?? fallback)?.focus();\n}\n\n/**\n * For an action that may or may not remove its item (the app decides, as with a row's Delete): call before running it.\n * If the item is gone afterwards and focus fell to the page, focus moves to the control that followed it.\n */\nexport function keepPlaceIfRemoved(item: Element | null | undefined, { scope }: { scope?: Element | null } = {}) {\n  if (!item) return;\n  const target = neighbour(item, scope ?? document.body);\n  const check = () => {\n    if (\n      !item.isConnected &&\n      (!document.activeElement || document.activeElement === document.body) &&\n      target?.isConnected\n    )\n      target.focus();\n  };\n  setTimeout(check, 0);\n  setTimeout(check, 400); // after an exit animation\n}\n\n/**\n * A press on a part's surface that hands focus to its control (a slider's well, a mark under its track): call from the\n * pointerdown. Focus goes to `control` without the keyboard's ring (`focusVisible: false`, so the part does not switch\n * to its keyboard look after a click). A mouse press is also followed by the browser's own focus step, which moves\n * focus to the nearest focusable ancestor or the page and leaves the arrow keys dead after the click; that step is\n * cancelled for this one press (and the press selects no text). Cancelling the pointerdown instead would also hide the\n * click from the browser, which then reads the focus as the keyboard's. A control a keyboard user already focused keeps\n * its ring.\n */\nexport function focusFromPress(\n  control: HTMLElement | null | undefined,\n  event: PointerEvent | { nativeEvent: PointerEvent },\n) {\n  if (!control) return;\n  const pointer = \"nativeEvent\" in event ? event.nativeEvent : event;\n  // asserted, not declared globally: shipped source must type-check without this repo's globals.d.ts, on the\n  // TypeScript an app already has (create-next-app installs 5.9, whose DOM lib lacks focusVisible)\n  control.focus({ preventScroll: true, focusVisible: false } as FocusOptions);\n  // the mouse's compatibility events follow the pointerdown in the same task; a finger's arrive only after a tap\n  if (pointer.pointerType !== \"mouse\") return;\n  const cancel = (e: MouseEvent) => e.preventDefault();\n  window.addEventListener(\"mousedown\", cancel, { capture: true, once: true });\n  setTimeout(() => window.removeEventListener(\"mousedown\", cancel, { capture: true }), 0);\n}\n"},{"path":"beamline/lib/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":"Allocation editor is in src/ui/beamline/components/allocation-editor/allocation-editor.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/allocation-editor"}}