{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"radio-group","type":"registry:component","title":"Radio group","description":"One choice from a short visible set, each option with an optional description, vertical or in a row.","categories":["forms"],"dependencies":["@radix-ui/react-direction@1.1.4","@radix-ui/react-label@2.1.15","@radix-ui/react-radio-group@1.4.7","clsx@^2.1.1","lucide-react@^1.48.0","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/_shared/field.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/field.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A field: label (with an optional aside at the end of its row), the control, then one message line. The gap under\n   the control belongs to the message (--pui-field-gap), so a message that arrives or leaves takes its gap with it. */\n  /* a column, not a grid: a message (or a fold) that leaves pulls its gap away with a negative margin, which a flex\n     column honours and a grid track does not (it would stop at zero and stretch the leaving part back open) */\n  .field {\n    --pui-field-gap: var(--pui-space-1-5);\n    /* positioned: a hidden label or a count's spoken words (sr-only, absolutely placed) stay inside the field */\n    position: relative;\n    display: flex;\n    min-width: 0;\n    flex-direction: column;\n    gap: var(--pui-field-gap);\n  }\n  .labelRow {\n    display: flex;\n    min-width: 0;\n    align-items: baseline;\n    gap: var(--pui-space-2);\n  }\n  .aside {\n    margin-inline-start: auto;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n  }\n\n  /* The message: one line at a time. A new line grows in to its own height (0fr to 1fr, no measuring) while the one it\n   replaces folds away above it, so the height glides from one to the other; a line that leaves with nothing after it\n   takes the gap with it. Arrival over --pui-motion-in, departure over --pui-motion-out; reduced motion keeps the fades. */\n  .messages {\n    display: grid;\n    margin-top: calc(var(--pui-field-gap, var(--pui-space-1-5)) * -1);\n  }\n  .line {\n    display: grid;\n    grid-template-rows: 1fr;\n    opacity: 1;\n    transition:\n      grid-template-rows var(--pui-motion-in) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n  }\n  @starting-style {\n    .line {\n      grid-template-rows: 0fr;\n      opacity: 0;\n    }\n  }\n  /* the line a field is first drawn with does not grow in */\n  .line[data-initial] {\n    transition: none;\n  }\n  /* a swap is one change of size: the old line folds and the new one grows over the same duration and curve, so their\n     sum (the field's height) moves straight from one to the other, with no swell between; the old words fade first */\n  .line[data-leaving] {\n    grid-template-rows: 0fr;\n    opacity: 0;\n    transition:\n      grid-template-rows var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n    pointer-events: none;\n  }\n  .messages:has(> .line[data-leaving]) > .line:not([data-leaving], [data-initial]) {\n    transition-duration: var(--pui-motion-out), var(--pui-fade-in, var(--pui-motion-in));\n  }\n  .clip {\n    min-height: 0;\n    overflow: clip;\n    overflow-clip-margin: var(--pui-space-1);\n  }\n  .message {\n    display: flex;\n    align-items: flex-start;\n    gap: var(--pui-space-1-5);\n    padding-top: var(--pui-field-gap, var(--pui-space-1-5));\n    font-size: var(--pui-text-xs);\n    line-height: 1.375;\n  }\n  .description {\n    display: block;\n    color: var(--pui-ink-3);\n  }\n  .error {\n    color: var(--pui-red);\n  }\n  .success {\n    color: var(--pui-green);\n  }\n  .icon {\n    flex-shrink: 0;\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    margin-top: 1px;\n  }\n\n  /* The count in the label's row: tabular, quiet, shown with attention (.reveal) and in words near the limit. */\n  .count {\n    font-variant-numeric: tabular-nums;\n    white-space: nowrap;\n  }\n  .count[data-state=\"near\"],\n  .count[data-state=\"full\"] {\n    color: var(--pui-orange);\n  }\n  .count[data-state=\"over\"] {\n    color: var(--pui-red);\n  }\n\n  /* The status at the end of the frame: the spinner arrives scaled in from its centre, and closes into the check. */\n  .status {\n    display: inline-grid;\n    flex-shrink: 0;\n    place-items: center;\n    animation: status-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  @keyframes status-in {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.4 * var(--pui-motion-travel, 1));\n    }\n  }\n\n  /* The one frame every text-like control sits in: input, textarea, password, search, number, the select and date\n   triggers. Its paint (fill, edge, hover, focus) is the field role of the look in force (looks.css); invalid,\n   read-only and disabled are drawn here so they read the same in every look. */\n  .frame {\n    --_field-height: var(--pui-control-height-md);\n    position: relative;\n    display: flex;\n    min-width: 0; /* a grid or flex parent may make it narrower than the input's natural width */\n    align-items: center;\n    gap: var(--pui-space-2);\n    min-height: var(--_field-height);\n    padding-inline: var(--pui-space-2-5);\n    border-radius: var(--pui-field-radius, var(--pui-radius-control));\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    cursor: text;\n    transition:\n      box-shadow var(--pui-motion-out) var(--pui-ease-default),\n      background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .frame:hover:not([data-disabled]) {\n    transition-duration: 0ms;\n  }\n  .frame[data-state=\"open\"] {\n    box-shadow: var(--m-field-edge-focus);\n  }\n  .frame:focus-within {\n    transition: none;\n  }\n  .frame[data-disabled] {\n    opacity: var(--pui-opacity-disabled);\n    cursor: not-allowed;\n  }\n  .frame[data-readonly] {\n    --m-field-bg: transparent;\n  }\n  .sm {\n    --_field-height: var(--pui-control-height-sm);\n    padding-inline: var(--pui-space-2);\n  }\n  .lg {\n    --_field-height: var(--pui-control-height-lg);\n    padding-inline: var(--pui-space-3);\n    font-size: var(--pui-text-base);\n  }\n  .multiline {\n    align-items: stretch;\n    padding-block: var(--pui-space-2);\n  }\n\n  /* The control itself: borderless, it takes the frame's look. Focus shows on the frame, so the control's own ring is off\n   (the frame's ring is the visible focus indicator, which is always present when the control has focus). */\n  .control {\n    flex: 1;\n    min-width: 0;\n    height: 100%;\n    min-height: calc(var(--_field-height) - 2px);\n    border: 0;\n    outline: none;\n    background: transparent;\n    color: inherit;\n    font: inherit;\n    letter-spacing: inherit;\n    padding: 0;\n  }\n  .control:focus-visible {\n    outline: none;\n  }\n  .control::placeholder {\n    color: var(--pui-ink-3);\n    opacity: 1;\n  }\n  .control:disabled {\n    cursor: not-allowed;\n  }\n  textarea.control {\n    min-height: 0;\n    resize: none;\n    line-height: 1.5;\n    field-sizing: content;\n  }\n  .adornment {\n    display: inline-flex;\n    align-items: center;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n  }\n  .adornment :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.frame {\n  composes: m-field nest-corner from global;\n}\n"},{"path":"beamline/components/_shared/field.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/field.tsx","content":"\"use client\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english } from \"./messages\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type Ref,\n  type TransitionEvent,\n} from \"react\";\nimport { AlertCircle, CircleCheck } from \"lucide-react\";\nimport { Label } from \"../label/label\";\nimport { Spinner } from \"../spinner/spinner\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./field.module.css\";\n\nexport type FieldSize = \"sm\" | \"md\" | \"lg\";\nexport type FieldClassNames = Partial<\n  Record<\"root\" | \"label\" | \"frame\" | \"leading\" | \"trailing\" | \"description\" | \"error\" | \"success\" | \"aside\", string>\n>;\n\nexport interface FieldOptions {\n  /** Visible label, also the accessible name. */\n  label?: ReactNode;\n  /** Keep the label for assistive tech only (dense rows, toolbars). */\n  hideLabel?: boolean;\n  /** One line of help under the control. */\n  description?: ReactNode;\n  /** The problem and its fix; replaces the description and marks the control invalid. */\n  error?: ReactNode;\n  /** The confirmation that the value works (\"@mira is available\"): replaces the description, with a check. */\n  success?: ReactNode;\n  required?: boolean;\n  size?: FieldSize;\n}\n\n/** Ids and aria wiring for one field: the control gets these spread onto it. */\nexport function useFieldIds(\n  id?: string,\n  { description, error, success }: Pick<FieldOptions, \"description\" | \"error\" | \"success\"> = {},\n) {\n  const auto = useId();\n  const controlId = id ?? `field-${auto}`;\n  // the line under the field is one of three, and the control points at whichever is there\n  const descriptionId = (success || description) && !error ? `${controlId}-description` : undefined;\n  const errorId = error ? `${controlId}-error` : undefined;\n  return {\n    controlId,\n    descriptionId,\n    errorId,\n    aria: {\n      id: controlId,\n      \"aria-describedby\": [descriptionId, errorId].filter(Boolean).join(\" \") || undefined,\n      \"aria-invalid\": error ? true : undefined,\n      \"aria-errormessage\": errorId,\n    },\n  };\n}\n\n/** Label above (with an optional aside at the end of its row: a counter, a link), control frame, then the message. */\nexport function Field({\n  label,\n  hideLabel,\n  description,\n  error,\n  success,\n  required,\n  controlId,\n  descriptionId,\n  errorId,\n  aside,\n  className,\n  classNames,\n  style,\n  slot = \"field\",\n  requiredOnControl,\n  children,\n}: FieldOptions & {\n  controlId: string;\n  descriptionId?: string;\n  errorId?: string;\n  /** At the end of the label's row: a character count, \"Forgot password?\", \"Optional\". */\n  aside?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  style?: CSSProperties;\n  slot?: string;\n  /** The control carries `required` itself, so the label does not repeat it in the control's name. */\n  requiredOnControl?: boolean;\n  children: ReactNode;\n}) {\n  const labelNode = label != null && (\n    <Label\n      htmlFor={controlId}\n      required={required}\n      requiredOnControl={requiredOnControl}\n      className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n    >\n      {label}\n    </Label>\n  );\n  return (\n    <div\n      data-slot={slot}\n      data-invalid={error ? \"\" : undefined}\n      style={style}\n      className={cn(styles.field, aside != null && \"reveal-host\", classNames?.root, className)}\n    >\n      {aside != null ? (\n        <div data-slot=\"field-label-row\" className={styles.labelRow}>\n          {labelNode}\n          <span data-slot=\"field-aside\" className={cn(styles.aside, classNames?.aside)}>\n            {aside}\n          </span>\n        </div>\n      ) : (\n        labelNode\n      )}\n      {children}\n      <FieldMessage\n        error={error}\n        success={success}\n        description={description}\n        errorId={errorId}\n        descriptionId={descriptionId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n\ntype Tone = \"error\" | \"success\" | \"description\";\ninterface Line {\n  key: string;\n  tone: Tone;\n  content: ReactNode;\n}\nconst ICON = { error: AlertCircle, success: CircleCheck } as const;\n\n/**\n * The line under a field: its problem when there is one, else its confirmation, else its help. Shared by every field\n * and choice group. A change of line keeps its path: the new line grows in under the control while the old one folds\n * away above it, so what sits below glides instead of jumping, and a quick reversal turns around from where it is.\n * The leaving line is a still copy, hidden from assistive tech and inert; ids and aria point only at the line that is\n * there. It is gone when its fold ends, and by the clock as well: a line that leaves in the frame it arrived has no\n * fold to run, so no end is reported.\n */\nexport function FieldMessage({\n  error,\n  success,\n  description,\n  errorId,\n  descriptionId,\n  slot = \"field\",\n  classNames,\n}: Pick<FieldOptions, \"error\" | \"description\" | \"success\"> & {\n  errorId?: string;\n  descriptionId?: string;\n  /** The slot names' prefix: \"checkbox\" names the lines checkbox-error, checkbox-description. */\n  slot?: string;\n  classNames?: Pick<FieldClassNames, \"error\" | \"description\" | \"success\">;\n}) {\n  const tone: Tone | null = error ? \"error\" : success ? \"success\" : description ? \"description\" : null;\n  const content = error || success || description;\n  // a new text is a new line (one problem replacing another also folds and grows); rich content changes in place\n  const text = typeof content === \"string\" || typeof content === \"number\" ? String(content) : \"\";\n  const current: Line | null = tone ? { key: `${tone}\\u0000${text}`, tone, content } : null;\n  const [last, setLast] = useState<Line | null>(current);\n  const [leaving, setLeaving] = useState<Line | null>(null);\n  // the line a field is drawn with is simply there; only a change of line moves\n  const [changed, setChanged] = useState(false);\n  if ((last?.key ?? null) !== (current?.key ?? null)) {\n    // the line that was there leaves (one at a time: a line already leaving is gone at once)\n    setLeaving(last && last.key !== current?.key ? last : null);\n    setLast(current);\n    setChanged(true);\n  }\n  const box = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    if (!leaving) return;\n    // the clock is the leaving line's own longest transition, read once as it starts to leave (the fold's end event\n    // usually comes first), so a field resolves no motion tokens of its own\n    const el = box.current?.querySelector<HTMLElement>(\"[data-leaving]\");\n    const longest = el\n      ? Math.max(\n          0,\n          ...getComputedStyle(el)\n            .transitionDuration.split(\",\")\n            .map((t) => parseFloat(t) * (t.trim().endsWith(\"ms\") ? 1 : 1000) || 0),\n        )\n      : 0;\n    const id = window.setTimeout(() => setLeaving((l) => (l === leaving ? null : l)), longest + 250);\n    return () => window.clearTimeout(id);\n  }, [leaving]);\n  if (!current && !leaving) return null;\n  const shown = leaving && leaving.key === current?.key ? null : leaving;\n  const ids = { error: errorId, success: descriptionId, description: descriptionId };\n  const line = (l: Line, gone: boolean) => {\n    const Icon = l.tone === \"description\" ? null : ICON[l.tone];\n    return (\n      <div\n        key={l.key}\n        data-slot=\"field-message-line\"\n        data-leaving={gone ? \"\" : undefined}\n        data-initial={changed ? undefined : \"\"}\n        aria-hidden={gone || undefined}\n        inert={gone || undefined}\n        className={styles.line}\n        onTransitionEnd={\n          gone\n            ? (e: TransitionEvent<HTMLDivElement>) => {\n                if (e.target === e.currentTarget && e.propertyName === \"grid-template-rows\") setLeaving(null);\n              }\n            : undefined\n        }\n      >\n        <div className={styles.clip}>\n          <p\n            id={gone ? undefined : ids[l.tone]}\n            data-slot={`${slot}-${l.tone}`}\n            className={cn(styles.message, styles[l.tone], classNames?.[l.tone])}\n          >\n            {Icon && <Icon aria-hidden className={styles.icon} />}\n            {l.content}\n          </p>\n        </div>\n      </div>\n    );\n  };\n  return (\n    <div ref={box} data-slot=\"field-message\" className={styles.messages}>\n      {shown && line(shown, true)}\n      {current && line(current, false)}\n    </div>\n  );\n}\n\n/** What the end of a text field says about its work: a spinner while a value is checked, a drawn check when it works. */\nexport type FieldStatus = \"busy\" | \"success\";\n\n/** The frame a text-like control sits in; clicking anywhere on it focuses the control. */\nexport function FieldFrame({\n  size = \"md\",\n  invalid,\n  disabled,\n  readOnly,\n  multiline,\n  status,\n  statusLabel: statusLabelProp,\n  leading,\n  trailing,\n  className,\n  classNames,\n  children,\n  onPointerDown,\n  ref,\n}: {\n  size?: FieldSize;\n  invalid?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  multiline?: boolean;\n  /** busy: a spinner at the end while the value is checked or saved; success: the ring closes into a check. */\n  status?: FieldStatus;\n  /** What the spinner says while busy (\"Checking\", \"Saving\"). */\n  statusLabel?: string;\n  leading?: ReactNode;\n  trailing?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  children: ReactNode;\n  onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void;\n  /** The frame itself, for a control that lays out by the frame's width (useWidthAs). */\n  ref?: Ref<HTMLDivElement>;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const statusLabel = statusLabelProp === undefined ? i18n(\"statusLabel\") : statusLabelProp;\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"field-control\"\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-readonly={readOnly ? \"\" : undefined}\n      data-status={status}\n      className={cn(styles.frame, styles[size], multiline && styles.multiline, classNames?.frame, className)}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        // a click on the frame's padding or an adornment that is not itself interactive focuses the control\n        const t = e.target as HTMLElement;\n        if (t === e.currentTarget || (t.closest(\"[data-adornment]\") && !t.closest(\"button, a, input, [role=button]\"))) {\n          e.preventDefault();\n          e.currentTarget.querySelector<HTMLElement>(\"input, textarea\")?.focus();\n        }\n      }}\n    >\n      {leading && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"leading\"\n          className={cn(styles.adornment, classNames?.leading)}\n        >\n          {leading}\n        </span>\n      )}\n      {children}\n      {status && (\n        // one ring for both: it spins while the value is checked and closes into the check when it works\n        <span data-slot=\"field-status\" data-adornment=\"status\" className={styles.status}>\n          <Spinner size=\"sm\" state={status === \"busy\" ? \"loading\" : \"success\"} label={statusLabel} />\n        </span>\n      )}\n      {trailing && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"trailing\"\n          className={cn(styles.adornment, classNames?.trailing)}\n        >\n          {trailing}\n        </span>\n      )}\n    </div>\n  );\n}\n\n/**\n * A character count for the label's row (Field `aside`): quiet (\"58/140\") and shown while the field has attention,\n * then always shown and in words as the limit nears (\"12 left\", \"Limit reached\", \"3 over\"), so it never competes with\n * the label at rest. Assistive tech hears it only when it crosses into those words, not at every key.\n */\nexport function FieldCount({\n  length,\n  max,\n  near: nearAt,\n  slot = \"field-count\",\n  className,\n}: {\n  length: number;\n  max: number;\n  /** How many left turns it into words: by default 10 % of the limit, at least 5 (characters); 1 suits a few tags. */\n  near?: number;\n  slot?: string;\n  className?: string;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const left = max - length;\n  const near = left <= (nearAt ?? Math.max(5, Math.ceil(max * 0.1)));\n  const state = left < 0 ? \"over\" : left === 0 ? \"full\" : near ? \"near\" : \"room\";\n  const words =\n    state === \"over\"\n      ? i18n(\"fieldOver\", { count: -left })\n      : state === \"full\"\n        ? i18n(\"fieldFull\")\n        : i18n(\"fieldLeft\", { count: left });\n  return (\n    <span\n      data-slot={slot}\n      data-state={state}\n      data-shown={state === \"room\" ? undefined : \"\"}\n      className={cn(\"reveal\", styles.count, className)}\n    >\n      <span aria-hidden>{state === \"room\" ? `${i18n.number(length)}/${i18n.number(max)}` : words}</span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {state === \"room\" ? \"\" : words}\n      </span>\n    </span>\n  );\n}\n\n/** The class a native input or textarea needs inside a FieldFrame. */\nexport const fieldControlClass = styles.control;\n\n/** What a choice group (segmented control, radio cards) spreads onto its own root when it is a form field. */\nexport interface ChoiceFieldAria {\n  \"aria-labelledby\": string;\n  \"aria-describedby\"?: string;\n  \"aria-invalid\"?: true;\n}\n\n/**\n * Whether a choice control (segmented control, radio cards, chip group) is laid out as a form field, its label shown\n * above like every field's: when asked (`field`), when it has a description, error or required mark, and whenever its\n * label is not hidden. A view switch or a quick filter passes `hideLabel`: its label stays for assistive tech only.\n * (Trials t16 and t19: agents wrote <SegmentedControl label=\"Catering needed\" …> and the question never showed.)\n */\nexport function choiceIsField(\n  field: boolean | undefined,\n  label: ReactNode,\n  hideLabel: boolean | undefined,\n  marked: boolean,\n) {\n  return field ?? (marked || (label != null && label !== \"\" && !hideLabel));\n}\n\n/**\n * A choice group as a form field: the visible label above (with the required marker), the group, then its help or its\n * problem — the same frame and message every text field has. Groups that name themselves only for assistive tech\n * (a view switch in a toolbar) do not use it.\n */\nexport function ChoiceField({\n  slot,\n  label,\n  hideLabel,\n  required,\n  description,\n  error,\n  className,\n  style,\n  classNames,\n  requiredOnControl,\n  children,\n}: Omit<FieldOptions, \"size\" | \"success\"> & {\n  slot: string;\n  /** The group carries `aria-required` itself (a radiogroup), so the label does not repeat it in the group's name. */\n  requiredOnControl?: boolean;\n  className?: string;\n  style?: CSSProperties;\n  classNames?: Pick<FieldClassNames, \"label\" | \"description\" | \"error\">;\n  children: (aria: ChoiceFieldAria) => ReactNode;\n}) {\n  const auto = useId();\n  const labelId = `choice-${auto}-label`;\n  const messageId = error || description ? `choice-${auto}-message` : undefined;\n  return (\n    <div data-slot={slot} data-invalid={error ? \"\" : undefined} style={style} className={cn(styles.field, className)}>\n      <Label\n        id={labelId}\n        required={required}\n        requiredOnControl={requiredOnControl}\n        className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n      >\n        {label}\n      </Label>\n      {children({\n        \"aria-labelledby\": labelId,\n        ...(messageId ? { \"aria-describedby\": messageId } : {}),\n        ...(error ? { \"aria-invalid\": true as const } : {}),\n      })}\n      <FieldMessage\n        error={error}\n        description={description}\n        errorId={messageId}\n        descriptionId={messageId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n"},{"path":"beamline/components/_shared/messages.ts","type":"registry:file","target":"src/ui/beamline/components/_shared/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy for this helper only. */\nexport const english = {\n  results: {\n    one: \"{count} result\",\n    other: \"{count} results\",\n  },\n  noMatch: \"No match for {query}\",\n  fieldOver: \"{count} over\",\n  fieldFull: \"Limit reached\",\n  fieldLeft: \"{count} left\",\n  foldMore: \"{list} and {count} more\",\n  foldShow: \"Show {count} more {noun}: {list}\",\n  statusLabel: \"Checking\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/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/radio-group/radio-group.module.css","type":"registry:file","target":"src/ui/beamline/components/radio-group/radio-group.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Radio group: a column (or row) of options; the dot grows into the chosen ring. The message line's gap is the\n     group's own (--pui-field-gap), so a problem that arrives or leaves takes its gap with it. */\n  .root {\n    --pui-field-gap: var(--pui-space-2);\n    display: flex;\n    min-width: 0;\n    flex-direction: column;\n    gap: var(--pui-field-gap);\n  }\n  /* options keep their natural width in both arrangements, so a row measures the same as a column (orientation auto) */\n  .group {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    gap: var(--pui-space-2-5) var(--pui-space-5);\n  }\n  .group[data-orientation-mode=\"auto\"] {\n    align-items: flex-start;\n  }\n  .across {\n    flex-direction: row;\n    flex-wrap: wrap;\n    align-items: flex-start;\n  }\n  .item {\n    display: flex;\n    align-items: flex-start;\n    gap: var(--pui-space-2-5);\n    cursor: pointer;\n  }\n  .item[data-disabled] {\n    cursor: not-allowed;\n    opacity: var(--pui-opacity-disabled);\n  }\n  /* the toggle role paints it: a well with a 3:1 ring that fills when chosen */\n  .dot {\n    --m-color: var(--pui-control-on);\n    --m-toggle-off: var(--pui-field);\n    --m-toggle-off-hover: var(--pui-field);\n    --m-toggle-ring: var(--pui-edge-control);\n    --m-toggle-ring-hover: color-mix(in oklab, var(--pui-edge-control) 60%, var(--pui-ink));\n    flex-shrink: 0;\n    display: grid;\n    place-items: center;\n    width: 16px;\n    height: 16px;\n    margin-top: calc(var(--pui-space-0-5) / 2);\n    border-radius: 50%;\n    cursor: inherit;\n    transition: var(--m-transition);\n  }\n  /* the whole row is the hit area, so pointing anywhere on it lights the ring; a press is acknowledged at once */\n  .item:hover .dot:not(:disabled) {\n    --m-toggle-ring: var(--m-toggle-ring-hover);\n    transition-duration: 0ms;\n  }\n  .item:active .dot:not(:disabled) {\n    scale: var(--pui-press-scale);\n  }\n  .indicator {\n    width: 6px;\n    height: 6px;\n    border-radius: 50%;\n    background: var(--pui-control-glyph);\n    animation: grow var(--pui-duration-fast) var(--pui-ease-enter);\n  }\n  /* the chosen fill as one bead on top of the rings: it leaves the old choice and flows into the new one as matter\n     (radio-group.tsx), carrying its centre dot, so the per-option fill and dot stand aside while it is there */\n  .bead {\n    /* painted exactly as a chosen option is, by the toggle role of the look in force */\n    --m-color: var(--pui-control-on);\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 1;\n    border-radius: 50%;\n    pointer-events: none;\n  }\n  .bead::after {\n    content: \"\";\n    position: absolute;\n    inset: 0;\n    margin: auto;\n    width: 6px;\n    height: 6px;\n    border-radius: 50%;\n    background: var(--pui-control-glyph);\n  }\n  [data-bead] .indicator {\n    visibility: hidden;\n  }\n  [data-bead] .dot[data-state=\"checked\"] {\n    --pui-m-c: var(--m-toggle-off);\n    background: var(--m-inset-shade), var(--pui-m-c);\n    box-shadow:\n      inset 0 0 0 1px var(--m-toggle-ring, transparent),\n      var(--m-inset-edge);\n  }\n  @keyframes grow {\n    from {\n      transform: scale(0.2);\n    }\n    to {\n      transform: scale(1);\n    }\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.dot {\n  composes: m-toggle from global;\n}\n.bead {\n  composes: m-toggle from global;\n}\n"},{"path":"beamline/components/radio-group/radio-group.tsx","type":"registry:file","target":"src/ui/beamline/components/radio-group/radio-group.tsx","content":"\"use client\";\n\nimport { useDeprecatedProp } from \"../../hooks/use-deprecated-prop\";\n\nimport { useId, useMemo, useState, type CSSProperties, type Ref, type ReactNode } from \"react\";\nimport * as RadixRadioGroup from \"@radix-ui/react-radio-group\";\nimport { useScopeDirection } from \"../../hooks/use-scope-direction\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useFit } from \"../../hooks/use-fit\";\nimport { useFlip } from \"../../hooks/use-flip\";\nimport { useIndicator } from \"../../hooks/use-indicator\";\nimport { useContour } from \"../../hooks/use-contour\";\nimport { cn } from \"../../lib/utils\";\nimport { FieldMessage } from \"../_shared/field\";\nimport { Label } from \"../label/label\";\nimport styles from \"./radio-group.module.css\";\n\nexport interface RadioOption {\n  /** Value represented by this item. Required. */\n  value: string;\n  /** Text displayed for this item. Required. */\n  label: ReactNode;\n  /** Supporting content describing this item. Omitted by default. */\n  description?: ReactNode;\n  /** Whether this option is unavailable for selection. Omitted by default. */\n  disabled?: boolean;\n}\n\nexport type RadioGroupClassNames = Partial<\n  Record<\n    | \"root\"\n    | \"label\"\n    | \"group\"\n    | \"option\"\n    | \"control\"\n    | \"indicator\"\n    | \"text\"\n    | \"optionLabel\"\n    | \"description\"\n    | \"groupDescription\"\n    | \"error\",\n    string\n  >\n>;\n\nexport interface RadioGroupProps {\n  /** The question the options answer; the group's accessible name. */\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  /** One line of help under the options (like every field's description). */\n  description?: ReactNode;\n  /** The problem and its fix (a required choice not made); replaces the description and marks the group invalid. */\n  error?: ReactNode;\n  /** Available choices with their labels and values. Required. */\n  options: RadioOption[];\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  /** vertical (default) or horizontal; auto: a row while every option fits the group's room on one line, a column\n   *  when it does not (the options slide between the two). */\n  orientation?: \"vertical\" | \"horizontal\" | \"auto\";\n  /** Field name used in native form submission. Defaults to no form field name. */\n  name?: string;\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  /** 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  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n  /** Reading direction; inherits the surrounding direction when omitted. Defaults to the surrounding reading direction. */\n  dir?: \"ltr\" | \"rtl\";\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: RadioGroupClassNames;\n}\n\n/** One choice from a short, visible set. Arrow keys move and select; Tab leaves the group. */\nexport function RadioGroup({\n  label,\n  hideLabel: hideLabelProp,\n  showLabel,\n  description,\n  error,\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  orientation = \"vertical\",\n  name,\n  disabled,\n  required,\n  className,\n  classNames,\n  style,\n  ref,\n  dir,\n}: RadioGroupProps) {\n  useDeprecatedProp(\"RadioGroup\", \"hideLabel\", \"showLabel={false}\", hideLabelProp !== undefined);\n  const hideLabel = showLabel === undefined ? hideLabelProp : !showLabel;\n  // one bead of colour travels from the old choice to the new one as matter (ui/lib/contour.ts). It follows the chosen\n  // option's row and sits where that row's dot is: a row's own place is never thrown off by the row sliding (useFlip\n  // moves rows with a transform, which makes a moving row its dot's offset parent), and the dot is found inside it\n  const [dot, setDot] = useState<{ x: number; y: number; w: number; h: number } | null>(null);\n  const [control, row] = useIndicator<HTMLDivElement>(\n    \"[data-slot=radio-option]:has([data-slot=radio-control][data-state=checked])\",\n    undefined,\n    (item) => {\n      const d = item.querySelector<HTMLElement>(\"[data-slot=radio-control]\")?.getBoundingClientRect();\n      const r = item.getBoundingClientRect();\n      if (!d) return;\n      const next = { x: d.left - r.left, y: d.top - r.top, w: d.width, h: d.height };\n      setDot((p) => (p && p.x === next.x && p.y === next.y && p.w === next.w && p.h === next.h ? p : next));\n    },\n  );\n  const chosen = useMemo(\n    () => (row && dot ? { ...row, x: row.x + dot.x, y: row.y + dot.y, w: dot.w, h: dot.h } : null),\n    [row, dot],\n  );\n  const bead = useContour<HTMLSpanElement>(chosen);\n  const [groupRef, direction] = useScopeDirection(useComposedRef(control, ref), dir);\n  // auto: the options are measured once at their natural width and laid out as a row only while all of them fit on one\n  // line of the room; a change between row and column carries each option to its new place\n  const optionSet = options.map((o) => o.value).join(\"\\u0000\");\n  const fits = useFit(control, { items: options.length, lines: 1, enabled: orientation === \"auto\" }, optionSet);\n  const across = orientation === \"horizontal\" || (orientation === \"auto\" && fits >= options.length);\n  useFlip(control, { ghost: false, enter: false });\n  const auto = useId();\n  const labelId = `radio-${auto}-label`;\n  const messageId = error || description ? `radio-${auto}-message` : undefined;\n  return (\n    <div\n      data-slot=\"radio-group\"\n      data-invalid={error ? \"\" : undefined}\n      dir={dir}\n      style={style}\n      className={cn(styles.root, classNames?.root, className)}\n    >\n      {/* the same label (and required marker) every field has; the group itself says \"required\" */}\n      <Label\n        id={labelId}\n        data-slot=\"radio-group-label\"\n        required={required}\n        requiredOnControl\n        className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n      >\n        {label}\n      </Label>\n      <RadixRadioGroup.Root\n        ref={groupRef}\n        dir={direction}\n        data-slot=\"radio-group-control\"\n        aria-labelledby={labelId}\n        aria-describedby={messageId}\n        aria-invalid={error ? true : undefined}\n        value={value}\n        defaultValue={defaultValue}\n        onValueChange={onValueChange}\n        orientation={across ? \"horizontal\" : \"vertical\"}\n        data-orientation-mode={orientation}\n        name={name}\n        disabled={disabled}\n        required={required}\n        data-bead={chosen ? \"\" : undefined}\n        className={cn(styles.group, across && styles.across, classNames?.group)}\n      >\n        {chosen && <span ref={bead} aria-hidden data-slot=\"radio-bead\" data-state=\"checked\" className={styles.bead} />}\n        {options.map((o) => {\n          const id = `radio-${auto}-${o.value}`;\n          return (\n            <label\n              key={o.value}\n              htmlFor={id}\n              data-fit-item\n              data-flip={o.value}\n              data-slot=\"radio-option\"\n              data-disabled={o.disabled || disabled ? \"\" : undefined}\n              className={cn(styles.item, classNames?.option)}\n            >\n              <RadixRadioGroup.Item\n                id={id}\n                value={o.value}\n                disabled={o.disabled}\n                data-slot=\"radio-control\"\n                className={cn(styles.dot, classNames?.control)}\n              >\n                <RadixRadioGroup.Indicator\n                  data-slot=\"radio-indicator\"\n                  className={cn(styles.indicator, classNames?.indicator)}\n                />\n              </RadixRadioGroup.Item>\n              <span data-slot=\"radio-text\" className={cn(\"grid gap-0.5\", classNames?.text)}>\n                <span\n                  data-slot=\"radio-label\"\n                  className={cn(\n                    \"text-sm leading-[var(--pui-leading-body)] font-medium text-ink\",\n                    classNames?.optionLabel,\n                  )}\n                >\n                  {o.label}\n                </span>\n                {o.description && (\n                  <span\n                    data-slot=\"radio-description\"\n                    className={cn(\"text-xs leading-snug text-ink-3\", classNames?.description)}\n                  >\n                    {o.description}\n                  </span>\n                )}\n              </span>\n            </label>\n          );\n        })}\n      </RadixRadioGroup.Root>\n      <FieldMessage\n        error={error}\n        description={description}\n        errorId={messageId}\n        descriptionId={messageId}\n        classNames={{ error: classNames?.error, description: classNames?.groupDescription }}\n      />\n    </div>\n  );\n}\n\nexport default RadioGroup;\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-contour.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-contour.ts","content":"\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef } from \"react\";\nimport { createContour, type Contour } from \"../lib/contour\";\nimport type { IndicatorBox } from \"./use-indicator\";\n\n/**\n * Puts a decorative surface (a segmented thumb, a tab's pill or line) on the contour engine and moves it to `box`\n * whenever the box changes: the first placement is instant, later ones travel as matter (ui/lib/contour.ts). Returns a\n * ref for the surface. The surface stays aria-hidden; the control's own state has already changed by the time it moves.\n */\nexport function useContour<T extends HTMLElement>(box: IndicatorBox | null) {\n  const contour = useRef<Contour | null>(null);\n  const ref = useCallback((el: T | null) => {\n    contour.current?.destroy();\n    contour.current = el ? createContour(el) : null;\n  }, []);\n  useLayoutEffect(() => {\n    // a choice travels; the first placement and a reflow of the same choice are simply there\n    if (box) contour.current?.to(box, { instant: !box.ready || box.cause === \"layout\" });\n  }, [box]);\n  return ref;\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-fit.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-fit.ts","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState, type RefObject } from \"react\";\n\n/**\n * How many of a row's items fit its room: the one fold every element that lists things in a line uses (chips,\n * avatars, breadcrumbs, filters, tags, actions), so a narrow panel shows fewer and a \"+N\" for the rest, and the rest\n * come back as the room grows. Content-sized items keep their natural width; the row decides how many. Items never\n * change order (a fold that reshuffles breaks the reader's memory of where things are): the last ones fold first.\n *\n * Mark each item `data-fit-item` (direct children of the row) and the overflow control (the \"+N\") `data-fit-more`.\n * Every item is measured once at its natural width — all of them render on the first pass and again whenever `items`\n * or `key` changes, read in a layout effect, so nothing is painted half-measured — and then each change of the row's\n * width lays the widths out with the row's own column gap into `lines` lines, keeping room on the last line for the\n * overflow control. Nothing runs at rest: one ResizeObserver on the row.\n *\n * `key` names the item set and must be a stable primitive (a string of the items' ids and labels), never an array or\n * object built during render: a new identity each render would measure again each render, for ever.\n * `keep[i]` marks items that always show (a chosen chip, the current crumb); room for them is taken first.\n * Returns how many of the other items show, in order (all of them while measuring, when off, and when they fit).\n */\nexport function useFit(\n  row: RefObject<HTMLElement | null>,\n  {\n    items,\n    lines = 1,\n    enabled = true,\n    min = 1,\n    keep,\n    from = \"start\",\n  }: {\n    /** How many items there are; a change (or a different `key`) measures them again. */\n    items: number;\n    /** Lines the row may take before it folds. */\n    lines?: number;\n    enabled?: boolean;\n    /** Never show fewer than this many of the other items. */\n    min?: number;\n    keep?: readonly boolean[];\n    /** Which of the other items the room shows: the first that fit (start, the default: the last fold first), or the\n     *  last that fit (end: a breadcrumb folds the levels nearest the top and keeps the parent). The count returned is\n     *  how many show either way; the caller shows them from its end. */\n    from?: \"start\" | \"end\";\n  },\n  key?: string | number,\n): number {\n  const free = keep ? keep.filter((k) => !k).length : items;\n  const [count, setCount] = useState(free);\n  const widths = useRef<number[] | null>(null);\n  const moreWidth = useRef(0);\n  const measuring = useRef(true);\n  const kept = useRef(keep);\n  kept.current = keep;\n  const end = useRef(from === \"end\");\n  end.current = from === \"end\";\n  const recent = useRef<number[]>([]);\n  const last = useRef(free);\n\n  // a new set of items: show them all for one (unpainted) pass so their natural widths can be read\n  useLayoutEffect(() => {\n    widths.current = null;\n    measuring.current = true;\n    setCount(free);\n  }, [items, key, enabled, free]);\n\n  useLayoutEffect(() => {\n    const el = row.current;\n    if (!el || !enabled || typeof ResizeObserver === \"undefined\") return;\n    const layout = () => {\n      if (!widths.current) return;\n      const cs = getComputedStyle(el);\n      const gap = parseFloat(cs.columnGap) || 0;\n      const room = roomOf(el, cs) - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);\n      // counted from the end, the same placement is read backwards: the last items are the ones that stay\n      const w = end.current ? [...widths.current].reverse() : widths.current;\n      const k = end.current && kept.current ? [...kept.current].reverse() : kept.current;\n      let next = fit(w, room, gap, moreWidth.current, lines, min, k);\n      // a backstop: a row whose room still moves with its own fold (a layout the room reading can't see) settles on\n      // the folded side instead of flipping every frame\n      const now = performance.now();\n      const flips = recent.current.filter((t) => now - t < 500);\n      if (flips.length >= 6) next = Math.min(next, last.current);\n      if (next !== last.current) recent.current = [...flips, now];\n      last.current = next;\n      setCount((c) => (c === next ? c : next));\n    };\n    if (measuring.current) {\n      const found = [...el.querySelectorAll<HTMLElement>(\":scope > [data-fit-item]\")];\n      if (found.length >= items) {\n        widths.current = found.slice(0, items).map((n) => n.getBoundingClientRect().width);\n        const more = el.querySelector<HTMLElement>(\":scope > [data-fit-more]\");\n        // the \"+N\" is not on screen while everything fits: assume a short chip until it has been seen\n        moreWidth.current = more ? more.getBoundingClientRect().width : Math.max(moreWidth.current, 56);\n        measuring.current = false;\n      }\n    }\n    layout();\n    const observer = new ResizeObserver(() => {\n      const more = el.querySelector<HTMLElement>(\":scope > [data-fit-more]\");\n      if (more) moreWidth.current = more.getBoundingClientRect().width;\n      layout();\n    });\n    observer.observe(el);\n    // the room is the parent's line as much as the row's own box: a content-sized row only learns it can grow there\n    if (el.parentElement) observer.observe(el.parentElement);\n    return () => observer.disconnect();\n  });\n\n  return enabled ? count : free;\n}\n\n/**\n * The width a row may take: its own box, or, when it sits content-sized in a flex row (a tab row beside a title in a\n * space-between bar), the line's width less what shares that line with it. Read this way the room never depends on\n * how many items the row shows, so folding can't change the room it was decided by (and flip back).\n */\nfunction roomOf(el: HTMLElement, cs: CSSStyleDeclaration) {\n  const own = el.clientWidth;\n  const parent = el.parentElement;\n  if (!parent) return own;\n  const ps = getComputedStyle(parent);\n  if (!ps.display.includes(\"flex\") || !ps.flexDirection.startsWith(\"row\")) return own;\n  const box = el.getBoundingClientRect();\n  let others = 0;\n  let count = 0;\n  for (const sib of parent.children) {\n    if (sib === el || !(sib instanceof HTMLElement)) continue;\n    const ss = getComputedStyle(sib);\n    if (ss.position === \"absolute\" || ss.position === \"fixed\" || ss.display === \"none\") continue;\n    const r = sib.getBoundingClientRect();\n    // on the row's own line (a wrapped bar puts the others above or below)\n    if (r.bottom <= box.top + 0.5 || r.top >= box.bottom - 0.5) continue;\n    others += r.width + (parseFloat(ss.marginLeft) || 0) + (parseFloat(ss.marginRight) || 0);\n    count++;\n  }\n  const line =\n    parent.clientWidth -\n    (parseFloat(ps.paddingLeft) || 0) -\n    (parseFloat(ps.paddingRight) || 0) -\n    others -\n    count * (parseFloat(ps.columnGap) || 0) -\n    (parseFloat(cs.marginLeft) || 0) -\n    (parseFloat(cs.marginRight) || 0) -\n    // the line is measured outside the row's border; clientWidth is inside it\n    (parseFloat(cs.borderLeftWidth) || 0) -\n    (parseFloat(cs.borderRightWidth) || 0);\n  const cap = cs.maxWidth.endsWith(\"px\") ? parseFloat(cs.maxWidth) : Infinity;\n  return Math.max(own, Math.min(line, cap));\n}\n\n/**\n * How many of the items not kept fit, in order, when the shown ones are laid into `lines` lines of `room` with `gap`\n * between them and `more` kept free on the last line whenever something folds.\n */\nexport function fit(\n  widths: readonly number[],\n  room: number,\n  gap: number,\n  more: number,\n  lines: number,\n  min: number,\n  keep?: readonly boolean[],\n) {\n  const fits = (show: (i: number) => boolean, reserve: number) => {\n    let line = 1;\n    let x = 0;\n    for (let i = 0; i < widths.length; i++) {\n      if (!show(i)) continue;\n      const w = widths[i];\n      const cap = line === lines ? room - reserve : room;\n      const need = x === 0 ? w : x + gap + w;\n      if (need <= cap + 0.5) {\n        x = need;\n        continue;\n      }\n      if (x === 0 || line === lines) return false;\n      line++;\n      if (w > (line === lines ? room - reserve : room) + 0.5) return false;\n      x = w;\n    }\n    return true;\n  };\n  const rank: number[] = [];\n  let free = 0;\n  for (let i = 0; i < widths.length; i++) rank.push(keep?.[i] ? -1 : free++);\n  if (fits(() => true, 0)) return free;\n  // with items that always show, the floor is none of the others (they are what keeps the row from being empty)\n  const floor = keep?.some(Boolean) ? 0 : Math.min(min, free);\n  for (let n = free - 1; n > floor; n--) if (fits((i) => rank[i] < 0 || rank[i] < n, more + gap)) return n;\n  return floor;\n}\n"},{"path":"beamline/hooks/use-flip.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-flip.ts","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, type RefObject } from \"react\";\nimport { useMotionTokens } from \"./use-motion-tokens\";\nimport { cssEase } from \"../lib/motion-tokens\";\n\ntype Seen = { el: HTMLElement; x: number; y: number; w: number; h: number };\n\n/**\n * Items of a collection keep their path (the morph grammar's \"changes keep their size and their path\"): mark each item\n * with `data-flip=\"<stable key>\"` inside `container`. After a change, an item that is new grows in, items that moved\n * slide from where they were, and an item that left fades out as a ghost where it stood (a still copy, inert and\n * hidden from assistive tech, removed when it has faded). The data and focus change at once; only the picture\n * settles. Nothing runs at rest; reduced motion places at once. Positions are kept in the container's own content\n * (relative to it, plus its scroll) and read at rest, so neither a scroll of the page, nor a scroll of the container\n * itself, nor an item still settling reads as movement.\n *\n * `resize`: an item whose box changed size also scales from its old box (for marks without text, a bar or a segment, so a\n * new value glides on transforms instead of laying out every frame). `origin(key)`: an element a new item grows out of\n * (a ribbon's segment becoming a column, a shared chip dividing into pieces); with `originFirst` it applies on the\n * container's first render too, so a panel that opens unfolds its items from what was already on screen. `inner`: items\n * inside another item that lies outside the container (a merge's rows inside a card that moves itself) are this\n * container's own; nesting is judged inside the container only.\n */\nexport function useFlip(\n  container: RefObject<HTMLElement | null> | (() => HTMLElement | null),\n  {\n    ghost = true,\n    enter = true,\n    resize = false,\n    origin,\n    originFirst = false,\n    inner = false,\n  }: {\n    ghost?: boolean;\n    enter?: boolean;\n    resize?: boolean;\n    origin?: (key: string) => Element | null | undefined;\n    originFirst?: boolean;\n    inner?: boolean;\n  } = {},\n) {\n  const get = typeof container === \"function\" ? container : () => container.current;\n  // timing read at the container itself, found when the commit has attached it (a function container has no ref of its\n  // own; without a host the timing stayed unresolved where no UIRoot is around, then rendered again to say so)\n  const host =\n    typeof container === \"function\"\n      ? {\n          get current() {\n            return container();\n          },\n        }\n      : container;\n  const motion = useMotionTokens(host);\n  const last = useRef<Map<string, Seen> | null>(null);\n  const lastWidth = useRef(0);\n  const flying = useRef(new Set<Animation>());\n  // where every item stands, in the container's own content (relative to it, plus its scroll): a list scrolled since the\n  // last render has moved nothing (a row that slid back by the scroll would leave the finger that is tapping it). The\n  // items' own grow-ins and slides still in flight are lifted while they are read, so each box is where the layout puts\n  // it: a render mid-flight (a live price ticking while a new row grows in) is not a move.\n  const read = (root: HTMLElement) => {\n    const lifted = [...flying.current].map((a) => a.effect as KeyframeEffect).map((e) => [e, e.target] as const);\n    for (const [effect] of lifted) effect.target = null;\n    const box = root.getBoundingClientRect();\n    const now = new Map<string, Seen>();\n    for (const el of root.querySelectorAll<HTMLElement>(\"[data-flip]\")) {\n      // only the outermost items move (with `inner`, outermost inside this container)\n      const outer = el.parentElement?.closest(\"[data-flip]\");\n      if (outer && (!inner || (outer !== root && root.contains(outer)))) continue;\n      const r = el.getBoundingClientRect();\n      now.set(el.dataset.flip!, {\n        el,\n        x: r.left - box.left + root.scrollLeft,\n        y: r.top - box.top + root.scrollTop,\n        w: r.width,\n        h: r.height,\n      });\n    }\n    for (const [effect, target] of lifted) effect.target = target;\n    return { box, now };\n  };\n  // a container that changes size at rest, with no render (fonts arriving, a scrollbar appearing, a window resized), has\n  // its places read again then: else the first change after it would take the new width for a reflow and move nothing\n  const watched = useRef<{ el: HTMLElement; observer: ResizeObserver } | null>(null);\n  useLayoutEffect(() => () => watched.current?.observer.disconnect(), []);\n  // every render on purpose: a collection changes through its parent's state, not through anything we can depend on\n  useLayoutEffect(() => {\n    const root = get();\n    // a container that is gone (a panel folded) starts over when it comes back: its items are new, not moved\n    if (!root) {\n      last.current = null;\n      return;\n    }\n    if (watched.current?.el !== root && typeof ResizeObserver !== \"undefined\") {\n      watched.current?.observer.disconnect();\n      const observer = new ResizeObserver(() => {\n        if (flying.current.size || !last.current || !root.isConnected) return;\n        const seen = read(root);\n        last.current = seen.now;\n        lastWidth.current = seen.box.width;\n      });\n      observer.observe(root);\n      watched.current = { el: root, observer };\n    }\n    const { box, now } = read(root);\n    // a container that changed width since the last render reflowed its items by itself (a panel resized, a room\n    // step crossed): that is placement, not a change a person made, so this render records the new places and moves\n    // nothing (a reflow places, only a choice travels)\n    const reflowed = Math.abs(box.width - lastWidth.current) > 0.5;\n    lastWidth.current = box.width;\n    const before = reflowed ? null : last.current;\n    last.current = now;\n    const ms = motion.duration.standard * 1000;\n    // the first render moves nothing, except items that grow out of an origin already on screen\n    if ((!before && !(origin && originFirst)) || motion.reduced || ms < 2) return;\n    const easing = cssEase(motion.ease.standard, \"ease-out\");\n    // an item in flight says so (`data-flip-flight`: \"far\" when it crosses more than its own size, a slide between groups or\n    // a share growing out of another item; \"near\" for a neighbour's step), so a part can lift what travels over other\n    // text onto a surface of its own instead of drawing words over words\n    const fly = (el: HTMLElement, keyframes: Keyframe[], far = false) => {\n      const flight = el.animate(keyframes, { duration: ms, easing });\n      flying.current.add(flight);\n      if (far || el.dataset.flipFlight !== \"far\") el.dataset.flipFlight = far ? \"far\" : \"near\";\n      const land = () => {\n        flying.current.delete(flight);\n        if (![...flying.current].some((a) => (a.effect as KeyframeEffect | null)?.target === el))\n          delete el.dataset.flipFlight;\n      };\n      flight.finished.then(land, land);\n    };\n    const boxOf = (key: string) => {\n      const from = origin?.(key);\n      if (!from || !from.isConnected) return null;\n      const r = from.getBoundingClientRect();\n      return { x: r.left - box.left + root.scrollLeft, y: r.top - box.top + root.scrollTop, w: r.width, h: r.height };\n    };\n    // from a box to where the item stands: translate and scale about the top-left corner\n    const morph = (was: { x: number; y: number; w: number; h: number }, at: Seen): Keyframe[] => [\n      {\n        transformOrigin: \"0 0\",\n        transform: `translate(${was.x - at.x}px, ${was.y - at.y}px) scale(${was.w / Math.max(at.w, 0.5)}, ${was.h / Math.max(at.h, 0.5)})`,\n      },\n      { transformOrigin: \"0 0\", transform: \"none\" },\n    ];\n    for (const [key, at] of now) {\n      const was = before?.get(key);\n      if (!was) {\n        const from = boxOf(key);\n        if (from) fly(at.el, morph(from, at), true);\n        else if (enter && before)\n          fly(at.el, [\n            { opacity: 0, transform: \"scale(0.86)\" },\n            { opacity: 1, transform: \"none\" },\n          ]);\n        continue;\n      }\n      const dx = was.x - at.x,\n        dy = was.y - at.y;\n      const resized = resize && (Math.abs(was.w - at.w) > 0.5 || Math.abs(was.h - at.h) > 0.5);\n      const far = Math.abs(dx) > at.w * 0.5 || Math.abs(dy) > at.h * 1.5;\n      if (resized) fly(at.el, morph(was, at), far);\n      else if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)\n        fly(at.el, [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: \"none\" }], far);\n    }\n    if (!ghost || !before) return;\n    for (const [key, was] of before) {\n      if (now.has(key) || was.el.isConnected) continue;\n      const copy = was.el.cloneNode(true) as HTMLElement;\n      copy.removeAttribute(\"data-flip\");\n      // the ghost is a picture of the item, not the item: nothing that finds parts by slot or id may find it\n      for (const n of [copy, ...copy.querySelectorAll(\"[id], [data-slot]\")]) {\n        n.removeAttribute(\"id\");\n        n.removeAttribute(\"data-slot\");\n      }\n      copy.setAttribute(\"data-ghost\", \"\");\n      copy.setAttribute(\"aria-hidden\", \"true\");\n      copy.inert = true;\n      if (getComputedStyle(root).position === \"static\") root.style.position = \"relative\";\n      Object.assign(copy.style, {\n        position: \"absolute\",\n        left: `${was.x}px`,\n        top: `${was.y}px`,\n        width: `${was.w}px`,\n        height: `${was.h}px`,\n        margin: \"0\",\n        pointerEvents: \"none\",\n      });\n      root.appendChild(copy);\n      // the ghost is gone by the time a neighbour slides into its place, so their texts never mix. The clock runs\n      // straight and an even curve shapes only the fade: the front-loaded standard curve on the whole timeline passed\n      // 0.45 inside the first frame, so what left read as a pop instead of a fade\n      const fade = copy.animate(\n        [\n          { opacity: 1, transform: \"none\", easing: cssEase(motion.ease.default, \"ease-in-out\") },\n          { opacity: 0, transform: \"scale(0.9)\", offset: 0.6 },\n          { opacity: 0, transform: \"scale(0.86)\" },\n        ],\n        {\n          duration: motion.duration.standard * 1000,\n          easing: \"linear\",\n        },\n      );\n      const drop = () => copy.remove();\n      fade.finished.then(drop, drop);\n      // and by the clock as well: a fade that never reports its end (the page hidden, an animation cancelled from\n      // outside) must not leave a copy behind for a person or a test to find\n      setTimeout(drop, motion.duration.standard * 1000 + 250);\n    }\n  });\n}\n"},{"path":"beamline/hooks/use-indicator.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-indicator.ts","content":"\"use client\";\n\nimport { APPEARANCE_EVENT } from \"../lib/motion-tokens\";\nimport { useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from \"react\";\n\nexport interface IndicatorBox {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n  /** False on the first placement, so the indicator appears in place instead of sliding in from the corner. */\n  ready: boolean;\n  /** Which way the last move went on each axis (-1 left or up, 1 right or down, 0 still), for the edge that leads. */\n  dx: -1 | 0 | 1;\n  dy: -1 | 0 | 1;\n  /** choice: a selection changed; layout: the same choice moved because the page reflowed (a resize, a font, a\n   *  type scale). A surface may travel for a choice and should simply be there after a reflow. */\n  cause: \"choice\" | \"layout\";\n}\n\ntype Box = Pick<IndicatorBox, \"x\" | \"y\" | \"w\" | \"h\">;\nconst same = (a: IndicatorBox | null, b: Box) => a !== null && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;\n\n/**\n * Follows the chosen item inside a container: the thumb of a segmented control, the pill or line of tabs, the ring of\n * radio cards. Returns a ref for the container and the box of the first element matching `selector` in it, relative\n * to the container (null when nothing matches). It measures before paint whenever `key` changes, and again when the\n * container resizes or a selection attribute changes inside it (for parts whose choice lives in a library, like tabs).\n */\nexport function useIndicator<T extends HTMLElement>(\n  selector: string,\n  key?: unknown,\n  onPlace?: (item: HTMLElement, container: T) => void,\n  /** offset (default): the item's offset box, for items whose offset parent is the container. rect: measured on screen\n   *  relative to the container, for items nested in positioned cells (a calendar day in its table cell). */\n  measure: \"offset\" | \"rect\" = \"offset\",\n): [RefObject<T | null>, IndicatorBox | null] {\n  const ref = useRef<T>(null);\n  const [box, setBox] = useState<IndicatorBox | null>(null);\n  const placed = useRef(onPlace);\n  useLayoutEffect(() => {\n    placed.current = onPlace;\n  });\n  useLayoutEffect(() => {\n    const container = ref.current;\n    if (!container) return;\n    const place = (cause: IndicatorBox[\"cause\"] = \"choice\") => {\n      const item = container.querySelector<HTMLElement>(selector);\n      if (!item) {\n        setBox(null);\n        return;\n      }\n      let next = { x: item.offsetLeft, y: item.offsetTop, w: item.offsetWidth, h: item.offsetHeight };\n      if (measure === \"rect\") {\n        const a = item.getBoundingClientRect();\n        const c = container.getBoundingClientRect();\n        next = {\n          x: Math.round(a.left - c.left - container.clientLeft + container.scrollLeft),\n          y: Math.round(a.top - c.top - container.clientTop + container.scrollTop),\n          w: item.offsetWidth,\n          h: item.offsetHeight,\n        };\n      }\n      setBox((b) =>\n        same(b, next)\n          ? b\n          : {\n              ...next,\n              cause,\n              ready: b !== null,\n              dx: b ? (Math.sign(next.x - b.x) as IndicatorBox[\"dx\"]) : 0,\n              dy: b ? (Math.sign(next.y - b.y) as IndicatorBox[\"dy\"]) : 0,\n            },\n      );\n      placed.current?.(item, container);\n    };\n    const resizes = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(() => place(\"layout\"));\n    resizes?.observe(container);\n    const observed = new Set<Element>();\n    const observeItems = () => {\n      // A full-width fixed-height list can stay the same size while its tabs/radio choices grow with type or labels.\n      // Ignore the decorative indicator itself, whose own dimensions are the output of this measurement.\n      const items = new Set([...container.children].filter((child) => child.getAttribute(\"aria-hidden\") !== \"true\"));\n      for (const old of observed)\n        if (!items.has(old)) {\n          resizes?.unobserve(old);\n          observed.delete(old);\n        }\n      for (const item of items)\n        if (!observed.has(item)) {\n          resizes?.observe(item);\n          observed.add(item);\n        }\n    };\n    observeItems();\n    place();\n    const mutations = new MutationObserver(() => {\n      observeItems();\n      place(\"choice\");\n    });\n    mutations.observe(container, {\n      subtree: true,\n      childList: true,\n      attributes: true,\n      attributeFilter: [\"data-state\", \"aria-checked\", \"aria-selected\", \"aria-current\", \"data-active\", \"data-open\"],\n    });\n    const reflow = () => place(\"layout\");\n    container.ownerDocument.addEventListener(APPEARANCE_EVENT, reflow);\n    container.ownerDocument.fonts?.addEventListener(\"loadingdone\", reflow);\n    return () => {\n      mutations.disconnect();\n      resizes?.disconnect();\n      container.ownerDocument.removeEventListener(APPEARANCE_EVENT, reflow);\n      container.ownerDocument.fonts?.removeEventListener(\"loadingdone\", reflow);\n    };\n  }, [selector, key, measure]);\n  return [ref, box];\n}\n\n/** The box as custom properties, for a stylesheet to place the indicator with: --pui-indicator-x/-y/-w/-h, and its\n *  four edges --pui-glide-l/-r/-t/-b, which the `glide` class moves one at a time. */\nexport function indicatorStyle(box: IndicatorBox): CSSProperties {\n  return {\n    \"--pui-indicator-x\": `${box.x}px`,\n    \"--pui-indicator-y\": `${box.y}px`,\n    \"--pui-indicator-w\": `${box.w}px`,\n    \"--pui-indicator-h\": `${box.h}px`,\n    \"--pui-glide-l\": `${box.x}px`,\n    \"--pui-glide-r\": `${box.x + box.w}px`,\n    \"--pui-glide-t\": `${box.y}px`,\n    \"--pui-glide-b\": `${box.y + box.h}px`,\n  } as CSSProperties;\n}\n\nconst AXIS = { x: [\"left\", undefined, \"right\"], y: [\"up\", undefined, \"down\"] } as const;\n\n/** Everything an indicator wearing the `glide` class needs (utilities.css): its place, whether it may move yet, and\n *  which way it is going, so the edge on that side leads and the other follows: it stretches toward the new choice and\n *  gathers there. */\nexport function glideProps(box: IndicatorBox) {\n  return {\n    style: indicatorStyle(box),\n    \"data-ready\": box.ready ? \"\" : undefined,\n    \"data-glide-x\": AXIS.x[box.dx + 1],\n    \"data-glide-y\": AXIS.y[box.dy + 1],\n  };\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/hooks/use-scope-direction.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-scope-direction.ts","content":"\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState, type Ref } from \"react\";\nimport { useDirection } from \"@radix-ui/react-direction\";\nimport { useAppearance, useAppearanceRoot } from \"../runtime/appearance\";\nimport { APPEARANCE_EVENT } from \"../lib/motion-tokens\";\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n/** Preserve Radix direction in managed scopes and read the actual parent in static scopes before Radix stamps dir. */\nexport function useScopeDirection<T extends HTMLElement>(ref?: Ref<T>, explicit?: \"ltr\" | \"rtl\") {\n  const root = useRef<T | null>(null);\n  const provided = useDirection(explicit);\n  const appearance = useAppearance();\n  const managedRoot = useAppearanceRoot();\n  const [inherited, setInherited] = useState<\"ltr\" | \"rtl\">();\n  const read = useCallback(() => {\n    const around = root.current?.parentElement;\n    if (around)\n      setInherited(around.ownerDocument.defaultView?.getComputedStyle(around).direction === \"rtl\" ? \"rtl\" : \"ltr\");\n  }, []);\n  useIsoLayoutEffect(read, [read, provided, appearance]);\n  useEffect(() => {\n    const doc = root.current?.ownerDocument;\n    doc?.addEventListener(APPEARANCE_EVENT, read);\n    return () => doc?.removeEventListener(APPEARANCE_EVENT, read);\n  }, [read]);\n  const attach = useCallback(\n    (node: T | null) => {\n      root.current = node;\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            root.current = null;\n            cleanup();\n          };\n      } else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n  return [\n    attach,\n    explicit ?? (managedRoot ? provided : provided === \"rtl\" ? provided : (inherited ?? provided)),\n  ] as const;\n}\n"},{"path":"beamline/lib/contour.ts","type":"registry:file","target":"src/ui/beamline/lib/contour.ts","content":"import { readMotionFactor } from \"./motion-tokens\";\n\n/**\n * Contour: a surface whose outline moves like matter instead of a box that resizes. Beamline's own model (research/\n * morphing-interfaces-2026-10-04): a rounded quadrilateral whose four corners each follow their target on a critically\n * damped spring. The corner facing the way it travels answers fastest and the far one slowest, so it leads and gathers;\n * each edge's midpoint heads for where its edge will be a little ahead of its corners and may only bulge outward (the\n * leading edge rounds out like the front of a drop, the tail stays smooth), and while the shape is longer than its target the long edges\n * pinch toward the centre, so a fast move draws a neck; while it is stretched its corners round toward a capsule, as a moving drop does. No spring overshoots: the elasticity is the parts arriving at\n * different moments, never bounce. At rest the surface is its plain CSS box with no clip and no frames.\n *\n * The look decides the material through four inputs on the element (preset-source.css): --m-morph-spread (how unevenly\n * the corners answer; 0 is rigid travel), --m-morph-front (px the leading edge may round out), --m-morph-waist (how much\n * a stretch pinches) and --m-morph-weight (a multiplier on the response time).\n */\nexport interface ContourBox {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\nexport interface Contour {\n  /** Move the surface to `box` (in its offset parent's coordinates). `instant` places it with no motion. */\n  to(box: ContourBox, options?: { instant?: boolean }): void;\n  /** Stop and leave the surface at its target. */\n  destroy(): void;\n}\n\ntype Point = [number, number];\ntype Corner = \"tl\" | \"tr\" | \"br\" | \"bl\";\ntype Edge = \"t\" | \"r\" | \"b\" | \"l\";\nconst CORNERS: Corner[] = [\"tl\", \"tr\", \"br\", \"bl\"];\nconst EDGES: Edge[] = [\"t\", \"r\", \"b\", \"l\"];\n/** Outward direction of each corner, for deciding which one leads. */\nconst OUT: Record<Corner, Point> = { tl: [-1, -1], tr: [1, -1], br: [1, 1], bl: [-1, 1] };\n/** Outward direction of each edge. */\nconst NORMAL: Record<Edge, Point> = { t: [0, -1], r: [1, 0], b: [0, 1], l: [-1, 0] };\n/** Settles at the motion-token morph spring at the foundation default. */\nconst BASE_RESPONSE = 0.26;\n\ninterface Mechanics {\n  spread: number;\n  front: number;\n  waist: number;\n  weight: number;\n  /** The colour of the edge drawn along a non-rectangular outline (the look's hairline, which a clip would cut off). */\n  rim: string;\n  rimWidth: number;\n}\n\n/** One step of a critically damped spring with response time `t` (s), in closed form so a long frame cannot explode. */\nfunction step(x: number, v: number, target: number, t: number, dt: number): [number, number] {\n  const w = (2 * Math.PI) / (t * 1.2);\n  const d = x - target;\n  const e = Math.exp(-w * dt);\n  return [target + (d + (v + w * d) * dt) * e, (v - w * (v + w * d) * dt) * e];\n}\n\nconst cornersOf = (b: ContourBox): Record<Corner, Point> => ({\n  tl: [b.x, b.y],\n  tr: [b.x + b.w, b.y],\n  br: [b.x + b.w, b.y + b.h],\n  bl: [b.x, b.y + b.h],\n});\nconst midsOf = (c: Record<Corner, Point>): Record<Edge, Point> => ({\n  t: [(c.tl[0] + c.tr[0]) / 2, (c.tl[1] + c.tr[1]) / 2],\n  r: [(c.tr[0] + c.br[0]) / 2, (c.tr[1] + c.br[1]) / 2],\n  b: [(c.br[0] + c.bl[0]) / 2, (c.br[1] + c.bl[1]) / 2],\n  l: [(c.bl[0] + c.tl[0]) / 2, (c.bl[1] + c.tl[1]) / 2],\n});\nconst zero = <K extends string>(keys: K[]) => Object.fromEntries(keys.map((k) => [k, [0, 0]])) as Record<K, Point>;\nconst num = (style: CSSStyleDeclaration, name: string, fallback: number) => {\n  const value = Number.parseFloat(style.getPropertyValue(name));\n  return Number.isFinite(value) ? value : fallback;\n};\n\n/** All moving contours share one frame loop, which stops when the last one settles. */\nconst moving = new Set<(dt: number) => boolean>();\nlet frame = 0;\nlet last = 0;\nfunction tick(now: number) {\n  const dt = Math.min(0.032, Math.max(0.001, (now - last) / 1000));\n  last = now;\n  for (const advance of moving) if (!advance(dt)) moving.delete(advance);\n  frame = moving.size ? requestAnimationFrame(tick) : 0;\n}\nfunction run(advance: (dt: number) => boolean) {\n  moving.add(advance);\n  if (!frame) {\n    last = performance.now();\n    frame = requestAnimationFrame(tick);\n  }\n}\n\n/** One contour per element, so a remount (or React's development double mount) keeps its place and keeps moving. */\nconst contours = new WeakMap<HTMLElement, Contour>();\nexport function createContour(el: HTMLElement): Contour {\n  const existing = contours.get(el);\n  if (existing) return existing;\n  let target: ContourBox | null = null;\n  let pos: Record<Corner, Point> | null = null;\n  let vel = zero(CORNERS);\n  let mid: Record<Edge, Point> = { t: [0, 0], r: [0, 0], b: [0, 0], l: [0, 0] };\n  let midVel = zero(EDGES);\n  let mech: Mechanics = { spread: 0.35, front: 5, waist: 0.16, weight: 1, rim: \"none\", rimWidth: 1 };\n  let rimPath: SVGPathElement | null = null;\n  const rim = (d: string | null, w = 0, h = 0) => {\n    if (!d || mech.rim === \"none\") {\n      if (rimPath) (rimPath.ownerSVGElement as SVGSVGElement).style.display = \"none\";\n      return;\n    }\n    if (!rimPath) {\n      const ns = \"http://www.w3.org/2000/svg\";\n      const svg = el.ownerDocument.createElementNS(ns, \"svg\");\n      svg.setAttribute(\"aria-hidden\", \"true\");\n      svg.setAttribute(\"data-contour-rim\", \"\");\n      svg.style.cssText = \"position:absolute;left:0;top:0;z-index:2;overflow:visible;pointer-events:none\";\n      rimPath = el.ownerDocument.createElementNS(ns, \"path\");\n      rimPath.setAttribute(\"fill\", \"none\");\n      svg.appendChild(rimPath);\n      el.appendChild(svg);\n    }\n    const svg = rimPath.ownerSVGElement as SVGSVGElement;\n    svg.style.display = \"\";\n    svg.setAttribute(\"width\", String(w));\n    svg.setAttribute(\"height\", String(h));\n    rimPath.setAttribute(\"d\", d);\n    // the clip keeps the inner half of the stroke: an edge of rimWidth inside the outline\n    rimPath.setAttribute(\"stroke-width\", String(mech.rimWidth * 2));\n    rimPath.style.stroke = mech.rim;\n  };\n  let response = BASE_RESPONSE;\n  let radius = 0;\n  let running = false;\n\n  const rest = () => {\n    if (!target) return;\n    el.style.transform = `translate(${target.x}px, ${target.y}px)`;\n    el.style.width = `${target.w}px`;\n    el.style.height = `${target.h}px`;\n    el.style.clipPath = \"\";\n    rim(null);\n    delete el.dataset.morphing;\n  };\n\n  const draw = () => {\n    if (!pos || !target) return;\n    const c = pos;\n    const w = (c.tr[0] - c.tl[0] + c.br[0] - c.bl[0]) / 2;\n    const h = (c.bl[1] - c.tl[1] + c.br[1] - c.tr[1]) / 2;\n    // the long edges pinch while the shape is longer than its target (area kept, roughly)\n    const pinchY = Math.min(h * mech.waist * 0.5, 4, h * Math.max(0, w / target.w - 1) * mech.waist);\n    const pinchX = Math.min(w * mech.waist * 0.5, 4, w * Math.max(0, h / target.h - 1) * mech.waist);\n    const edges: Record<Edge, Point> = {\n      t: [mid.t[0], mid.t[1] + pinchY],\n      b: [mid.b[0], mid.b[1] - pinchY],\n      l: [mid.l[0] + pinchX, mid.l[1]],\n      r: [mid.r[0] - pinchX, mid.r[1]],\n    };\n    // still a rectangle (a rigid look, or a straight move): move the box itself and keep every bit of the look's paint\n    const flat = (a: number, b: number) => Math.abs(a - b) < 0.35;\n    const corners = midsOf(c);\n    if (\n      flat(c.tl[1], c.tr[1]) &&\n      flat(c.bl[1], c.br[1]) &&\n      flat(c.tl[0], c.bl[0]) &&\n      flat(c.tr[0], c.br[0]) &&\n      EDGES.every((k) => flat(edges[k][0], corners[k][0]) && flat(edges[k][1], corners[k][1]))\n    ) {\n      el.style.transform = `translate(${c.tl[0]}px, ${c.tl[1]}px)`;\n      el.style.width = `${c.tr[0] - c.tl[0]}px`;\n      el.style.height = `${c.bl[1] - c.tl[1]}px`;\n      el.style.clipPath = \"\";\n      rim(null);\n      return;\n    }\n    const all = [...CORNERS.map((k) => c[k]), ...EDGES.map((k) => edges[k])];\n    const minX = Math.floor(Math.min(...all.map((p) => p[0]))) - 1;\n    const minY = Math.floor(Math.min(...all.map((p) => p[1]))) - 1;\n    const maxX = Math.ceil(Math.max(...all.map((p) => p[0]))) + 1;\n    const maxY = Math.ceil(Math.max(...all.map((p) => p[1]))) + 1;\n    const local = (p: Point): Point => [p[0] - minX, p[1] - minY];\n    const C = CORNERS.map((k) => local(c[k]));\n    const E = ([\"t\", \"r\", \"b\", \"l\"] as Edge[]).map((k) => local(edges[k]));\n    const len = (a: Point, b: Point) => Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;\n    const toward = (a: Point, b: Point, d: number): Point => {\n      const l = len(a, b);\n      return [a[0] + ((b[0] - a[0]) / l) * d, a[1] + ((b[1] - a[1]) / l) * d];\n    };\n    // each corner backs off its radius along both of its real edges and turns through the corner itself, so a sheared\n    // shape never folds over; each edge bows through its midpoint\n    // a moving drop is round: corners grow toward a capsule while the shape is stretched or far from home\n    const away = Math.max(w / target.w - 1, h / target.h - 1, 0);\n    const round = mech.front > 0 ? Math.min(1, away * 2.5) : 0;\n    const r = radius + (Math.min(w, h) / 2 - radius) * Math.max(0, round);\n    const rr = C.map((p, i) => Math.min(r, len(p, C[(i + 1) % 4]) / 2.2, len(p, C[(i + 3) % 4]) / 2.2));\n    const into = C.map((p, i) => toward(p, C[(i + 3) % 4], rr[i]));\n    const out = C.map((p, i) => toward(p, C[(i + 1) % 4], rr[i]));\n    const through = (a: Point, m: Point, b: Point): Point => [\n      2 * m[0] - (a[0] + b[0]) / 2,\n      2 * m[1] - (a[1] + b[1]) / 2,\n    ];\n    const f = (p: Point) => `${p[0].toFixed(1)} ${p[1].toFixed(1)}`;\n    let d = `M${f(out[0])}`;\n    for (let i = 0; i < 4; i++) {\n      const j = (i + 1) % 4;\n      // an edge the rounded corners have nearly used up can only bow a little, or its middle would point\n      const a = out[i];\n      const b = into[j];\n      const chord: Point = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];\n      const bow: Point = [E[i][0] - chord[0], E[i][1] - chord[1]];\n      const most = len(a, b) * 0.18;\n      const bowLen = Math.hypot(bow[0], bow[1]);\n      const m: Point =\n        bowLen > most ? [chord[0] + (bow[0] / bowLen) * most, chord[1] + (bow[1] / bowLen) * most] : E[i];\n      d += ` Q${f(through(a, m, b))} ${f(b)} Q${f(C[j])} ${f(out[j])}`;\n    }\n    el.style.transform = `translate(${minX}px, ${minY}px)`;\n    el.style.width = `${maxX - minX}px`;\n    el.style.height = `${maxY - minY}px`;\n    el.style.clipPath = `path(\"${d}Z\")`;\n    rim(`${d}Z`, maxX - minX, maxY - minY);\n  };\n\n  const advance = (dt: number) => {\n    if (!pos || !target || !el.isConnected) return (running = false);\n    const goal = cornersOf(target);\n    let busy = false;\n    for (const k of CORNERS) {\n      const p = pos[k];\n      const v = vel[k];\n      const g = goal[k];\n      const dx = g[0] - p[0];\n      const dy = g[1] - p[1];\n      const dist = Math.hypot(dx, dy);\n      // -1 … 1: how squarely this corner faces the way it is going\n      const along = dist > 0.5 ? (OUT[k][0] * dx + OUT[k][1] * dy) / (dist * Math.SQRT2) : 0;\n      const t = response * (1 + mech.spread * 0.4 - mech.spread * along);\n      for (const a of [0, 1] as const) {\n        [p[a], v[a]] = step(p[a], v[a], g[a], t, dt);\n        if (Math.abs(p[a] - g[a]) > 0.2 || Math.abs(v[a]) > 4) busy = true;\n      }\n    }\n    const now = midsOf(pos);\n    const aim = midsOf(goal);\n    for (const k of EDGES) {\n      for (const a of [0, 1] as const)\n        [mid[k][a], midVel[k][a]] = step(mid[k][a], midVel[k][a], aim[k][a], response * 0.75, dt);\n      // the lead is a few pixels at most, and never more than a fraction of the edge or of the shape's depth\n      const across = k === \"t\" || k === \"b\" ? target.h : target.w;\n      const edge =\n        k === \"t\" ? [pos.tl, pos.tr] : k === \"b\" ? [pos.br, pos.bl] : k === \"l\" ? [pos.bl, pos.tl] : [pos.tr, pos.br];\n      const limit = Math.min(\n        mech.front,\n        across * 0.3,\n        Math.hypot(edge[1][0] - edge[0][0], edge[1][1] - edge[0][1]) * 0.15,\n      );\n      // edges only ever bulge outward: the front rounds out, the tail stays smooth instead of curling in\n      let ox = mid[k][0] - now[k][0];\n      let oy = mid[k][1] - now[k][1];\n      const [nx, ny] = NORMAL[k];\n      const inward = ox * nx + oy * ny;\n      if (inward < 0) {\n        ox -= inward * nx;\n        oy -= inward * ny;\n        mid[k][0] = now[k][0] + ox;\n        mid[k][1] = now[k][1] + oy;\n      }\n      const off = Math.hypot(ox, oy);\n      if (off > limit) {\n        mid[k][0] = now[k][0] + (ox / off) * limit;\n        mid[k][1] = now[k][1] + (oy / off) * limit;\n      }\n      if (Math.abs(mid[k][0] - aim[k][0]) > 0.2 || Math.abs(mid[k][1] - aim[k][1]) > 0.2) busy = true;\n    }\n    if (!busy) {\n      pos = goal;\n      vel = zero(CORNERS);\n      mid = midsOf(goal);\n      midVel = zero(EDGES);\n      rest();\n      return (running = false);\n    }\n    draw();\n    return true;\n  };\n\n  const contour: Contour = {\n    to(box, options) {\n      const same = target && target.x === box.x && target.y === box.y && target.w === box.w && target.h === box.h;\n      if (same && !options?.instant) return;\n      target = { ...box };\n      const style = getComputedStyle(el);\n      const r = style.borderTopLeftRadius;\n      radius = r.endsWith(\"%\") ? (Number.parseFloat(r) / 100) * Math.min(box.w, box.h) : Number.parseFloat(r) || 0;\n      mech = {\n        spread: num(style, \"--m-morph-spread\", 0.35),\n        front: num(style, \"--m-morph-front\", 6),\n        waist: num(style, \"--m-morph-waist\", 0.18),\n        weight: num(style, \"--m-morph-weight\", 1),\n        rim: style.getPropertyValue(\"--m-morph-rim\").trim() || \"none\",\n        rimWidth: num(style, \"--m-morph-rim-width\", 1),\n      };\n      const factor = readMotionFactor(el);\n      response = BASE_RESPONSE * mech.weight * factor;\n      // first placement, reduced motion, or a hidden surface: be there, with nothing to watch\n      if (!pos || options?.instant || factor < 0.05 || !el.getClientRects().length) {\n        pos = cornersOf(target);\n        vel = zero(CORNERS);\n        mid = midsOf(pos);\n        midVel = zero(EDGES);\n        moving.delete(advance);\n        running = false;\n        rest();\n        return;\n      }\n      el.dataset.morphing = \"\";\n      if (!running) {\n        running = true;\n        run(advance);\n      }\n    },\n    destroy() {\n      moving.delete(advance);\n      running = false;\n      if (target) rest();\n    },\n  };\n  contours.set(el, contour);\n  return contour;\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":"Radio group is in src/ui/beamline/components/radio-group/radio-group.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/radio-group"}}