{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-cropper","type":"registry:component","title":"Image cropper","description":"Frame a picture before it is saved: a fixed window (square, round for avatars, wide for covers, or free with handles) over the picture, which you drag, zoom with the slider, wheel or a pinch, and turn a quarter at a time; it always covers the window, thirds lines show while you move it, and the arrow keys, + and − and R do the same from the keyboard. cropImage turns the result into a file.","categories":["forms"],"dependencies":["@radix-ui/react-direction@1.1.4","@radix-ui/react-label@2.1.15","@radix-ui/react-slider@1.4.7","@radix-ui/react-slot@1.3.3","@radix-ui/react-tooltip@1.2.16","clsx@^2.1.1","lucide-react@^1.48.0","motion@^13.4.4","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/_shared/field.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/field.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A field: label (with an optional aside at the end of its row), the control, then one message line. The gap under\n   the control belongs to the message (--pui-field-gap), so a message that arrives or leaves takes its gap with it. */\n  /* a column, not a grid: a message (or a fold) that leaves pulls its gap away with a negative margin, which a flex\n     column honours and a grid track does not (it would stop at zero and stretch the leaving part back open) */\n  .field {\n    --pui-field-gap: var(--pui-space-1-5);\n    /* positioned: a hidden label or a count's spoken words (sr-only, absolutely placed) stay inside the field */\n    position: relative;\n    display: flex;\n    min-width: 0;\n    flex-direction: column;\n    gap: var(--pui-field-gap);\n  }\n  .labelRow {\n    display: flex;\n    min-width: 0;\n    align-items: baseline;\n    gap: var(--pui-space-2);\n  }\n  .aside {\n    margin-inline-start: auto;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n  }\n\n  /* The message: one line at a time. A new line grows in to its own height (0fr to 1fr, no measuring) while the one it\n   replaces folds away above it, so the height glides from one to the other; a line that leaves with nothing after it\n   takes the gap with it. Arrival over --pui-motion-in, departure over --pui-motion-out; reduced motion keeps the fades. */\n  .messages {\n    display: grid;\n    margin-top: calc(var(--pui-field-gap, var(--pui-space-1-5)) * -1);\n  }\n  .line {\n    display: grid;\n    grid-template-rows: 1fr;\n    opacity: 1;\n    transition:\n      grid-template-rows var(--pui-motion-in) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n  }\n  @starting-style {\n    .line {\n      grid-template-rows: 0fr;\n      opacity: 0;\n    }\n  }\n  /* the line a field is first drawn with does not grow in */\n  .line[data-initial] {\n    transition: none;\n  }\n  /* a swap is one change of size: the old line folds and the new one grows over the same duration and curve, so their\n     sum (the field's height) moves straight from one to the other, with no swell between; the old words fade first */\n  .line[data-leaving] {\n    grid-template-rows: 0fr;\n    opacity: 0;\n    transition:\n      grid-template-rows var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n    pointer-events: none;\n  }\n  .messages:has(> .line[data-leaving]) > .line:not([data-leaving], [data-initial]) {\n    transition-duration: var(--pui-motion-out), var(--pui-fade-in, var(--pui-motion-in));\n  }\n  .clip {\n    min-height: 0;\n    overflow: clip;\n    overflow-clip-margin: var(--pui-space-1);\n  }\n  .message {\n    display: flex;\n    align-items: flex-start;\n    gap: var(--pui-space-1-5);\n    padding-top: var(--pui-field-gap, var(--pui-space-1-5));\n    font-size: var(--pui-text-xs);\n    line-height: 1.375;\n  }\n  .description {\n    display: block;\n    color: var(--pui-ink-3);\n  }\n  .error {\n    color: var(--pui-red);\n  }\n  .success {\n    color: var(--pui-green);\n  }\n  .icon {\n    flex-shrink: 0;\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    margin-top: 1px;\n  }\n\n  /* The count in the label's row: tabular, quiet, shown with attention (.reveal) and in words near the limit. */\n  .count {\n    font-variant-numeric: tabular-nums;\n    white-space: nowrap;\n  }\n  .count[data-state=\"near\"],\n  .count[data-state=\"full\"] {\n    color: var(--pui-orange);\n  }\n  .count[data-state=\"over\"] {\n    color: var(--pui-red);\n  }\n\n  /* The status at the end of the frame: the spinner arrives scaled in from its centre, and closes into the check. */\n  .status {\n    display: inline-grid;\n    flex-shrink: 0;\n    place-items: center;\n    animation: status-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  @keyframes status-in {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.4 * var(--pui-motion-travel, 1));\n    }\n  }\n\n  /* The one frame every text-like control sits in: input, textarea, password, search, number, the select and date\n   triggers. Its paint (fill, edge, hover, focus) is the field role of the look in force (looks.css); invalid,\n   read-only and disabled are drawn here so they read the same in every look. */\n  .frame {\n    --_field-height: var(--pui-control-height-md);\n    position: relative;\n    display: flex;\n    min-width: 0; /* a grid or flex parent may make it narrower than the input's natural width */\n    align-items: center;\n    gap: var(--pui-space-2);\n    min-height: var(--_field-height);\n    padding-inline: var(--pui-space-2-5);\n    border-radius: var(--pui-field-radius, var(--pui-radius-control));\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    cursor: text;\n    transition:\n      box-shadow var(--pui-motion-out) var(--pui-ease-default),\n      background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .frame:hover:not([data-disabled]) {\n    transition-duration: 0ms;\n  }\n  .frame[data-state=\"open\"] {\n    box-shadow: var(--m-field-edge-focus);\n  }\n  .frame:focus-within {\n    transition: none;\n  }\n  .frame[data-disabled] {\n    opacity: var(--pui-opacity-disabled);\n    cursor: not-allowed;\n  }\n  .frame[data-readonly] {\n    --m-field-bg: transparent;\n  }\n  .sm {\n    --_field-height: var(--pui-control-height-sm);\n    padding-inline: var(--pui-space-2);\n  }\n  .lg {\n    --_field-height: var(--pui-control-height-lg);\n    padding-inline: var(--pui-space-3);\n    font-size: var(--pui-text-base);\n  }\n  .multiline {\n    align-items: stretch;\n    padding-block: var(--pui-space-2);\n  }\n\n  /* The control itself: borderless, it takes the frame's look. Focus shows on the frame, so the control's own ring is off\n   (the frame's ring is the visible focus indicator, which is always present when the control has focus). */\n  .control {\n    flex: 1;\n    min-width: 0;\n    height: 100%;\n    min-height: calc(var(--_field-height) - 2px);\n    border: 0;\n    outline: none;\n    background: transparent;\n    color: inherit;\n    font: inherit;\n    letter-spacing: inherit;\n    padding: 0;\n  }\n  .control:focus-visible {\n    outline: none;\n  }\n  .control::placeholder {\n    color: var(--pui-ink-3);\n    opacity: 1;\n  }\n  .control:disabled {\n    cursor: not-allowed;\n  }\n  textarea.control {\n    min-height: 0;\n    resize: none;\n    line-height: 1.5;\n    field-sizing: content;\n  }\n  .adornment {\n    display: inline-flex;\n    align-items: center;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n  }\n  .adornment :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.frame {\n  composes: m-field nest-corner from global;\n}\n"},{"path":"beamline/components/_shared/field.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/field.tsx","content":"\"use client\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english } from \"./messages\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type Ref,\n  type TransitionEvent,\n} from \"react\";\nimport { AlertCircle, CircleCheck } from \"lucide-react\";\nimport { Label } from \"../label/label\";\nimport { Spinner } from \"../spinner/spinner\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./field.module.css\";\n\nexport type FieldSize = \"sm\" | \"md\" | \"lg\";\nexport type FieldClassNames = Partial<\n  Record<\"root\" | \"label\" | \"frame\" | \"leading\" | \"trailing\" | \"description\" | \"error\" | \"success\" | \"aside\", string>\n>;\n\nexport interface FieldOptions {\n  /** Visible label, also the accessible name. */\n  label?: ReactNode;\n  /** Keep the label for assistive tech only (dense rows, toolbars). */\n  hideLabel?: boolean;\n  /** One line of help under the control. */\n  description?: ReactNode;\n  /** The problem and its fix; replaces the description and marks the control invalid. */\n  error?: ReactNode;\n  /** The confirmation that the value works (\"@mira is available\"): replaces the description, with a check. */\n  success?: ReactNode;\n  required?: boolean;\n  size?: FieldSize;\n}\n\n/** Ids and aria wiring for one field: the control gets these spread onto it. */\nexport function useFieldIds(\n  id?: string,\n  { description, error, success }: Pick<FieldOptions, \"description\" | \"error\" | \"success\"> = {},\n) {\n  const auto = useId();\n  const controlId = id ?? `field-${auto}`;\n  // the line under the field is one of three, and the control points at whichever is there\n  const descriptionId = (success || description) && !error ? `${controlId}-description` : undefined;\n  const errorId = error ? `${controlId}-error` : undefined;\n  return {\n    controlId,\n    descriptionId,\n    errorId,\n    aria: {\n      id: controlId,\n      \"aria-describedby\": [descriptionId, errorId].filter(Boolean).join(\" \") || undefined,\n      \"aria-invalid\": error ? true : undefined,\n      \"aria-errormessage\": errorId,\n    },\n  };\n}\n\n/** Label above (with an optional aside at the end of its row: a counter, a link), control frame, then the message. */\nexport function Field({\n  label,\n  hideLabel,\n  description,\n  error,\n  success,\n  required,\n  controlId,\n  descriptionId,\n  errorId,\n  aside,\n  className,\n  classNames,\n  style,\n  slot = \"field\",\n  requiredOnControl,\n  children,\n}: FieldOptions & {\n  controlId: string;\n  descriptionId?: string;\n  errorId?: string;\n  /** At the end of the label's row: a character count, \"Forgot password?\", \"Optional\". */\n  aside?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  style?: CSSProperties;\n  slot?: string;\n  /** The control carries `required` itself, so the label does not repeat it in the control's name. */\n  requiredOnControl?: boolean;\n  children: ReactNode;\n}) {\n  const labelNode = label != null && (\n    <Label\n      htmlFor={controlId}\n      required={required}\n      requiredOnControl={requiredOnControl}\n      className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n    >\n      {label}\n    </Label>\n  );\n  return (\n    <div\n      data-slot={slot}\n      data-invalid={error ? \"\" : undefined}\n      style={style}\n      className={cn(styles.field, aside != null && \"reveal-host\", classNames?.root, className)}\n    >\n      {aside != null ? (\n        <div data-slot=\"field-label-row\" className={styles.labelRow}>\n          {labelNode}\n          <span data-slot=\"field-aside\" className={cn(styles.aside, classNames?.aside)}>\n            {aside}\n          </span>\n        </div>\n      ) : (\n        labelNode\n      )}\n      {children}\n      <FieldMessage\n        error={error}\n        success={success}\n        description={description}\n        errorId={errorId}\n        descriptionId={descriptionId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n\ntype Tone = \"error\" | \"success\" | \"description\";\ninterface Line {\n  key: string;\n  tone: Tone;\n  content: ReactNode;\n}\nconst ICON = { error: AlertCircle, success: CircleCheck } as const;\n\n/**\n * The line under a field: its problem when there is one, else its confirmation, else its help. Shared by every field\n * and choice group. A change of line keeps its path: the new line grows in under the control while the old one folds\n * away above it, so what sits below glides instead of jumping, and a quick reversal turns around from where it is.\n * The leaving line is a still copy, hidden from assistive tech and inert; ids and aria point only at the line that is\n * there. It is gone when its fold ends, and by the clock as well: a line that leaves in the frame it arrived has no\n * fold to run, so no end is reported.\n */\nexport function FieldMessage({\n  error,\n  success,\n  description,\n  errorId,\n  descriptionId,\n  slot = \"field\",\n  classNames,\n}: Pick<FieldOptions, \"error\" | \"description\" | \"success\"> & {\n  errorId?: string;\n  descriptionId?: string;\n  /** The slot names' prefix: \"checkbox\" names the lines checkbox-error, checkbox-description. */\n  slot?: string;\n  classNames?: Pick<FieldClassNames, \"error\" | \"description\" | \"success\">;\n}) {\n  const tone: Tone | null = error ? \"error\" : success ? \"success\" : description ? \"description\" : null;\n  const content = error || success || description;\n  // a new text is a new line (one problem replacing another also folds and grows); rich content changes in place\n  const text = typeof content === \"string\" || typeof content === \"number\" ? String(content) : \"\";\n  const current: Line | null = tone ? { key: `${tone}\\u0000${text}`, tone, content } : null;\n  const [last, setLast] = useState<Line | null>(current);\n  const [leaving, setLeaving] = useState<Line | null>(null);\n  // the line a field is drawn with is simply there; only a change of line moves\n  const [changed, setChanged] = useState(false);\n  if ((last?.key ?? null) !== (current?.key ?? null)) {\n    // the line that was there leaves (one at a time: a line already leaving is gone at once)\n    setLeaving(last && last.key !== current?.key ? last : null);\n    setLast(current);\n    setChanged(true);\n  }\n  const box = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    if (!leaving) return;\n    // the clock is the leaving line's own longest transition, read once as it starts to leave (the fold's end event\n    // usually comes first), so a field resolves no motion tokens of its own\n    const el = box.current?.querySelector<HTMLElement>(\"[data-leaving]\");\n    const longest = el\n      ? Math.max(\n          0,\n          ...getComputedStyle(el)\n            .transitionDuration.split(\",\")\n            .map((t) => parseFloat(t) * (t.trim().endsWith(\"ms\") ? 1 : 1000) || 0),\n        )\n      : 0;\n    const id = window.setTimeout(() => setLeaving((l) => (l === leaving ? null : l)), longest + 250);\n    return () => window.clearTimeout(id);\n  }, [leaving]);\n  if (!current && !leaving) return null;\n  const shown = leaving && leaving.key === current?.key ? null : leaving;\n  const ids = { error: errorId, success: descriptionId, description: descriptionId };\n  const line = (l: Line, gone: boolean) => {\n    const Icon = l.tone === \"description\" ? null : ICON[l.tone];\n    return (\n      <div\n        key={l.key}\n        data-slot=\"field-message-line\"\n        data-leaving={gone ? \"\" : undefined}\n        data-initial={changed ? undefined : \"\"}\n        aria-hidden={gone || undefined}\n        inert={gone || undefined}\n        className={styles.line}\n        onTransitionEnd={\n          gone\n            ? (e: TransitionEvent<HTMLDivElement>) => {\n                if (e.target === e.currentTarget && e.propertyName === \"grid-template-rows\") setLeaving(null);\n              }\n            : undefined\n        }\n      >\n        <div className={styles.clip}>\n          <p\n            id={gone ? undefined : ids[l.tone]}\n            data-slot={`${slot}-${l.tone}`}\n            className={cn(styles.message, styles[l.tone], classNames?.[l.tone])}\n          >\n            {Icon && <Icon aria-hidden className={styles.icon} />}\n            {l.content}\n          </p>\n        </div>\n      </div>\n    );\n  };\n  return (\n    <div ref={box} data-slot=\"field-message\" className={styles.messages}>\n      {shown && line(shown, true)}\n      {current && line(current, false)}\n    </div>\n  );\n}\n\n/** What the end of a text field says about its work: a spinner while a value is checked, a drawn check when it works. */\nexport type FieldStatus = \"busy\" | \"success\";\n\n/** The frame a text-like control sits in; clicking anywhere on it focuses the control. */\nexport function FieldFrame({\n  size = \"md\",\n  invalid,\n  disabled,\n  readOnly,\n  multiline,\n  status,\n  statusLabel: statusLabelProp,\n  leading,\n  trailing,\n  className,\n  classNames,\n  children,\n  onPointerDown,\n  ref,\n}: {\n  size?: FieldSize;\n  invalid?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  multiline?: boolean;\n  /** busy: a spinner at the end while the value is checked or saved; success: the ring closes into a check. */\n  status?: FieldStatus;\n  /** What the spinner says while busy (\"Checking\", \"Saving\"). */\n  statusLabel?: string;\n  leading?: ReactNode;\n  trailing?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  children: ReactNode;\n  onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void;\n  /** The frame itself, for a control that lays out by the frame's width (useWidthAs). */\n  ref?: Ref<HTMLDivElement>;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const statusLabel = statusLabelProp === undefined ? i18n(\"statusLabel\") : statusLabelProp;\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"field-control\"\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-readonly={readOnly ? \"\" : undefined}\n      data-status={status}\n      className={cn(styles.frame, styles[size], multiline && styles.multiline, classNames?.frame, className)}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        // a click on the frame's padding or an adornment that is not itself interactive focuses the control\n        const t = e.target as HTMLElement;\n        if (t === e.currentTarget || (t.closest(\"[data-adornment]\") && !t.closest(\"button, a, input, [role=button]\"))) {\n          e.preventDefault();\n          e.currentTarget.querySelector<HTMLElement>(\"input, textarea\")?.focus();\n        }\n      }}\n    >\n      {leading && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"leading\"\n          className={cn(styles.adornment, classNames?.leading)}\n        >\n          {leading}\n        </span>\n      )}\n      {children}\n      {status && (\n        // one ring for both: it spins while the value is checked and closes into the check when it works\n        <span data-slot=\"field-status\" data-adornment=\"status\" className={styles.status}>\n          <Spinner size=\"sm\" state={status === \"busy\" ? \"loading\" : \"success\"} label={statusLabel} />\n        </span>\n      )}\n      {trailing && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"trailing\"\n          className={cn(styles.adornment, classNames?.trailing)}\n        >\n          {trailing}\n        </span>\n      )}\n    </div>\n  );\n}\n\n/**\n * A character count for the label's row (Field `aside`): quiet (\"58/140\") and shown while the field has attention,\n * then always shown and in words as the limit nears (\"12 left\", \"Limit reached\", \"3 over\"), so it never competes with\n * the label at rest. Assistive tech hears it only when it crosses into those words, not at every key.\n */\nexport function FieldCount({\n  length,\n  max,\n  near: nearAt,\n  slot = \"field-count\",\n  className,\n}: {\n  length: number;\n  max: number;\n  /** How many left turns it into words: by default 10 % of the limit, at least 5 (characters); 1 suits a few tags. */\n  near?: number;\n  slot?: string;\n  className?: string;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const left = max - length;\n  const near = left <= (nearAt ?? Math.max(5, Math.ceil(max * 0.1)));\n  const state = left < 0 ? \"over\" : left === 0 ? \"full\" : near ? \"near\" : \"room\";\n  const words =\n    state === \"over\"\n      ? i18n(\"fieldOver\", { count: -left })\n      : state === \"full\"\n        ? i18n(\"fieldFull\")\n        : i18n(\"fieldLeft\", { count: left });\n  return (\n    <span\n      data-slot={slot}\n      data-state={state}\n      data-shown={state === \"room\" ? undefined : \"\"}\n      className={cn(\"reveal\", styles.count, className)}\n    >\n      <span aria-hidden>{state === \"room\" ? `${i18n.number(length)}/${i18n.number(max)}` : words}</span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {state === \"room\" ? \"\" : words}\n      </span>\n    </span>\n  );\n}\n\n/** The class a native input or textarea needs inside a FieldFrame. */\nexport const fieldControlClass = styles.control;\n\n/** What a choice group (segmented control, radio cards) spreads onto its own root when it is a form field. */\nexport interface ChoiceFieldAria {\n  \"aria-labelledby\": string;\n  \"aria-describedby\"?: string;\n  \"aria-invalid\"?: true;\n}\n\n/**\n * Whether a choice control (segmented control, radio cards, chip group) is laid out as a form field, its label shown\n * above like every field's: when asked (`field`), when it has a description, error or required mark, and whenever its\n * label is not hidden. A view switch or a quick filter passes `hideLabel`: its label stays for assistive tech only.\n * (Trials t16 and t19: agents wrote <SegmentedControl label=\"Catering needed\" …> and the question never showed.)\n */\nexport function choiceIsField(\n  field: boolean | undefined,\n  label: ReactNode,\n  hideLabel: boolean | undefined,\n  marked: boolean,\n) {\n  return field ?? (marked || (label != null && label !== \"\" && !hideLabel));\n}\n\n/**\n * A choice group as a form field: the visible label above (with the required marker), the group, then its help or its\n * problem — the same frame and message every text field has. Groups that name themselves only for assistive tech\n * (a view switch in a toolbar) do not use it.\n */\nexport function ChoiceField({\n  slot,\n  label,\n  hideLabel,\n  required,\n  description,\n  error,\n  className,\n  style,\n  classNames,\n  requiredOnControl,\n  children,\n}: Omit<FieldOptions, \"size\" | \"success\"> & {\n  slot: string;\n  /** The group carries `aria-required` itself (a radiogroup), so the label does not repeat it in the group's name. */\n  requiredOnControl?: boolean;\n  className?: string;\n  style?: CSSProperties;\n  classNames?: Pick<FieldClassNames, \"label\" | \"description\" | \"error\">;\n  children: (aria: ChoiceFieldAria) => ReactNode;\n}) {\n  const auto = useId();\n  const labelId = `choice-${auto}-label`;\n  const messageId = error || description ? `choice-${auto}-message` : undefined;\n  return (\n    <div data-slot={slot} data-invalid={error ? \"\" : undefined} style={style} className={cn(styles.field, className)}>\n      <Label\n        id={labelId}\n        required={required}\n        requiredOnControl={requiredOnControl}\n        className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n      >\n        {label}\n      </Label>\n      {children({\n        \"aria-labelledby\": labelId,\n        ...(messageId ? { \"aria-describedby\": messageId } : {}),\n        ...(error ? { \"aria-invalid\": true as const } : {}),\n      })}\n      <FieldMessage\n        error={error}\n        description={description}\n        errorId={messageId}\n        descriptionId={messageId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n"},{"path":"beamline/components/_shared/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/button/button-variants.ts","type":"registry:file","target":"src/ui/beamline/components/button/button-variants.ts","content":"import { cn } from \"../../lib/utils\";\nimport styles from \"./button.module.css\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"danger\" | \"accent\";\n/** sm 32 · md 36 · lg 40 · touch 44 (a bottom action bar on a phone). */\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"touch\";\n/** Market side: buy fills with --pui-up, sell with --pui-down (a red-up market flips the two tokens, not the code). */\nexport type ButtonTone = \"up\" | \"down\";\n\n/** Which paint role paints each variant: filled variants are solids, the quiet ones keep their own light paint. */\nconst ROLE: Record<ButtonVariant, string | undefined> = {\n  primary: \"m-solid\",\n  accent: \"m-solid\",\n  secondary: \"m-control\",\n  outline: undefined,\n  ghost: undefined,\n  danger: undefined,\n};\n\n/** A variant's paint without the button's layout, for a group that wears one button's look (SplitButton paints the\n *  pair as one button and leaves its halves transparent). */\nexport function buttonPaint(variant: ButtonVariant = \"primary\", tone?: ButtonTone) {\n  return cn(ROLE[variant], styles[variant], tone && styles[tone]);\n}\n\n/** The classes that make any element look like a Button, for parts that render their own element. */\nexport function buttonClassName({\n  variant = \"primary\",\n  size = \"md\",\n  tone,\n  className,\n}: { variant?: ButtonVariant; size?: ButtonSize; tone?: ButtonTone; className?: string } = {}) {\n  return cn(styles.root, ROLE[variant], styles[variant], tone && styles[tone], styles[size], className);\n}\n"},{"path":"beamline/components/button/button.module.css","type":"registry:file","target":"src/ui/beamline/components/button/button.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Button. Every colour, size and duration is a token; a variant only sets the four local properties below. */\n  .root {\n    --_button-bg: transparent;\n    --_button-bg-hover: var(--pui-hover);\n    --_button-fg: var(--pui-ink);\n    --_button-edge: none;\n    --_button-height: var(--pui-control-height-md);\n    --_button-pad: 14px;\n    position: relative;\n    isolation: isolate;\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    gap: var(--pui-space-1-5);\n    flex-shrink: 0;\n    height: var(--_button-height);\n    min-width: var(--_button-height);\n    /* never wider than its room: in a narrow card the label ends in an ellipsis instead of spilling (the full words\n       stay the accessible name) */\n    max-width: 100%;\n    padding-inline: var(--_button-pad);\n    border: 0;\n    /* Nested context: inside a frame that sets --pui-nest-radius and --pui-nest-inset (and --pui-nest-ceiling for pills)\n       the corner is the shared nested corner (.nest-corner, utilities.css: radius − inset, or the button's own radius\n       where that would fall under the chip radius); elsewhere the control radius. --pui-button-radius wins over both. */\n    border-radius: var(--pui-button-radius, var(--pui-nested, var(--pui-radius-control)));\n    color: var(--_button-fg);\n    font-family: var(--pui-font-body);\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-medium);\n    line-height: 1;\n    letter-spacing: var(--pui-tracking-body);\n    white-space: nowrap;\n    text-decoration: none;\n    cursor: pointer;\n    user-select: none;\n    -webkit-tap-highlight-color: transparent;\n    /* frequency law: hover arrives at 0ms (below), leaves over the ruled departure; opacity and translate are listed\n       so a button that waits for attention (.reveal) fades away like every reveal instead of vanishing */\n    transition:\n      var(--m-transition),\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  /* the quiet variants paint themselves; filled and secondary variants are painted by their look role (m-solid,\n   m-control), which these zero-specificity rules can never override */\n  :where(.root) {\n    background: var(--_button-bg);\n    box-shadow: var(--_button-edge);\n  }\n  :where(.root:hover:not(:disabled, [aria-disabled=\"true\"])) {\n    background: var(--_button-bg-hover);\n  }\n  .root:hover:not(:disabled, [aria-disabled=\"true\"]) {\n    transition-duration: 0ms;\n  }\n  /* the asChild form has no motion: a CSS press instead */\n  .root:not(button):active {\n    transform: scale(0.97);\n  }\n  .root:disabled,\n  .root[aria-disabled=\"true\"]:not([data-loading]) {\n    opacity: var(--pui-opacity-disabled);\n    cursor: not-allowed;\n  }\n  .root[data-loading] {\n    cursor: progress;\n  }\n\n  /* Icons: 16px unless the icon says otherwise (zero specificity, so size-5 or an inline size wins) */\n  :where(.root) :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n    flex-shrink: 0;\n  }\n\n  .sm {\n    --_button-height: var(--pui-control-height-sm);\n    --_button-pad: var(--pui-space-2-5);\n    /* the base gap (--pui-space-1-5); 5px had no scale step, and 6px also lets the loader's negative margin cancel\n       the gap exactly at every size */\n  }\n  .md {\n    --_button-height: var(--pui-control-height-md);\n    --_button-pad: 14px;\n  }\n  .lg {\n    --_button-height: var(--pui-control-height-lg);\n    --_button-pad: var(--pui-space-4);\n    font-size: var(--pui-text-base);\n  }\n  /* a bottom action bar on a phone: the 44 px touch target (a platform minimum, not a spacing step, so it holds when\n     the spacing parameter changes — O1 §14.2), the large label */\n  .touch {\n    --_button-height: calc(var(--pui-control-height-lg) + 0.25rem);\n    --_button-pad: var(--pui-space-5);\n    font-size: var(--pui-text-base);\n  }\n  .root[data-icon-only] {\n    padding-inline: 0;\n    width: var(--_button-height);\n  }\n\n  /* the main action: ink on the page, inverted per theme */\n  .primary {\n    --m-color: var(--pui-ink);\n    --_button-fg: var(--pui-ink-inverse);\n  }\n  .secondary {\n    --_button-fg: var(--pui-ink);\n  }\n  .outline {\n    --_button-edge: inset 0 0 0 1px var(--pui-line-strong);\n    --_button-bg-hover: var(--pui-hover);\n  }\n  .ghost {\n    --_button-fg: var(--pui-ink-2);\n    --_button-bg-hover: var(--pui-hover);\n  }\n  .ghost:hover:not(:disabled) {\n    color: var(--pui-ink);\n  }\n  .danger {\n    --_button-fg: var(--pui-red);\n    --_button-edge: inset 0 0 0 1px color-mix(in oklab, var(--pui-red) 42%, transparent);\n    --_button-bg-hover: var(--pui-red-tint);\n  }\n  .accent {\n    --m-color: var(--pui-accent);\n    --_button-fg: var(--pui-accent-foreground);\n  }\n\n  /* Market side. Declared after the variants so it wins: a filled variant takes the side's colour and its on-colour\n     ink; a quiet variant keeps its paint and colours the label. */\n  .up {\n    --m-color: var(--pui-up);\n    --_button-fg: var(--pui-up);\n  }\n  .down {\n    --m-color: var(--pui-down);\n    --_button-fg: var(--pui-down);\n  }\n  .ghost:is(.up, .down):hover:not(:disabled) {\n    color: var(--_button-fg);\n  }\n  .primary.up,\n  .accent.up {\n    --_button-fg: var(--pui-on-up);\n  }\n  .primary.down,\n  .accent.down {\n    --_button-fg: var(--pui-on-down);\n  }\n\n  /* The main actions' light, where the look provides one, as a spectrum. Glow spills a soft light in the brand's\n     spectrum from under the button's bottom edge (clipped at it, so it never tints the face) that brightens under the\n     pointer. Prism keeps it inside the glass (--m-solid-glow-fit: 1): the face the light leaves by, the bottom edge,\n     splits it into a crisp line with a faint wash rising into the face, only while pointed at. Every other look\n     leaves --m-solid-glow at none, so the layer does not exist. */\n  .primary::after,\n  .accent::after {\n    --_fit: var(--m-solid-glow-fit, 0);\n    content: var(--m-solid-glow);\n    position: absolute;\n    z-index: -1;\n    top: calc((1 - var(--_fit)) * (100% + var(--m-solid-glow-gap, 0px)));\n    inset-inline: var(--m-solid-glow-inset, 14%);\n    height: var(--m-solid-glow-height, 14px);\n    border-radius: calc(\n      var(--_fit) * var(--pui-button-radius, var(--pui-nested, var(--pui-radius-control))) + (1 - var(--_fit)) *\n        var(--pui-radius-pill)\n    );\n    background: var(--m-spectrum);\n    background-size: var(--m-spectrum-size, 100% 100%);\n    /* the part of the spectrum that shows: this button's own angle (--m-prism-seed, new at every press), turned by\n       where the pointer is across the face (--m-prism-x) in a look that turns it (--m-solid-glow-turn) */\n    background-position: calc((var(--m-prism-seed, 0.3) + var(--m-prism-x, 0.5) * var(--m-solid-glow-turn, 0)) * 100%) 0;\n    filter: blur(var(--m-solid-glow-blur, 9px));\n    clip-path: var(--m-solid-glow-clip, inset(0 -32px -32px -32px));\n    mask: var(--m-solid-glow-mask, none);\n    mask-position:\n      0 0,\n      calc(var(--m-prism-seed, 0.3) * 100%) 0;\n    mask-composite: intersect;\n    opacity: var(--m-solid-glow-rest, 0.75);\n    scale: var(--m-solid-glow-scale, 1) 1;\n    pointer-events: none;\n    transition:\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      scale var(--pui-motion-out) var(--pui-ease-default),\n      background-position var(--pui-duration-considered) var(--pui-ease-default),\n      mask-position var(--pui-duration-considered) var(--pui-ease-default);\n  }\n  /* pointed at: the light brightens (glow) or grows out from the centre and drifts along while the pointer stays\n     (prism) */\n  .primary:hover::after,\n  .accent:hover::after {\n    opacity: 1;\n    scale: 1 1;\n    transition-duration: var(--m-solid-glow-in, 0ms);\n    animation: var(--m-solid-glow-flow, none);\n  }\n  /* A change of word or colour (Save → Saved, a tone turning green) passes a band of split light across the face once,\n     in a look that has one (prism: --m-solid-shift-light; the pen hook's ::before is free on a solid otherwise). */\n  .primary[data-shift]::before,\n  .accent[data-shift]::before {\n    content: var(--m-solid-shift-light, none);\n    position: absolute;\n    inset: 0;\n    z-index: -1;\n    border: 0;\n    border-radius: inherit;\n    background: var(--m-spectrum) 0 0 / var(--m-spectrum-size, 100% 100%);\n    mask: linear-gradient(105deg, transparent 39%, #000 47.5%, #000 52.5%, transparent 61%) 0 0 / 300% 100% no-repeat;\n    opacity: 0;\n    animation: prism-shift calc(var(--pui-duration-considered) * 2.2) var(--pui-ease-default);\n    pointer-events: none;\n  }\n  @keyframes prism-shift {\n    from {\n      mask-position: 100% 0;\n      opacity: 0.25;\n    }\n    35% {\n      opacity: 0.55;\n    }\n    to {\n      mask-position: 0 0;\n      opacity: 0;\n    }\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .primary::after,\n    .accent::after {\n      transition: none;\n      animation: none !important;\n    }\n    .primary[data-shift]::before,\n    .accent[data-shift]::before {\n      content: none;\n    }\n  }\n  .root:disabled::after,\n  .root[aria-disabled=\"true\"]::after {\n    content: none;\n  }\n\n  /* The loader opens its own width, and the negative margin cancels the gap so the label does not jump */\n  .loader {\n    display: inline-flex;\n    overflow: hidden;\n    margin-inline-end: calc(var(--pui-space-1-5) * -1);\n  }\n  .loader > * {\n    margin-inline-end: var(--pui-space-1-5);\n  }\n  .spinner {\n    color: currentColor;\n  }\n  .root[data-icon-only] .loader {\n    margin-inline-end: 0;\n  }\n  .root[data-icon-only] .loader > * {\n    margin-inline-end: 0;\n  }\n\n  /* The label holds its place while the text inside swaps; the leaving text pops out of flow against it */\n  .label {\n    position: relative;\n    display: inline-flex;\n    justify-content: center;\n    min-width: 0;\n    overflow: visible;\n  }\n  /* the clip box is let out by a little over a line so descenders are never cut, without changing the layout */\n  .text {\n    display: inline-block;\n    max-width: 100%;\n    margin-block: -0.2em;\n    padding-block: 0.2em;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .label[data-morphing] > .text {\n    overflow: visible;\n    text-overflow: clip;\n  }\n}\n\n@layer components {\n  :global(.pui[data-motion=\"reduced\"]) .root:not(button):active,\n  :global(.pui[data-motion=\"none\"]) .root:not(button):active {\n    transform: none;\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .root:not(button):active {\n      transform: none;\n    }\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.root {\n  composes: nest-corner from global;\n}\n"},{"path":"beamline/components/button/button.tsx","type":"registry:file","target":"src/ui/beamline/components/button/button.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport {\n  Children,\n  Fragment,\n  isValidElement,\n  useLayoutEffect,\n  useRef,\n  type ButtonHTMLAttributes,\n  type PointerEvent,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  type ComponentProps,\n} from \"react\";\nimport { AnimatePresence, LazyMotion, m, spring, useIsPresent } from \"motion/react\";\nimport { animate } from \"motion/mini\";\nimport * as Slot from \"@radix-ui/react-slot\";\nimport { Spinner } from \"../spinner/spinner\";\nimport type { BuiltInLook as Look } from \"../../runtime/preset\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./button.module.css\";\n\nexport type { ButtonVariant, ButtonSize, ButtonTone } from \"./button-variants\";\nexport { buttonPaint, buttonClassName } from \"./button-variants\";\nimport { buttonClassName, type ButtonVariant, type ButtonSize, type ButtonTone } from \"./button-variants\";\nimport type { ButtonDefaults } from \"./defaults\";\nexport type { ButtonDefaults } from \"./defaults\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\n\ntype NativeProps = Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\"\n>;\n\nexport interface ButtonProps extends NativeProps {\n  /** Visual weight: primary (ink-filled), secondary, outline, ghost, danger, or accent (filled with the brand). */\n  variant?: ButtonVariant;\n  /** Height 32 / 36 / 40 px; touch 44 px for a bottom action bar on a phone. */\n  size?: ButtonSize;\n  /** Market side. On primary/accent the fill becomes --pui-up (buy) or --pui-down (sell); on the quiet variants the\n   *  label takes that colour. Pair it with a word or arrow: colour is never the only signal. */\n  tone?: ButtonTone;\n  /** Shows a loader in front of the label, sets aria-busy and ignores clicks; size and focus stay. */\n  loading?: boolean;\n  classNames?: Partial<Record<\"root\" | \"label\" | \"loader\", string>>;\n  /** Paint this button in another look than the one in force around it (DESIGN-LANGUAGE.md \"Looks\"). */\n  look?: Look;\n  /** Render the single child (a link, a Radix trigger) with the button's look instead of a <button>. */\n  asChild?: boolean;\n  /** A square button holding only an icon (give it an aria-label). Decided by the children when left out: icon-only when\n   *  no words sit anywhere among them (an icon, or a frame of icons), so `<span>Close</span>` is a label. */\n  iconOnly?: boolean;\n  ref?: Ref<HTMLButtonElement>;\n}\n\n// Motion's animation features arrive in a deferred chunk (AT11): the button renders and works at once, and its press,\n// loader and label motion run once they land, a frame or two after mount. Buttons that mount after that get the\n// loaded features directly, so they render once instead of again when a lazy load resolves.\ntype Features = typeof import(\"./motion-features\").default;\nlet loaded: Features | null = null;\nconst features = () =>\n  import(\"./motion-features\").then((module) => {\n    loaded = module.default;\n    return loaded;\n  });\n\n/** Children with fragments opened up, so `{busy ? <><Icon />Saving</> : …}` reads as icon plus text. */\nconst flat = (children: ReactNode): ReactNode[] =>\n  Children.toArray(children).flatMap((c) =>\n    isValidElement<{ children?: ReactNode }>(c) && c.type === Fragment ? flat(c.props.children) : [c],\n  );\n\n/** Whether any words sit anywhere inside (an icon, or a frame of icons, has none). */\nfunction hasText(node: ReactNode): boolean {\n  if (typeof node === \"string\") return node.trim() !== \"\";\n  if (typeof node === \"number\") return true;\n  if (Array.isArray(node)) return node.some(hasText);\n  return isValidElement<{ children?: ReactNode }>(node) && hasText(node.props.children);\n}\n\n/** Splits children into the text run (the label that can change) and everything else (icons), in order, and says\n *  whether any element carries content of its own (a `<span>Close</span>` label, unlike an icon). */\nfunction split(children: ReactNode) {\n  const parts = flat(children);\n  const text = parts\n    .filter((p) => typeof p === \"string\" || typeof p === \"number\")\n    .join(\"\")\n    .trim();\n  const firstText = parts.findIndex((p) => typeof p === \"string\" || typeof p === \"number\");\n  const before = firstText === -1 ? parts : parts.slice(0, firstText).filter(isValidElement);\n  const after = firstText === -1 ? [] : parts.slice(firstText).filter(isValidElement);\n  const content = parts.some((p) => isValidElement<{ children?: ReactNode }>(p) && hasText(p.props.children));\n  return { text, before, after, content };\n}\n\n/**\n * The action control. Hover answers at once and lets go over --pui-motion-out; a press scales down at once, or swells\n * on the snappy spring where the look's --pui-press-scale is above 1 (glass), never on a trigger that anchors a\n * floating layer, which would measure a resized box; a changed label slides in while the\n * width follows on a critically damped spring; loading keeps the size and the focus.\n */\nexport function Button({\n  variant: variantProp,\n  size: sizeProp,\n  tone: toneProp,\n  loading = false,\n  look,\n  asChild = false,\n  iconOnly: iconOnlyProp,\n  className,\n  classNames,\n  children,\n  type,\n  onClick,\n  onPointerMove,\n  onPointerDown,\n  disabled,\n  ref,\n  ...props\n}: ButtonProps) {\n  const i18n = useMessages(\"button\", englishMessages);\n\n  const defaults = useComponentDefaults<ButtonDefaults>(\"button\", [\"variant\", \"size\", \"tone\"]);\n  const variant = variantProp ?? defaults.variant ?? \"primary\";\n  const size = sizeProp ?? defaults.size ?? \"md\";\n  const tone = toneProp ?? defaults.tone;\n  const host = useRef<HTMLButtonElement>(null);\n  const attach = useComposedRef(host, ref);\n  const motionTokens = useMotionTokens(host);\n  const requestedReduced = useReducedMotionSafe();\n  const reduced = requestedReduced || motionTokens.reduced;\n  // Reduced motion keeps a restrained fade; none removes it (APPEARANCE A06).\n  const fade = motionTokens.mode === \"none\" ? 0 : motionTokens.fade.in;\n  // a caller's aria-disabled keeps the button focusable (focus never drops after Save resolves) but inert to presses\n  const softDisabled = props[\"aria-disabled\"] === true || props[\"aria-disabled\"] === \"true\";\n  const { text, before, after, content } = split(children);\n  const prism = usePrismLight(host, variant === \"primary\" || variant === \"accent\", `${text}|${tone}|${variant}`);\n  // with asChild the text sits inside the child (the link), not among our own children\n  const inner = asChild && isValidElement<{ children?: ReactNode }>(children) ? split(children.props.children) : null;\n  const iconOnly = iconOnlyProp ?? (inner ? !inner.text && !inner.content : !text && !content);\n  const anchor =\n    (props[\"aria-haspopup\"] != null && props[\"aria-haspopup\"] !== false && props[\"aria-haspopup\"] !== \"false\") ||\n    \"data-state\" in props;\n  const shared = {\n    \"data-slot\": \"button\",\n    \"data-variant\": variant,\n    \"data-size\": size,\n    \"data-tone\": tone,\n    \"data-icon-only\": iconOnly ? \"\" : undefined,\n    \"data-loading\": loading ? \"\" : undefined,\n    \"data-look\": look,\n    className: buttonClassName({ variant, size, tone, className: cn(classNames?.root, className) }),\n  };\n\n  if (asChild)\n    return (\n      <Slot.Root {...shared} {...props} ref={attach}>\n        {children}\n      </Slot.Root>\n    );\n\n  const button = (\n    <m.button\n      ref={attach}\n      type={type ?? \"button\"}\n      {...shared}\n      {...props}\n      disabled={disabled}\n      aria-busy={loading || undefined}\n      aria-disabled={loading || softDisabled || undefined}\n      onPointerMove={(e) => {\n        prism.turn(e);\n        onPointerMove?.(e);\n      }}\n      onPointerDown={(e) => {\n        prism.press();\n        onPointerDown?.(e);\n      }}\n      onClick={(e) => {\n        if (loading || softDisabled) {\n          e.preventDefault();\n          return;\n        }\n        onClick?.(e);\n      }}\n      whileTap={\n        reduced || anchor || disabled || loading || softDisabled\n          ? undefined\n          : {\n              scale: motionTokens.pressScale,\n              // a press that sinks lands at once; one that swells toward the finger (glass) grows on the spring\n              transition: motionTokens.pressScale > 1 ? motionTokens.spring.snappy : { duration: 0 },\n            }\n      }\n      transition={{ scale: motionTokens.spring.snappy }}\n    >\n      <AnimatePresence initial={false}>\n        {loading && (\n          <m.span\n            key=\"loader\"\n            data-slot=\"button-loader\"\n            className={cn(styles.loader, classNames?.loader)}\n            initial={reduced ? { opacity: 0 } : { width: 0, opacity: 0 }}\n            animate={{ width: \"auto\", opacity: 1 }}\n            exit={reduced ? { opacity: 0 } : { width: 0, opacity: 0 }}\n            transition={{ duration: reduced ? fade : motionTokens.duration.standard, ease: motionTokens.ease.default }}\n          >\n            <Spinner size=\"sm\" label={i18n(\"working\")} className={styles.spinner} />\n          </m.span>\n        )}\n      </AnimatePresence>\n      {iconOnly ? !loading && before : before}\n      {!iconOnly && text && (\n        <Label text={text} reduced={!!reduced} fade={fade} motionTokens={motionTokens} className={classNames?.label} />\n      )}\n      {after}\n    </m.button>\n  );\n  return <LazyMotion features={loaded ?? features}>{button}</LazyMotion>;\n}\n\n/**\n * The main action's light inside the glass, in a look that draws one (prism): its own angle from the start, turned by\n * where the pointer is across the face, a new angle at every press, and a sweep of split light (data-shift) when the\n * button's word, tone or variant changes after it first appears. Writes two style properties and one attribute on the\n * button; nothing runs at rest. Looks without this light ignore all three.\n */\nfunction usePrismLight(host: RefObject<HTMLButtonElement | null>, solid: boolean, state: string) {\n  const frame = useRef(0);\n  const shown = useRef(state);\n  const ending = useRef(0);\n  useLayoutEffect(() => {\n    const el = host.current;\n    if (el && solid) el.style.setProperty(\"--m-prism-seed\", Math.random().toFixed(3));\n  }, [host, solid]);\n  useLayoutEffect(() => {\n    const el = host.current;\n    if (!el || !solid || shown.current === state) return;\n    shown.current = state;\n    // restart the sweep if it is still running\n    delete el.dataset.shift;\n    void el.offsetWidth;\n    el.dataset.shift = \"\";\n    clearTimeout(ending.current);\n    ending.current = window.setTimeout(() => delete el.dataset.shift, 1200);\n  }, [host, solid, state]);\n  useLayoutEffect(\n    () => () => {\n      clearTimeout(ending.current);\n      cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n  return {\n    turn: (e: PointerEvent<HTMLButtonElement>) => {\n      if (!solid || e.pointerType !== \"mouse\" || frame.current) return;\n      const el = e.currentTarget;\n      const x = e.clientX;\n      frame.current = requestAnimationFrame(() => {\n        frame.current = 0;\n        const r = el.getBoundingClientRect();\n        if (r.width) el.style.setProperty(\"--m-prism-x\", ((x - r.left) / r.width).toFixed(3));\n      });\n    },\n    press: () => {\n      if (solid) host.current?.style.setProperty(\"--m-prism-seed\", Math.random().toFixed(3));\n    },\n  };\n}\n\n/** The text run. When it changes, the old words leave upward and the new ones rise in, while the width moves from the\n *  old measure to the new one; no measuring happens while the label is unchanged. */\nfunction Label({\n  text,\n  reduced,\n  fade,\n  motionTokens,\n  className,\n}: {\n  text: string;\n  reduced: boolean;\n  /** Seconds of the label crossfade under reduced motion (0 under none). */\n  fade: number;\n  /** The button's own, resolved at the button: the label sits inside it, in the same look and motion. */\n  motionTokens: ReturnType<typeof useMotionTokens>;\n  className?: string;\n}) {\n  const slot = useRef<HTMLSpanElement>(null);\n  const latest = useRef(motionTokens);\n  useLayoutEffect(() => {\n    latest.current = motionTokens;\n  }, [motionTokens]);\n  const last = useRef<{ text: string; width: number } | null>(null);\n  useLayoutEffect(() => {\n    const el = slot.current;\n    if (!el) return;\n    const prev = last.current;\n    // first mount: read the width after paint, when layout is already clean, instead of forcing a layout per button\n    if (!prev) {\n      last.current = { text, width: Number.NaN };\n      const id = requestAnimationFrame(() => {\n        if (last.current?.text === text) last.current = { text, width: el.getBoundingClientRect().width };\n      });\n      return () => cancelAnimationFrame(id);\n    }\n    el.style.width = \"\";\n    const width = el.getBoundingClientRect().width;\n    last.current = { text, width };\n    // a label that is not rendered (a hidden toolbar on a phone) has nothing to animate\n    if (prev.text === text || reduced || Number.isNaN(prev.width) || !el.getClientRects().length) return;\n    // The same spring, played natively: motion/mini samples the generator into a linear() easing.\n    const { visualDuration, bounce } = latest.current.spring.morph;\n    // while the width eases the words are clipped, not cut short with an ellipsis (that is for a room too narrow)\n    el.dataset.morphing = \"\";\n    const run = animate(el, { width: [`${prev.width}px`, `${width}px`] }, { type: spring, visualDuration, bounce });\n    run.then(() => {\n      el.style.width = \"\";\n      delete el.dataset.morphing;\n    });\n    // cancel, not stop: stop commits the running value, which throws once the element stops being rendered, and the\n    // next change measures afresh anyway\n    return () => {\n      run.cancel();\n      el.style.width = \"\";\n      delete el.dataset.morphing;\n    };\n  }, [text, reduced]);\n  // A label that changes faster than its words can rise in (a count read live while a hand drags) shows each new\n  // value at once: otherwise every value would start invisible and the button would read blank for the whole drag.\n  const rapid = useRapid(text, motionTokens.duration.standard * 1000);\n  const enter = reduced ? { opacity: 0 } : { opacity: 0, y: \"0.45em\", filter: `blur(${motionTokens.blur.subtle}px)` };\n  const leave = reduced\n    ? { opacity: 0 }\n    : {\n        opacity: 0,\n        y: \"-0.45em\",\n        filter: `blur(${motionTokens.blur.subtle}px)`,\n        transition: { duration: motionTokens.duration.fast },\n      };\n  return (\n    <span ref={slot} data-slot=\"button-label\" className={cn(styles.label, className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={rapid}>\n        <Words\n          key={text}\n          className={styles.text}\n          custom={rapid}\n          variants={{\n            enter: (fast: boolean) => (fast ? { opacity: 1, y: \"0em\", filter: \"blur(0px)\" } : enter),\n            shown: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n            leave: (fast: boolean) => (fast ? { opacity: 0, transition: { duration: 0 } } : leave),\n          }}\n          initial=\"enter\"\n          animate=\"shown\"\n          exit=\"leave\"\n          transition={{ duration: reduced ? fade : motionTokens.duration.standard, ease: motionTokens.ease.default }}\n        >\n          {text}\n        </Words>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Whether `text` changed again within `ms` of its last change. Read in render: the answer belongs to this text. */\nfunction useRapid(text: string, ms: number) {\n  const seen = useRef({ text, at: Number.NEGATIVE_INFINITY, rapid: false });\n  if (seen.current.text !== text) {\n    const at = performance.now();\n    seen.current = { text, at, rapid: at - seen.current.at < ms };\n  }\n  return seen.current.rapid;\n}\n\n/** One label's words. Leaving, they are already gone for assistive tech: the button's name is the new label at once,\n *  not both while the old words animate away (or for good, where time stands still in a test). */\nfunction Words(props: ComponentProps<typeof m.span>) {\n  const present = useIsPresent();\n  return <m.span {...props} aria-hidden={present ? undefined : true} />;\n}\n\nexport default Button;\n"},{"path":"beamline/components/button/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/button/defaults.ts","content":"import type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-variants\";\n/** Scoped design choices only; no loading, events or domain state. */\nexport interface ButtonDefaults {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  tone?: ButtonTone;\n}\n"},{"path":"beamline/components/button/messages.ts","type":"registry:file","target":"src/ui/beamline/components/button/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  working: \"Working\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/button/motion-features.ts","type":"registry:file","target":"src/ui/beamline/components/button/motion-features.ts","content":"// REASON: Motion's DOM animation features (tween, spring, presence, press) are loaded after the first paint, so a\n// Button costs a small part of Motion on first load; LazyMotion in button.tsx renders until they arrive.\nimport { domAnimation } from \"motion/react\";\n\nexport default domAnimation;\n"},{"path":"beamline/components/image-cropper/image-cropper.module.css","type":"registry:file","target":"src/ui/beamline/components/image-cropper/image-cropper.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  .root {\n    display: grid;\n    gap: var(--pui-space-3);\n    width: 100%;\n    min-width: min(14rem, 100%);\n  }\n  /* the stage takes only the look's edge (a media frame), never its fill */\n  .stage {\n    position: relative;\n    width: 100%;\n    max-height: 70vh;\n    overflow: hidden;\n    border-radius: var(--pui-radius-card);\n    background: var(--pui-inset);\n    box-shadow: var(--m-surface-edge, none);\n    cursor: grab;\n    touch-action: none;\n    user-select: none;\n    outline-offset: 2px;\n  }\n  .stage:active {\n    cursor: grabbing;\n  }\n  .root[data-disabled] .stage {\n    cursor: default;\n    opacity: 0.6;\n  }\n  .image {\n    position: absolute;\n    top: 0;\n    left: 0;\n    max-width: none;\n    transform-origin: center;\n    pointer-events: none;\n    will-change: transform;\n  }\n  .image[data-easing] {\n    transition: transform var(--pui-duration-considered) var(--pui-ease-standard);\n  }\n\n  /* the window: cut out of a dimmed surround by its own shadow, so a circle is as cheap as a square. Its edge, the\n     thirds and the handles are inverse marks (the knob's light) that read the same over any picture. */\n  .window {\n    position: absolute;\n    top: 0;\n    left: 0;\n    box-shadow:\n      0 0 0 1px color-mix(in oklab, var(--pui-control-thumb) 75%, transparent),\n      0 0 0 9999px oklch(from var(--pui-scrim) l c h / 0.62);\n    pointer-events: none;\n  }\n  [data-shape=\"round\"] .window {\n    border-radius: 50%;\n  }\n  .grid {\n    position: absolute;\n    inset: 0;\n    border-radius: inherit;\n    background:\n      linear-gradient(\n        to right,\n        transparent calc(33.333% - 0.5px),\n        color-mix(in oklab, var(--pui-control-thumb) 55%, transparent) 0,\n        transparent calc(33.333% + 0.5px)\n      ),\n      linear-gradient(\n        to right,\n        transparent calc(66.666% - 0.5px),\n        color-mix(in oklab, var(--pui-control-thumb) 55%, transparent) 0,\n        transparent calc(66.666% + 0.5px)\n      ),\n      linear-gradient(\n        to bottom,\n        transparent calc(33.333% - 0.5px),\n        color-mix(in oklab, var(--pui-control-thumb) 55%, transparent) 0,\n        transparent calc(33.333% + 0.5px)\n      ),\n      linear-gradient(\n        to bottom,\n        transparent calc(66.666% - 0.5px),\n        color-mix(in oklab, var(--pui-control-thumb) 55%, transparent) 0,\n        transparent calc(66.666% + 0.5px)\n      );\n    opacity: 0;\n    transition: opacity var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .window[data-moving] .grid {\n    opacity: 1;\n    transition-duration: var(--pui-motion-in);\n  }\n  .handle {\n    position: absolute;\n    width: 1.5rem;\n    height: 1.5rem;\n    pointer-events: auto;\n    touch-action: none;\n  }\n  .handle::before {\n    content: \"\";\n    position: absolute;\n    inset: 0.375rem;\n    border: 2px solid var(--pui-control-thumb);\n    border-radius: 2px;\n  }\n  .handle[data-corner=\"nw\"] {\n    top: -0.75rem;\n    left: -0.75rem;\n    cursor: nwse-resize;\n  }\n  .handle[data-corner=\"ne\"] {\n    top: -0.75rem;\n    right: -0.75rem;\n    cursor: nesw-resize;\n  }\n  .handle[data-corner=\"sw\"] {\n    bottom: -0.75rem;\n    left: -0.75rem;\n    cursor: nesw-resize;\n  }\n  .handle[data-corner=\"se\"] {\n    right: -0.75rem;\n    bottom: -0.75rem;\n    cursor: nwse-resize;\n  }\n  .handle[data-corner=\"nw\"]::before {\n    border-right: none;\n    border-bottom: none;\n  }\n  .handle[data-corner=\"ne\"]::before {\n    border-left: none;\n    border-bottom: none;\n  }\n  .handle[data-corner=\"sw\"]::before {\n    border-right: none;\n    border-top: none;\n  }\n  .handle[data-corner=\"se\"]::before {\n    border-left: none;\n    border-top: none;\n  }\n  .failed {\n    position: absolute;\n    inset: 0;\n    display: grid;\n    place-content: center;\n    justify-items: center;\n    gap: var(--pui-space-2);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-sm);\n  }\n  .failed :where(svg) {\n    width: var(--pui-icon-lg);\n    height: var(--pui-icon-lg);\n  }\n\n  .toolbar {\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-2);\n  }\n  .slider {\n    min-width: 0;\n    flex: 1;\n  }\n}\n"},{"path":"beamline/components/image-cropper/image-cropper.tsx","type":"registry:file","target":"src/ui/beamline/components/image-cropper/image-cropper.tsx","content":"\"use client\";\n// organism-allow-file: drag-copy -- the stage pans with one pointer and pinches with two; useDrag follows one, so the stage keeps its own until a pinch engine exists (its corners are on useDrag)\n\nimport { createTranslator, type Translator } from \"../../runtime/messages\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type Ref,\n} from \"react\";\nimport { ImageOff, RotateCw, Undo2, ZoomIn, ZoomOut } from \"lucide-react\";\nimport { Button } from \"../button/button\";\nimport { Slider } from \"../slider/slider\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useDrag } from \"../../hooks/use-drag\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { clamp, cn } from \"../../lib/utils\";\nimport { useExpiring } from \"../../hooks/use-expiring\";\nimport styles from \"./image-cropper.module.css\";\n\n/** The crop, in the picture's own pixels after rotation, with the zoom (1 = just covers the window) and rotation. */\nexport interface CropValue {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n  zoom: number;\n  rotation: 0 | 90 | 180 | 270;\n}\n\nexport type ImageCropperClassNames = Partial<\n  Record<\"root\" | \"stage\" | \"image\" | \"window\" | \"grid\" | \"handle\" | \"toolbar\", string>\n>;\n\nexport interface ImageCropperProps {\n  src: string;\n  alt?: string;\n  aspect?: number | \"free\";\n  shape?: \"rect\" | \"round\";\n  value?: CropValue;\n  defaultValue?: CropValue;\n  onValueChange?: (value: CropValue) => void;\n  onValueCommit?: (value: CropValue) => void;\n  minZoom?: number;\n  maxZoom?: number;\n  toolbar?: boolean;\n  disabled?: boolean;\n  className?: string;\n  classNames?: ImageCropperClassNames;\n  style?: CSSProperties;\n  ref?: Ref<HTMLDivElement>;\n}\n\ntype Size = { w: number; h: number };\ntype Rect = { x: number; y: number; w: number; h: number };\nconst PAD = 20;\n\n/**\n * Frame a picture before it is saved: a window of a fixed shape (or free, with corner handles) over the picture, which\n * moves under it by drag, wheel, pinch, the zoom slider and the keyboard, turns a quarter at a time, and always covers\n * the window. Reports the crop in the picture's own pixels; cropImage draws it into a file.\n */\ntype Corner = \"nw\" | \"ne\" | \"sw\" | \"se\";\n\nexport function ImageCropper({\n  src,\n  alt = \"\",\n  aspect: aspectProp = 1,\n  shape = \"rect\",\n  value,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  minZoom = 1,\n  maxZoom = 4,\n  toolbar = true,\n  disabled,\n  className,\n  classNames,\n  style,\n  ref,\n}: ImageCropperProps) {\n  const i18n = useMessages(\"image-cropper\", englishMessages);\n\n  const aspect = shape === \"round\" ? 1 : aspectProp;\n  const reduced = useReducedMotionSafe();\n  const stageRef = useRef<HTMLDivElement>(null);\n  const root = useRef<HTMLDivElement>(null);\n  const attach = useComposedRef(root, ref);\n  const [stage, setStage] = useState<Size | null>(null);\n  const [natural, setNatural] = useState<Size | null>(null);\n  const [failed, setFailed] = useState(false);\n  const [zoom, setZoom] = useState(defaultValue?.zoom ?? 1);\n  const [rotation, setRotation] = useState<CropValue[\"rotation\"]>(defaultValue?.rotation ?? 0);\n  const [pan, setPan] = useState({ x: 0, y: 0 });\n  const [freeWindow, setFreeWindow] = useState<Rect | null>(null);\n  const [moving, setMoving] = useExpiring<true>(650);\n  const [easing, setEasing] = useState(false);\n  const [said, setSaid] = useState(\"\");\n\n  useLayoutEffect(() => {\n    const el = stageRef.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const measure = () => setStage({ w: el.clientWidth, h: el.clientHeight });\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  useEffect(() => {\n    setNatural(null);\n    setFailed(false);\n  }, [src]);\n\n  const rotated: Size | null = natural ? (rotation % 180 ? { w: natural.h, h: natural.w } : natural) : null;\n  // the window: centred at its aspect, or the person's own rectangle when free\n  const win: Rect | null = (() => {\n    if (!stage) return null;\n    if (aspect === \"free\") {\n      if (freeWindow) return freeWindow;\n      const w = (stage.w - PAD * 2) * 0.8,\n        h = (stage.h - PAD * 2) * 0.8;\n      return { x: (stage.w - w) / 2, y: (stage.h - h) / 2, w, h };\n    }\n    const w = Math.min(stage.w - PAD * 2, (stage.h - PAD * 2) * aspect);\n    const h = w / aspect;\n    return { x: (stage.w - w) / 2, y: (stage.h - h) / 2, w, h };\n  })();\n  const baseScale = (r: Rect, size: Size) => Math.max(r.w / size.w, r.h / size.h);\n  const scale = win && rotated ? baseScale(win, rotated) * zoom : 1;\n  const limitPan = (p: { x: number; y: number }, s: number, r: Rect | null = win, size: Size | null = rotated) => {\n    if (!r || !size) return p;\n    const mx = Math.max(0, (size.w * s - r.w) / 2),\n      my = Math.max(0, (size.h * s - r.h) / 2);\n    return { x: clamp(p.x, -mx, mx), y: clamp(p.y, -my, my) };\n  };\n  const centre = win ? { x: win.x + win.w / 2, y: win.y + win.h / 2 } : { x: 0, y: 0 };\n  const cImg = { x: centre.x + pan.x, y: centre.y + pan.y };\n\n  const valueOf = (z = zoom, p = pan, rot = rotation, r = win): CropValue | null => {\n    if (!r || !natural) return null;\n    const size = rot % 180 ? { w: natural.h, h: natural.w } : natural;\n    const s = baseScale(r, size) * z;\n    const left = r.x + r.w / 2 + p.x - (size.w * s) / 2;\n    const top = r.y + r.h / 2 + p.y - (size.h * s) / 2;\n    return {\n      x: Math.round((r.x - left) / s),\n      y: Math.round((r.y - top) / s),\n      width: Math.round(r.w / s),\n      height: Math.round(r.h / s),\n      zoom: +z.toFixed(3),\n      rotation: rot,\n    };\n  };\n  const report = (z = zoom, p = pan, rot = rotation, r = win, commit = false) => {\n    const v = valueOf(z, p, rot, r);\n    if (!v) return;\n    onValueChange?.(v);\n    if (commit) onValueCommit?.(v);\n  };\n  // a value given from outside (a saved crop) places the picture: zoom, rotation, and the pan that puts x, y at the\n  // window's corner\n  const placed = useRef<CropValue | undefined>(undefined);\n  const wanted = value ?? (placed.current ? undefined : defaultValue);\n  useLayoutEffect(() => {\n    if (!wanted || !win || !natural || wanted === placed.current) return;\n    placed.current = wanted;\n    const size = wanted.rotation % 180 ? { w: natural.h, h: natural.w } : natural;\n    const s = baseScale(win, size) * wanted.zoom;\n    const left = win.x - wanted.x * s,\n      top = win.y - wanted.y * s;\n    setZoom(wanted.zoom);\n    setRotation(wanted.rotation);\n    setPan(limitPan({ x: left + (size.w * s) / 2 - centre.x, y: top + (size.h * s) / 2 - centre.y }, s, win, size));\n  }, [wanted, natural, stage, aspect, freeWindow]); // eslint-disable-line react-hooks/exhaustive-deps -- the rest derives from these\n  // a new picture or window reports its first crop\n  useEffect(() => {\n    if (natural && win && !value) report(zoom, pan, rotation, win, true);\n  }, [natural, stage?.w, stage?.h]); // eslint-disable-line react-hooks/exhaustive-deps -- once per picture and room\n\n  /** Zoom to `z` keeping the picture point under `at` (stage px) where it is. */\n  const zoomTo = (z: number, at = centre, eased = false, commit = false) => {\n    if (!win || !rotated) return;\n    const nz = clamp(z, minZoom, maxZoom);\n    const s0 = baseScale(win, rotated);\n    const k = (s0 * nz) / (s0 * zoom);\n    const next = limitPan(\n      { x: (cImg.x - at.x) * k + at.x - centre.x, y: (cImg.y - at.y) * k + at.y - centre.y },\n      s0 * nz,\n    );\n    setEasing(eased && !reduced);\n    setZoom(nz);\n    setPan(next);\n    report(nz, next, rotation, win, commit);\n  };\n  const rotate = () => {\n    const r = ((rotation + 90) % 360) as CropValue[\"rotation\"];\n    const size = natural ? (r % 180 ? { w: natural.h, h: natural.w } : natural) : null;\n    const s = win && size ? baseScale(win, size) * zoom : 1;\n    const next = limitPan({ x: -pan.y, y: pan.x }, s, win, size);\n    setEasing(!reduced);\n    setRotation(r);\n    setPan(next);\n    report(zoom, next, r, win, true);\n    return r;\n  };\n  const reset = () => {\n    setEasing(!reduced);\n    setZoom(1);\n    setRotation(0);\n    setPan({ x: 0, y: 0 });\n    setFreeWindow(null);\n    report(1, { x: 0, y: 0 }, 0, win, true);\n  };\n\n  // the thirds show while it moves and fade a moment after\n  const nudge = () => setMoving(true);\n\n  // pointers: one drags, two pinch\n  const pointers = useRef(new Map<number, { x: number; y: number }>());\n  // a corner being dragged: which, how far the hand had moved at the last step, and what to put back on a cancel\n  const cornerStart = useRef<{\n    corner: Corner;\n    dx: number;\n    dy: number;\n    before: { freeWindow: Rect | null; zoom: number; pan: { x: number; y: number }; win: Rect | null };\n  } | null>(null);\n  const pinch = useRef<{ d: number; z: number } | null>(null);\n  const local = (e: { clientX: number; clientY: number }) => {\n    const b = stageRef.current!.getBoundingClientRect();\n    return { x: e.clientX - b.left, y: e.clientY - b.top };\n  };\n  const onDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (disabled || !natural || (e.target as HTMLElement).closest(\"[data-slot=image-cropper-handle]\")) return;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    pointers.current.set(e.pointerId, local(e));\n    setEasing(false);\n    if (pointers.current.size === 2) {\n      const [a, b] = [...pointers.current.values()];\n      pinch.current = { d: Math.hypot(a.x - b.x, a.y - b.y), z: zoom };\n    }\n  };\n  const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const was = pointers.current.get(e.pointerId);\n    if (!was) return;\n    const now = local(e);\n    pointers.current.set(e.pointerId, now);\n    nudge();\n    if (pointers.current.size >= 2 && pinch.current) {\n      const [a, b] = [...pointers.current.values()];\n      const d = Math.hypot(a.x - b.x, a.y - b.y);\n      zoomTo((pinch.current.z * d) / pinch.current.d, { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });\n      return;\n    }\n    const next = limitPan({ x: pan.x + now.x - was.x, y: pan.y + now.y - was.y }, scale);\n    setPan(next);\n    report(zoom, next);\n  };\n  const onUp = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.delete(e.pointerId)) return;\n    if (pointers.current.size < 2) pinch.current = null;\n    if (pointers.current.size === 0) report(zoom, pan, rotation, win, true);\n  };\n  // the wheel zooms about the pointer (a page scroll is not taken: only while the pointer is over the stage)\n  useEffect(() => {\n    const el = stageRef.current;\n    if (!el) return;\n    const onWheel = (e: WheelEvent) => {\n      if (disabled || !natural) return;\n      e.preventDefault();\n      setMoving(true);\n      zoomRef.current(zoomValue.current * Math.exp(-e.deltaY * 0.0016), local(e));\n    };\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => el.removeEventListener(\"wheel\", onWheel);\n  }, [disabled, natural, setMoving]); // reads the latest through refs\n  const zoomRef = useRef(zoomTo);\n  zoomRef.current = zoomTo;\n  const zoomValue = useRef(zoom);\n  zoomValue.current = zoom;\n\n  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (disabled || !natural) return;\n    const step = e.shiftKey ? 50 : 10;\n    const move = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }[e.key];\n    if (move) {\n      e.preventDefault();\n      // the keys move the window over the picture, the way a person reads the arrow\n      const next = limitPan({ x: pan.x + move[0], y: pan.y + move[1] }, scale);\n      setEasing(false);\n      setPan(next);\n      nudge();\n      report(zoom, next, rotation, win, true);\n    } else if (e.key === \"+\" || e.key === \"=\") {\n      e.preventDefault();\n      zoomTo(zoom + 0.1, centre, true, true);\n      setSaid(i18n(\"zoom2\", { value: Math.round(clamp(zoom + 0.1, minZoom, maxZoom) * 100) }));\n    } else if (e.key === \"-\" || e.key === \"_\") {\n      e.preventDefault();\n      zoomTo(zoom - 0.1, centre, true, true);\n      setSaid(i18n(\"zoom2\", { value: Math.round(clamp(zoom - 0.1, minZoom, maxZoom) * 100) }));\n    } else if (e.key === \"r\" || e.key === \"R\") {\n      e.preventDefault();\n      setSaid(i18n(\"turnedToDegrees\", { value: rotate() }));\n    } else if (e.key === \"0\") {\n      e.preventDefault();\n      reset();\n      setSaid(i18n(\"reset\"));\n    }\n  };\n\n  // free aspect: each corner resizes the window; the picture stays put and the window stays on it\n  const resize = (corner: Corner, dx: number, dy: number, commit = false) => {\n    if (!win || !rotated || !stage) return;\n    const imgW = rotated.w * scale,\n      imgH = rotated.h * scale;\n    const bound = {\n      l: Math.max(PAD / 2, cImg.x - imgW / 2),\n      t: Math.max(PAD / 2, cImg.y - imgH / 2),\n      r: Math.min(stage.w - PAD / 2, cImg.x + imgW / 2),\n      b: Math.min(stage.h - PAD / 2, cImg.y + imgH / 2),\n    };\n    let { x, y, w, h } = win;\n    const min = 48;\n    if (corner.includes(\"w\")) {\n      const nx = clamp(x + dx, bound.l, x + w - min);\n      w += x - nx;\n      x = nx;\n    } else w = clamp(w + dx, min, bound.r - x);\n    if (corner.includes(\"n\")) {\n      const ny = clamp(y + dy, bound.t, y + h - min);\n      h += y - ny;\n      y = ny;\n    } else h = clamp(h + dy, min, bound.b - y);\n    const next = { x, y, w, h };\n    // keep the picture where it is: the same scale and centre, expressed against the new window\n    const s0 = baseScale(next, rotated);\n    const z = scale / s0;\n    const p = { x: cImg.x - (x + w / 2), y: cImg.y - (y + h / 2) };\n    setEasing(false);\n    setFreeWindow(next);\n    setZoom(z);\n    setPan(p);\n    nudge();\n    report(z, p, rotation, next, commit);\n  };\n  const handle = (corner: Corner) => {\n    return {\n      \"data-slot\": \"image-cropper-handle\",\n      \"data-corner\": corner,\n      \"aria-label\": i18n(\"resizeFromTheCorner\", {\n        value: { nw: i18n(\"topLeft\"), ne: i18n(\"topRight\"), sw: i18n(\"bottomLeft\"), se: i18n(\"bottomRight\") }[corner],\n      }),\n      className: cn(styles.handle, classNames?.handle),\n      onKeyDown: (e: KeyboardEvent<HTMLButtonElement>) => {\n        const s = e.shiftKey ? 20 : 4;\n        const d = { ArrowLeft: [-s, 0], ArrowRight: [s, 0], ArrowUp: [0, -s], ArrowDown: [0, s] }[e.key];\n        if (!d) return;\n        e.preventDefault();\n        e.stopPropagation();\n        resize(corner, d[0], d[1], true);\n      },\n    };\n  };\n\n  // the corners on the shared drag (the stage's own handler leaves a press on a corner alone): the window follows the\n  // hand, a release commits it, and Escape or a lost pointer puts back the window, zoom and pan from before the press\n  useDrag(stageRef, {\n    onPress: (_p, e) => {\n      const corner = (e.target as Element).closest<HTMLElement>(\"[data-slot=image-cropper-handle]\")?.dataset.corner;\n      if (disabled || !corner) return false;\n      cornerStart.current = { corner: corner as Corner, dx: 0, dy: 0, before: { freeWindow, zoom, pan, win } };\n    },\n    onMove: (p) => {\n      const c = cornerStart.current;\n      if (!c) return;\n      resize(c.corner, p.dx - c.dx, p.dy - c.dy);\n      c.dx = p.dx;\n      c.dy = p.dy;\n    },\n    onEnd: () => {\n      cornerStart.current = null;\n      report(zoom, pan, rotation, win, true);\n    },\n    onTap: () => {\n      cornerStart.current = null;\n    },\n    onCancel: () => {\n      const c = cornerStart.current;\n      cornerStart.current = null;\n      if (!c) return;\n      setFreeWindow(c.before.freeWindow);\n      setZoom(c.before.zoom);\n      setPan(c.before.pan);\n      report(c.before.zoom, c.before.pan, rotation, c.before.win);\n    },\n    onAbandon: () => {\n      cornerStart.current = null;\n    },\n  });\n\n  const ready = !!(natural && win && stage);\n  const ratio = aspect === \"free\" ? 4 / 3 : Math.max(aspect, 4 / 3);\n  return (\n    <div\n      ref={attach}\n      data-slot=\"image-cropper\"\n      data-shape={shape}\n      data-disabled={disabled ? \"\" : undefined}\n      style={style}\n      className={cn(styles.root, classNames?.root, className)}\n    >\n      <div\n        ref={stageRef}\n        data-slot=\"image-cropper-stage\"\n        role=\"group\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={i18n(\"cropArrowKeysMoveItPlusAnd\", { value: alt || \"picture\" })}\n        aria-disabled={disabled || undefined}\n        className={cn(styles.stage, classNames?.stage)}\n        style={{ aspectRatio: `${ratio}` }}\n        onPointerDown={onDown}\n        onPointerMove={onMove}\n        onPointerUp={onUp}\n        onPointerCancel={onUp}\n        onKeyDown={onKey}\n      >\n        {failed ? (\n          <p className={styles.failed}>\n            <ImageOff aria-hidden />\n            {i18n(\"thisPictureCouldNotBeLoaded\")}\n          </p>\n        ) : (\n          // the picture: placed by one transform (centre, rotation, scale); eased only for the slider, Rotate and Reset\n          <img\n            src={src}\n            alt=\"\"\n            draggable={false}\n            data-slot=\"image-cropper-image\"\n            data-easing={easing ? \"\" : undefined}\n            className={cn(styles.image, classNames?.image)}\n            onLoad={(e) => setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}\n            onError={() => setFailed(true)}\n            onTransitionEnd={() => setEasing(false)}\n            style={\n              natural\n                ? {\n                    width: natural.w,\n                    height: natural.h,\n                    transform: `translate(${cImg.x - natural.w / 2}px, ${cImg.y - natural.h / 2}px) rotate(${rotation}deg) scale(${scale})`,\n                  }\n                : { visibility: \"hidden\" }\n            }\n          />\n        )}\n        {ready && (\n          <div\n            aria-hidden={aspect !== \"free\"}\n            data-slot=\"image-cropper-window\"\n            data-moving={moving ? \"\" : undefined}\n            className={cn(styles.window, classNames?.window)}\n            style={{ transform: `translate(${win!.x}px, ${win!.y}px)`, width: win!.w, height: win!.h }}\n          >\n            <span aria-hidden data-slot=\"image-cropper-grid\" className={cn(styles.grid, classNames?.grid)} />\n            {aspect === \"free\" &&\n              ([\"nw\", \"ne\", \"sw\", \"se\"] as const).map((c) => (\n                <button\n                  /* organism-allow: raw-control -- a crop corner's drag handle */ key={c}\n                  type=\"button\"\n                  {...handle(c)}\n                />\n              ))}\n          </div>\n        )}\n        <span role=\"status\" className=\"sr-only\">\n          {said}\n        </span>\n      </div>\n      {toolbar && (\n        <div data-slot=\"image-cropper-toolbar\" className={cn(styles.toolbar, classNames?.toolbar)}>\n          <Slider\n            label={i18n(\"zoom\")}\n            hideLabel\n            min={minZoom}\n            max={maxZoom}\n            step={0.01}\n            value={zoom}\n            disabled={disabled || !ready}\n            format={(v) => i18n.number(v, { style: \"percent\", maximumFractionDigits: 0 })}\n            onValueChange={(v) => zoomTo(v as number, centre, true)}\n            onValueCommit={(v) => report(v as number, pan, rotation, win, true)}\n            start={<ZoomOut aria-hidden />}\n            end={<ZoomIn aria-hidden />}\n            className={styles.slider}\n          />\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            disabled={disabled || !ready}\n            onClick={() => rotate()}\n            aria-label={i18n(\"rotateAQuarterTurn\")}\n          >\n            <RotateCw />\n          </Button>\n          <Button variant=\"ghost\" size=\"sm\" disabled={disabled || !ready} onClick={reset} aria-label={i18n(\"reset\")}>\n            <Undo2 />\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** Draws a crop into a file: `size` is the longest side in pixels (default the crop's own), `round` clips a circle. */\nexport async function cropImage(\n  src: string,\n  crop: CropValue,\n  {\n    size,\n    type = \"image/png\",\n    quality = 0.92,\n    round = false,\n    translator = createTranslator(\"image-cropper\", englishMessages),\n  }: {\n    size?: number;\n    type?: string;\n    quality?: number;\n    round?: boolean;\n    translator?: Translator<typeof englishMessages>;\n  } = {},\n): Promise<Blob> {\n  const img = new Image();\n  img.crossOrigin = \"anonymous\";\n  img.src = src;\n  await img.decode();\n  const k = size ? size / Math.max(crop.width, crop.height) : 1;\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = Math.round(crop.width * k);\n  canvas.height = Math.round(crop.height * k);\n  const ctx = canvas.getContext(\"2d\")!;\n  if (round) {\n    ctx.beginPath();\n    ctx.arc(canvas.width / 2, canvas.height / 2, Math.min(canvas.width, canvas.height) / 2, 0, Math.PI * 2);\n    ctx.clip();\n  }\n  const turned = crop.rotation % 180 !== 0;\n  const rw = turned ? img.naturalHeight : img.naturalWidth,\n    rh = turned ? img.naturalWidth : img.naturalHeight;\n  ctx.scale(k, k);\n  ctx.translate(-crop.x, -crop.y);\n  ctx.translate(rw / 2, rh / 2);\n  ctx.rotate((crop.rotation * Math.PI) / 180);\n  ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2);\n  return new Promise((resolve, reject) =>\n    canvas.toBlob((b) => (b ? resolve(b) : reject(new Error(translator(\"cropFailed\")))), type, quality),\n  );\n}\n\nexport default ImageCropper;\n"},{"path":"beamline/components/image-cropper/messages.ts","type":"registry:file","target":"src/ui/beamline/components/image-cropper/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  cropFailed: \"The crop could not be drawn\",\n  resizeFromTheCorner: \"Resize from the {value} corner\",\n  cropArrowKeysMoveItPlusAnd: \"Crop {value}. Arrow keys move it, plus and minus zoom, R turns it, 0 resets.\",\n  thisPictureCouldNotBeLoaded: \"This picture could not be loaded.\",\n  zoom: \"Zoom\",\n  rotateAQuarterTurn: \"Rotate a quarter turn\",\n  reset: \"Reset\",\n  zoom2: \"Zoom {value}%\",\n  turnedToDegrees: \"Turned to {value} degrees\",\n  topLeft: \"top left\",\n  topRight: \"top right\",\n  bottomLeft: \"bottom left\",\n  bottomRight: \"bottom right\",\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/slider/messages.ts","type":"registry:file","target":"src/ui/beamline/components/slider/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English defaults; instance labels take precedence. */\nexport const english = {\n  minimum: \"Minimum\",\n  maximum: \"Maximum\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/slider/slider.module.css","type":"registry:file","target":"src/ui/beamline/components/slider/slider.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Slider: a well (the inset role) with the chosen span filled as a solid and a knob per value. The knob grows a\n     little under the pointer and more while dragged; its value floats above it while dragged or keyboard-focused. */\n  .control {\n    position: relative;\n    display: flex;\n    width: 100%;\n    height: 20px;\n    align-items: center;\n    touch-action: none;\n    user-select: none;\n  }\n  .track {\n    min-width: 0;\n    --m-inset-fill: var(--pui-control-track);\n    position: relative;\n    flex-grow: 1;\n    height: 6px;\n    overflow: hidden;\n    border-radius: var(--pui-radius-pill);\n  }\n  .range {\n    --m-color: var(--pui-accent);\n    position: absolute;\n    height: 100%;\n    border-radius: var(--pui-radius-pill);\n  }\n  .thumb {\n    position: relative;\n    display: block;\n    width: 16px;\n    height: 16px;\n    border-radius: var(--pui-radius-pill);\n    transition:\n      scale var(--pui-duration-fast) var(--pui-ease-default),\n      box-shadow var(--pui-duration-fast) var(--pui-ease-default);\n  }\n  .thumb:hover {\n    scale: 1.1;\n  }\n  .thumb:active {\n    scale: 1.25;\n    transition: none;\n  }\n  /* a keyboard step (and a mark pressed) glides the knob and the fill to the new value over --pui-duration-fast;\n     under the pointer they follow the finger at once (Radix places the knob's wrapper and the fill with left/right) */\n  .control:not([data-live]) > span:has(> .thumb),\n  .control:not([data-live]) .range {\n    transition:\n      left var(--pui-duration-fast) var(--pui-ease-standard),\n      right var(--pui-duration-fast) var(--pui-ease-standard);\n  }\n  .thumb:focus-visible {\n    outline: none;\n    box-shadow:\n      var(--m-knob-edge),\n      var(--pui-control-thumb-shadow),\n      0 0 0 4px var(--pui-focus-halo);\n  }\n  .thumb[data-disabled] {\n    pointer-events: none;\n  }\n  .value {\n    min-width: 0;\n    font-family: var(--pui-font-numeric);\n    font-size: var(--pui-text-xs);\n    white-space: normal;\n    overflow-wrap: anywhere;\n    direction: ltr;\n    unicode-bidi: isolate;\n  }\n  .valueOverlay {\n    pointer-events: none;\n  }\n  /* ticks sit where the thumb centre lands: the knob stays inside the track, so its centre travels from half a\n   thumb in at one end to half a thumb in at the other */\n  .marks {\n    container-type: inline-size;\n    position: relative;\n    min-height: 16px;\n    height: auto;\n    display: grid;\n    margin-top: var(--pui-space-1);\n    margin-inline: var(--pui-space-2);\n  }\n  .mark {\n    position: absolute;\n    display: grid;\n    width: 0;\n    justify-items: start;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-2xs);\n    direction: ltr;\n    unicode-bidi: isolate; /* \"50×\" stays one figure in a right-to-left page */\n    cursor: pointer;\n    transition: color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .mark:hover {\n    color: var(--pui-ink);\n    transition: none;\n  }\n  /* a label folded for room: its tick stays, the words fade (and take no pointer) */\n  .markLabel {\n    width: max-content;\n    max-inline-size: 45cqi;\n    white-space: normal;\n    overflow-wrap: anywhere;\n    transition: opacity var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .mark[data-folded] {\n    pointer-events: none;\n  }\n  .mark[data-folded] .markLabel {\n    opacity: 0;\n    transition-duration: var(--pui-motion-out);\n  }\n  .readout {\n    min-width: 0;\n    max-width: 50%;\n    flex: 0 1 50%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    text-align: end;\n    color: var(--pui-ink-2);\n    font-family: var(--pui-font-numeric);\n    font-size: var(--pui-text-xs);\n    font-variant-numeric: tabular-nums;\n    direction: ltr;\n    unicode-bidi: isolate;\n  }\n  .tick {\n    display: block;\n    width: 1px;\n    height: 4px;\n    margin: 0 0 var(--pui-space-0-5);\n    background: var(--pui-line-strong);\n  }\n  /* layout: label over the track; spacing reads the scale so the Spacing parameter reaches it */\n  .root {\n    min-width: 0;\n    max-width: 100%;\n    grid-template-columns: minmax(0, 1fr);\n    display: grid;\n    gap: var(--pui-space-2);\n  }\n  .label {\n    min-width: 0;\n    overflow-wrap: anywhere;\n  }\n  .adornment {\n    min-width: 0;\n    max-width: 25%;\n    overflow-wrap: anywhere;\n  }\n  .trackWrap {\n    position: relative;\n    flex: 1;\n    min-width: 0;\n    container-type: inline-size;\n  }\n  .head {\n    min-width: 0;\n    flex-wrap: wrap;\n    display: flex;\n    align-items: baseline;\n    justify-content: space-between;\n    gap: var(--pui-space-3);\n  }\n  .row {\n    min-width: 0;\n    padding-inline: var(--pui-space-1);\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-3);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.track {\n  composes: m-inset from global;\n}\n.range {\n  composes: m-solid from global;\n}\n.thumb {\n  composes: m-knob from global;\n}\n"},{"path":"beamline/components/slider/slider.tsx","type":"registry:file","target":"src/ui/beamline/components/slider/slider.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useDeprecatedProp } from \"../../hooks/use-deprecated-prop\";\n\nimport { useId, useLayoutEffect, useRef, useState, type CSSProperties, type Ref, type ReactNode } from \"react\";\nimport * as RadixSlider from \"@radix-ui/react-slider\";\nimport { useScopeDirection } from \"../../hooks/use-scope-direction\";\nimport { cn } from \"../../lib/utils\";\nimport { fitLabels } from \"../../lib/axis\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport { Tooltip } from \"../tooltip/tooltip\";\nimport { FieldMessage } from \"../_shared/field\";\nimport styles from \"./slider.module.css\";\n\nexport interface SliderMark {\n  /** Numeric value at which the labelled tick appears. Required. */\n  value: number;\n  /** Text displayed for this item. Omitted by default. */\n  label?: ReactNode;\n}\n\nexport type SliderClassNames = Partial<\n  Record<\n    | \"root\"\n    | \"head\"\n    | \"label\"\n    | \"readout\"\n    | \"row\"\n    | \"start\"\n    | \"end\"\n    | \"control\"\n    | \"track\"\n    | \"range\"\n    | \"thumb\"\n    | \"value\"\n    | \"marks\"\n    | \"mark\"\n    | \"tick\"\n    | \"description\"\n    | \"error\",\n    string\n  >\n>;\n\nexport interface SliderProps<T extends number | [number, number]> {\n  /** Visible label or accessible name for the control. Required. */\n  label: ReactNode;\n  /** Shows the label visually while keeping its accessible name; defaults to true. Takes precedence over hideLabel. */\n  showLabel?: boolean;\n  /** Hides the label; defaults to false. @deprecated Use showLabel={false} instead. */\n  hideLabel?: boolean;\n  /** One line of help under the track, like every field's description. */\n  description?: ReactNode;\n  /** The problem and its fix; replaces the description and marks the slider invalid. */\n  error?: ReactNode;\n  /** One number, or [low, high] for a range. */\n  value?: T;\n  /** Initial number or range when uncontrolled; defaults to min for a single thumb. */\n  defaultValue?: T;\n  /** Reports number or two-number range after an edit; no callback by default. */\n  onValueChange?: (value: T) => void;\n  /** When the pointer lets go or a key is released: save here, not on every move. */\n  onValueCommit?: (value: T) => void;\n  /** Minimum permitted value. Defaults to 0. */\n  min?: number;\n  /** Maximum permitted value. Defaults to 100. */\n  max?: number;\n  /** Increment used by the control. Defaults to 1. */\n  step?: number;\n  /** Ticks under the track; numbers, or { value, label }. */\n  marks?: (number | SliderMark)[];\n  /** For a range: the least gap between the thumbs, in steps. */\n  minStepsBetweenThumbs?: number;\n  /** Formats a raw numeric value for display. Defaults to plain number text. */\n  format?: (value: number) => string;\n  /** The current value beside the label. */\n  showValue?: boolean;\n  /** Names of the two thumbs of a range. Default \"Minimum\", \"Maximum\". */\n  thumbLabels?: [string, string];\n  /** Icons or words at the track's ends (a quiet and a loud speaker). */\n  start?: ReactNode;\n  /** Icon or text after the slider track; omitted by default to show no end adornment. */\n  end?: ReactNode;\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  /** 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<HTMLSpanElement>;\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?: SliderClassNames;\n}\n\n/**\n * A value or range on a known scale. The thumb grows under the finger and shows its value while hovered, dragged or\n * focused; arrows step (the thumb glides to each step), Page Up/Down take ten steps, Home and End go to the ends. A\n * mark's label is a shortcut: pressing it moves the nearest thumb there. In a narrow room the mark labels that would\n * touch fold away (the ends stay), and come back as the room grows.\n */\nexport function Slider<T extends number | [number, number]>({\n  label,\n  hideLabel: hideLabelProp,\n  showLabel,\n  description,\n  error,\n  value,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  min = 0,\n  max = 100,\n  step = 1,\n  marks,\n  minStepsBetweenThumbs,\n  format = String,\n  showValue,\n  thumbLabels: thumbLabelsInput,\n  start,\n  end,\n  name,\n  disabled,\n  className,\n  style,\n  ref,\n  dir: dirProp,\n  classNames,\n}: SliderProps<T>) {\n  const i18n = useMessages(\"slider\", englishMessages);\n  const thumbLabels: [string, string] =\n    thumbLabelsInput === undefined ? [i18n(\"minimum\"), i18n(\"maximum\")] : thumbLabelsInput;\n\n  useDeprecatedProp(\"Slider\", \"hideLabel\", \"showLabel={false}\", hideLabelProp !== undefined);\n  const hideLabel = showLabel === undefined ? hideLabelProp : !showLabel;\n  const toArr = (v: T | undefined): number[] | undefined =>\n    v === undefined ? undefined : Array.isArray(v) ? [...v] : [v as number];\n  const range = Array.isArray(value ?? defaultValue);\n  // the thumbs are held as a list and reported in the caller's shape (one number, or a pair), so the report is beside\n  const [current, setCurrent] = useControllable<number[]>(toArr(value), toArr(defaultValue) ?? [min]);\n  const out = (a: number[]) => (range ? [a[0], a[1]] : a[0]) as T;\n  const labelId = useId();\n  const messageId = error || description ? `${labelId}-message` : undefined;\n  // the marks lie along the track Radix lays out, which follows the direction context (not the page's dir)\n  const [controlRef, dir] = useScopeDirection(ref, dirProp);\n  const [live, setLive] = useState(false);\n  const [focused, setFocused] = useState<number | null>(null);\n  const [hovered, setHovered] = useState<number | null>(null);\n  const [keyboardFocus, setKeyboardFocus] = useState(false);\n  const pct = (v: number) => ((v - min) / (max - min)) * 100;\n  const markList = (marks ?? []).map((m) => (typeof m === \"number\" ? { value: m, label: format(m) } : m));\n  const set = (a: number[], commit = false) => {\n    setCurrent(a);\n    onValueChange?.(out(a));\n    if (commit) onValueCommit?.(out(a));\n  };\n  // a mark's label moves the nearest thumb to it (a range keeps its order)\n  const jump = (to: number) => {\n    if (disabled) return;\n    if (!range) return set([to], true);\n    const near = Math.abs(current[0] - to) <= Math.abs(current[1] - to) ? 0 : 1;\n    const next = [...current];\n    next[near] = to;\n    set(next[0] <= next[1] ? next : [next[1], next[0]], true);\n  };\n  // The same ResizeObserver tracks room and label size changes (including text scaling). Labels are clamped\n  // inside the track, while each tick stays at its real value; nothing measures at rest.\n  const marksRef = useRef<HTMLDivElement>(null);\n  const marksKey = markList.map((m) => `${m.value}:${typeof m.label === \"string\" ? m.label : \"\"}`).join(\"|\");\n  const [shownMarks, setShownMarks] = useState<boolean[] | null>(null);\n  useLayoutEffect(() => {\n    const el = marksRef.current;\n    if (!el || !markList.length || typeof ResizeObserver === \"undefined\") return;\n    const at = markList.map((m) => pct(m.value));\n    const layout = () => {\n      const labels = [...el.querySelectorAll<HTMLElement>(\"[data-slot=slider-mark-label]\")];\n      const widths = labels.map((label) => label.offsetWidth);\n      const room = el.clientWidth;\n      const keep = fitLabels(at, widths, room, 6);\n      labels.forEach((label, index) => {\n        const x = ((dir === \"rtl\" ? 100 - at[index] : at[index]) / 100) * room;\n        label.style.transform = `translateX(${Math.max(0, Math.min(room - widths[index], x - widths[index] / 2)) - x}px)`;\n      });\n      el.style.minHeight = `${Math.max(16, ...labels.map((label, index) => (keep[index] ? label.parentElement!.offsetHeight : 0)))}px`;\n      setShownMarks((prev) => (prev && prev.join() === keep.join() ? prev : keep));\n    };\n    layout();\n    const observer = new ResizeObserver(layout);\n    observer.observe(el);\n    el.querySelectorAll(\"[data-slot=slider-mark-label]\").forEach((label) => observer.observe(label));\n    return () => observer.disconnect();\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- marksKey names the marks; min/max move them\n  }, [marksKey, min, max, dir]);\n  const text = range ? `${format(current[0])} – ${format(current[1])}` : format(current[0]);\n  return (\n    <div\n      data-slot=\"slider\"\n      data-invalid={error ? \"\" : undefined}\n      dir={dirProp}\n      style={style}\n      className={cn(styles.root, disabled && \"opacity-[var(--pui-opacity-disabled)]\", classNames?.root, className)}\n    >\n      <div data-slot=\"slider-head\" className={cn(styles.head, hideLabel && \"sr-only\", classNames?.head)}>\n        <span\n          id={labelId}\n          data-slot=\"slider-label\"\n          className={cn(styles.label, \"text-sm font-medium text-ink\", classNames?.label)}\n        >\n          {label}\n        </span>\n        {showValue && (\n          <span title={text} data-slot=\"slider-readout\" className={cn(styles.readout, classNames?.readout)}>\n            {text}\n          </span>\n        )}\n      </div>\n      <div data-slot=\"slider-row\" className={cn(styles.row, classNames?.row)}>\n        {start && (\n          <span\n            data-slot=\"slider-start\"\n            className={cn(styles.adornment, \"text-ink-3 [&_svg]:size-4\", classNames?.start)}\n          >\n            {start}\n          </span>\n        )}\n        <div className={styles.trackWrap}>\n          <RadixSlider.Root\n            ref={controlRef}\n            dir={dir}\n            data-slot=\"slider-control\"\n            value={current}\n            min={min}\n            max={max}\n            step={step}\n            minStepsBetweenThumbs={minStepsBetweenThumbs}\n            name={name}\n            disabled={disabled}\n            aria-labelledby={labelId}\n            onValueChange={(a) => set(a)}\n            onValueCommit={(a) => onValueCommit?.(out(a))}\n            onKeyDown={() => setKeyboardFocus(true)}\n            onPointerDown={() => {\n              setKeyboardFocus(false);\n              setLive(true);\n            }}\n            onPointerUp={() => setLive(false)}\n            onLostPointerCapture={() => setLive(false)}\n            data-live={live ? \"\" : undefined}\n            className={cn(styles.control, classNames?.control)}\n          >\n            <RadixSlider.Track data-slot=\"slider-track\" className={cn(styles.track, classNames?.track)}>\n              <RadixSlider.Range data-slot=\"slider-range\" className={cn(styles.range, classNames?.range)} />\n            </RadixSlider.Track>\n            {current.map((v, i) => (\n              <Tooltip\n                key={i}\n                open={!disabled && ((focused === i && (keyboardFocus || live)) || hovered === i)}\n                content={\n                  <span aria-hidden data-slot=\"slider-value\" className={cn(styles.value, classNames?.value)}>\n                    {format(v)}\n                  </span>\n                }\n                className={styles.valueOverlay}\n              >\n                <RadixSlider.Thumb\n                  onFocus={(event) => {\n                    setFocused(i);\n                    setKeyboardFocus(event.currentTarget.matches(\":focus-visible\"));\n                  }}\n                  onBlur={() => setFocused(null)}\n                  onPointerEnter={() => setHovered(i)}\n                  onPointerLeave={() => setHovered(null)}\n                  aria-label={range ? thumbLabels[i] : undefined}\n                  aria-labelledby={range ? undefined : labelId}\n                  aria-valuetext={format(v)}\n                  aria-describedby={messageId}\n                  aria-invalid={error ? true : undefined}\n                  data-slot=\"slider-thumb\"\n                  className={cn(styles.thumb, classNames?.thumb)}\n                />\n              </Tooltip>\n            ))}\n          </RadixSlider.Root>\n          {markList.length > 0 && (\n            // the keyboard has the arrows, Home and End; the labels are a pointer shortcut, so they stay out of the\n            // accessibility tree like the ticks\n            <div\n              ref={marksRef}\n              aria-hidden\n              dir={dir}\n              data-slot=\"slider-marks\"\n              className={cn(styles.marks, classNames?.marks)}\n            >\n              {markList.map((mk, i) => (\n                <span\n                  key={mk.value}\n                  data-slot=\"slider-mark\"\n                  data-folded={shownMarks && !shownMarks[i] ? \"\" : undefined}\n                  className={cn(styles.mark, classNames?.mark)}\n                  style={{ left: `${dir === \"rtl\" ? 100 - pct(mk.value) : pct(mk.value)}%` }}\n                  onPointerDown={(e) => {\n                    if (e.button !== 0) return;\n                    e.preventDefault();\n                    jump(mk.value);\n                  }}\n                >\n                  <span data-slot=\"slider-tick\" className={cn(styles.tick, classNames?.tick)} />\n                  <span data-slot=\"slider-mark-label\" className={styles.markLabel}>\n                    {mk.label}\n                  </span>\n                </span>\n              ))}\n            </div>\n          )}\n        </div>\n        {end && (\n          <span data-slot=\"slider-end\" className={cn(styles.adornment, \"text-ink-3 [&_svg]:size-4\", classNames?.end)}>\n            {end}\n          </span>\n        )}\n      </div>\n      <FieldMessage\n        error={error}\n        description={description}\n        errorId={messageId}\n        descriptionId={messageId}\n        classNames={{ error: classNames?.error, description: classNames?.description }}\n      />\n    </div>\n  );\n}\n\nexport default Slider;\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/components/tooltip/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/tooltip/defaults.ts","content":"/** Pure allowed presentation defaults for Tooltip; no component or runtime imports. */\nexport interface TooltipDefaults {\n  /** Shared align setting; omit to inherit the surrounding scope or the component default. */\n  align?: \"start\" | \"center\" | \"end\";\n}\n"},{"path":"beamline/components/tooltip/tooltip.module.css","type":"registry:file","target":"src/ui/beamline/components/tooltip/tooltip.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A tooltip arrives from its control: it starts a step toward the control on the side it opens on and moves away\n     into place as it fades in (--pui-motion-in), so on any side it reads as coming out of what it explains. Inside a\n     group, the next one opens at once where the last one stood and travels to its own control (--pui-duration-fast),\n     while the last one goes in the arrival time: one tooltip moving along the toolbar, not two blinking. It leaves\n     over the departure fade. Reduced motion keeps the fades and drops the travel (--pui-motion-travel is 0). */\n  .tip {\n    --_dx: 0px;\n    --_dy: 0px;\n    --_tx: 0px;\n    --_ty: 0px;\n    z-index: var(--pui-z-tooltip);\n    transform-origin: var(--radix-tooltip-content-transform-origin);\n  }\n  .tip[data-side=\"top\"] {\n    --_dy: var(--pui-space-1);\n  }\n  .tip[data-side=\"bottom\"] {\n    --_dy: calc(-1 * var(--pui-space-1));\n  }\n  .tip[data-side=\"left\"] {\n    --_dx: var(--pui-space-1);\n  }\n  .tip[data-side=\"right\"] {\n    --_dx: calc(-1 * var(--pui-space-1));\n  }\n  .tip[data-state=\"delayed-open\"] {\n    animation: tip-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .tip[data-state=\"instant-open\"] {\n    animation: tip-fade var(--pui-fade-in) var(--pui-ease-default);\n  }\n  .tip[data-state=\"instant-open\"][data-travel] {\n    animation: tip-travel var(--pui-duration-fast) var(--pui-ease-standard);\n  }\n  .tip[data-state=\"closed\"] {\n    animation: tip-out var(--pui-fade-out) var(--pui-ease-default) forwards;\n  }\n  .tip[data-grouped][data-state=\"closed\"] {\n    animation-duration: var(--pui-fade-in);\n  }\n}\n@keyframes tip-in {\n  from {\n    opacity: 0;\n    translate: calc(var(--_dx) * var(--pui-motion-travel, 1)) calc(var(--_dy) * var(--pui-motion-travel, 1));\n  }\n}\n@keyframes tip-fade {\n  from {\n    opacity: 0;\n  }\n}\n@keyframes tip-travel {\n  from {\n    translate: calc(var(--_tx) * var(--pui-motion-travel, 1)) calc(var(--_ty) * var(--pui-motion-travel, 1));\n  }\n}\n@keyframes tip-out {\n  to {\n    opacity: 0;\n  }\n}\n"},{"path":"beamline/components/tooltip/tooltip.tsx","type":"registry:file","target":"src/ui/beamline/components/tooltip/tooltip.tsx","content":"\"use client\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { TooltipDefaults } from \"./defaults\";\nexport type { TooltipDefaults } from \"./defaults\";\n\nimport {\n  createContext,\n  useContext,\n  useRef,\n  useState,\n  type CSSProperties,\n  type Ref,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport * as RadixTooltip from \"@radix-ui/react-tooltip\";\nimport { usePortalContainer } from \"../../runtime/appearance\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./tooltip.module.css\";\n\n/** How long after one tooltip of a group closes the next opens at once (and travels from where the last one was). */\nconst SKIP = 400;\n/** The last control of a group whose tooltip showed: its centre and when its tooltip opened or closed. */\ntype Last = { el: Element; x: number; y: number; at: number };\nconst Grouped = createContext<RefObject<Last | null> | null>(null);\n\n/** Tooltips inside a group open instantly one after another (a toolbar): only the first waits, and each next one\n *  travels from where the last one stood. */\nexport function TooltipGroup({\n  children,\n  delay = 500,\n}: {\n  /** Content rendered inside the component. Required. */\n  children: ReactNode;\n  /** Milliseconds before the first tooltip in the group opens. Defaults to 500. */\n  delay?: number;\n}) {\n  const last = useRef<Last | null>(null);\n  return (\n    <RadixTooltip.Provider delayDuration={delay} skipDelayDuration={SKIP}>\n      <Grouped.Provider value={last}>{children}</Grouped.Provider>\n    </RadixTooltip.Provider>\n  );\n}\n\nexport interface TooltipClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the trigger part; no override by default. */\n  trigger?: string;\n  /** Classes for the shortcut part; no override by default. */\n  shortcut?: string;\n}\n\nexport interface TooltipProps {\n  /** What the control does; a few words. */\n  content: ReactNode;\n  /** A keyboard shortcut shown after the text, e.g. \"⌘B\". */\n  shortcut?: string;\n  /** The control: one element that can take a ref (a button). */\n  children: ReactElement;\n  /** Edge from which the panel or anchored content opens. Defaults to \"top\". */\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  /** Alignment within the available space. Defaults to \"center\". */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Open state, controlled or uncontrolled; the tooltip opens on hover and on keyboard focus either way. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Defaults to false. */\n  defaultOpen?: boolean;\n  /** Reports open state after a change; no callback by default. */\n  onOpenChange?: (open: boolean) => void;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: TooltipClassNames;\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}\n\n/**\n * Short supporting text for a control: it opens on hover after a moment and at once on keyboard focus, and closes on\n * Escape. It arrives out of its control on whichever side it opens. Inside a TooltipGroup the next one opens instantly\n * and travels from where the last one stood. Never put essential information or anything clickable in a tooltip.\n */\nexport function Tooltip({\n  content,\n  shortcut,\n  children,\n  side = \"top\",\n  align: alignProp,\n  open,\n  defaultOpen,\n  onOpenChange,\n  className,\n  classNames,\n  style,\n  ref,\n}: TooltipProps) {\n  const scopeDefaults = useComponentDefaults<TooltipDefaults>(\"tooltip\", [\"align\"]);\n  const align = alignProp ?? scopeDefaults.align ?? \"center\";\n\n  const group = useContext(Grouped);\n  const container = usePortalContainer();\n  const trigger = useRef<HTMLButtonElement>(null); // the control, whatever element it is (Radix types it as a button)\n  // in a group, how far this tooltip is from the last one, so it can start there and travel to its own control\n  const [travel, setTravel] = useState<{ x: number; y: number } | null>(null);\n  const change = (next: boolean) => {\n    const el = trigger.current;\n    if (group && el) {\n      const r = el.getBoundingClientRect();\n      const x = r.left + r.width / 2;\n      const y = r.top + r.height / 2;\n      const last = group.current;\n      if (next) {\n        const near = last && last.el !== el && performance.now() - last.at < SKIP;\n        setTravel(near ? { x: last.x - x, y: last.y - y } : null);\n        group.current = { el, x, y, at: Infinity };\n      } else if (last?.el === el) last.at = performance.now();\n    }\n    onOpenChange?.(next);\n  };\n  const tip = (\n    <RadixTooltip.Root open={open} defaultOpen={defaultOpen} onOpenChange={change}>\n      <RadixTooltip.Trigger ref={trigger} asChild data-slot=\"tooltip-trigger\" className={classNames?.trigger}>\n        {children}\n      </RadixTooltip.Trigger>\n      {container !== null && (\n        <RadixTooltip.Portal container={container}>\n          <RadixTooltip.Content\n            data-slot=\"tooltip\"\n            data-grouped={group ? \"\" : undefined}\n            data-travel={travel ? \"\" : undefined}\n            style={travel ? ({ \"--_tx\": `${travel.x}px`, \"--_ty\": `${travel.y}px`, ...style } as CSSProperties) : style}\n            ref={ref}\n            side={side}\n            align={align}\n            sideOffset={6}\n            collisionPadding={8}\n            className={cn(\n              \"flex max-w-[min(260px,calc(100vw-16px))] items-center gap-2 rounded-chip bg-tooltip px-2 py-1 text-xs leading-snug text-tooltip-fg shadow-overlay\",\n              styles.tip,\n              classNames?.root,\n              className,\n            )}\n          >\n            {content}\n            {shortcut && (\n              <kbd\n                data-slot=\"tooltip-shortcut\"\n                className={cn(\"font-mono text-2xs text-[var(--pui-tooltip-muted)]\", classNames?.shortcut)}\n              >\n                {shortcut}\n              </kbd>\n            )}\n          </RadixTooltip.Content>\n        </RadixTooltip.Portal>\n      )}\n    </RadixTooltip.Root>\n  );\n  return group ? tip : <RadixTooltip.Provider delayDuration={500}>{tip}</RadixTooltip.Provider>;\n}\n\nexport default Tooltip;\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-controllable.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-controllable.ts","content":"\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * One value in both forms (rules/CONVENTIONS.md): the prop while it is passed, otherwise the element's own copy that\n * starts at `initial` (a value, or a function that makes it once). The setter takes a value or an updater; an updater\n * reads the latest value even twice in one event, and every change reaches `onChange`, controlled or not.\n * `Next` is what a change can carry when it is narrower than the state: a range that starts empty (null) but is only\n * ever changed to a range.\n */\nexport function useControllable<T, Next extends T = T>(\n  value: T | undefined,\n  initial: T | (() => T),\n  onChange?: (next: Next) => void,\n) {\n  const [own, setOwn] = useState<T>(initial);\n  const current = value !== undefined ? value : own;\n  const latest = useRef(current);\n  latest.current = current;\n  const set = useCallback(\n    (next: Next | ((was: T) => Next)) => {\n      const resolved = typeof next === \"function\" ? (next as (was: T) => Next)(latest.current) : next;\n      latest.current = resolved;\n      if (value === undefined) setOwn(resolved);\n      onChange?.(resolved);\n    },\n    [value, onChange],\n  );\n  return [current, set] as const;\n}\n"},{"path":"beamline/hooks/use-deprecated-prop.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-deprecated-prop.ts","content":"\"use client\";\n\nimport { useEffect } from \"react\";\n\n// Bundlers replace NODE_ENV for browser builds; this declaration emits no Node dependency.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nconst warned = new Set<string>();\n\n/** Report a legacy prop once after mount in development; production and server renders stay silent. */\nexport function useDeprecatedProp(component: string, prop: string, replacement: string, supplied: boolean) {\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"development\" || !supplied) return;\n    const key = `${component}.${prop}`;\n    if (warned.has(key)) return;\n    warned.add(key);\n    console.warn(`[Beamline] ${key} is deprecated. Use ${replacement} instead; the legacy prop still works.`);\n  }, [component, prop, replacement, supplied]);\n}\n"},{"path":"beamline/hooks/use-drag.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-drag.ts","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\nimport { VelocityTracker } from \"../lib/gesture\";\n\n/** Where the pointer is during a drag, relative to where it was pressed, with its velocity (px/s) over the last 100 ms. */\nexport interface DragPoint {\n  /** Pointer position in client coordinates. */\n  x: number;\n  y: number;\n  /** Distance from the press. */\n  dx: number;\n  dy: number;\n  /** Velocity in px/s (read from the last 100 ms, so a release carries the flick, not the whole drag). */\n  vx: number;\n  vy: number;\n  pointerType: string;\n  /** Shift was held (a part may offer a second mode on it: move one task alone, a finer step). */\n  shift: boolean;\n}\n\nexport interface DragHandlers {\n  /** A press landed on the element. Return false to leave it alone (a press on a nested button or link). */\n  onPress?: (point: DragPoint, event: PointerEvent) => boolean | void;\n  /** The pointer moved past the threshold: the drag begins (the press point is in `point.x - point.dx`). */\n  onStart?: (point: DragPoint) => void;\n  /** The pointer moved during a drag. `event` is the browser's own, for what a point does not carry: a pen's pressure,\n   *  the coalesced samples a fast stroke packs into one event (a drawing surface). */\n  onMove?: (point: DragPoint, event: PointerEvent) => void;\n  /** Released after a drag, with the release velocity. */\n  onEnd?: (point: DragPoint) => void;\n  /** Released without moving past the threshold: a tap or click (the part's tap route, WCAG 2.5.7). */\n  onTap?: (point: DragPoint) => void;\n  /** Escape, a lost pointer or the browser taking the gesture (a page scroll): put things back as they were. */\n  onCancel?: () => void;\n  /** A press that ended as neither a drag nor a tap: a finger that went on to scroll the page, or a pointer the browser\n   *  took before the threshold. Undo whatever `onPress` prepared. */\n  onAbandon?: () => void;\n}\n\nexport interface DragOptions {\n  /** The axis the part moves on. On touch, a drag that starts mostly across it is left to the page (a scroll). */\n  axis?: \"x\" | \"y\" | \"both\";\n  /** Pixels before a press becomes a drag: 3 for a mouse, 8 for a finger or pen unless given. */\n  threshold?: number;\n  disabled?: boolean;\n}\n\n/**\n * The pointer side of a hand on a part, shared by everything dragged, thrown or pulled (a thumb, a puck, a stub, a\n * task, a cable). It listens natively on the element, so moves never render React: the part writes positions in its\n * handlers (a spring, a transform, a custom property) and only `dragging`, which changes at the start and end, is\n * state (for `data-state`).\n *\n * A press becomes a drag past a small threshold, then the pointer is captured so the drag continues outside the\n * element; a release without moving is a tap (`onTap`), the route every drag needs for people who cannot drag. Escape,\n * `pointercancel` and a lost capture call `onCancel`, and the part restores what it showed before the press. On touch the\n * element needs a `touch-action` that leaves the other axis to the page (`pan-y` for a part dragged sideways; never\n * `none` on more than the dragged surface, which would block zoom), and a drag that starts across its axis is dropped\n * so the page can scroll.\n */\nexport function useDrag<T extends HTMLElement | SVGElement>(\n  ref: RefObject<T | null>,\n  handlers: DragHandlers,\n  { axis = \"both\", threshold, disabled = false }: DragOptions = {},\n): { dragging: boolean } {\n  const [dragging, setDragging] = useState(false);\n  const latest = useRef(handlers);\n  latest.current = handlers;\n\n  useEffect(() => {\n    // an SVG part (a chart's brush) is dragged the same way; its pointer events are typed as an HTML element's\n    const el = ref.current as HTMLElement | null;\n    if (!el || disabled) return;\n    const doc = el.ownerDocument;\n    // Escape during a drag belongs to the drag: listened on the window's capture phase, it runs before any dialog or\n    // popover listening on the document, which would otherwise also close around the cancelled drag\n    const win = doc.defaultView ?? window;\n    let active: {\n      id: number;\n      x0: number;\n      y0: number;\n      type: string;\n      started: boolean;\n      tracker: VelocityTracker;\n    } | null = null;\n\n    const point = (e: PointerEvent): DragPoint => {\n      const a = active!;\n      const [vx, vy] = a.tracker.velocity(e.timeStamp);\n      return {\n        x: e.clientX,\n        y: e.clientY,\n        dx: e.clientX - a.x0,\n        dy: e.clientY - a.y0,\n        vx,\n        vy,\n        pointerType: e.pointerType,\n        shift: e.shiftKey,\n      };\n    };\n\n    const finish = () => {\n      if (active?.started && el.hasPointerCapture?.(active.id)) el.releasePointerCapture(active.id);\n      active = null;\n      doc.removeEventListener(\"pointermove\", move);\n      doc.removeEventListener(\"pointerup\", up);\n      doc.removeEventListener(\"pointercancel\", cancel);\n      win.removeEventListener(\"keydown\", key, true);\n      doc.removeEventListener(\"selectstart\", hold, true);\n      doc.removeEventListener(\"dragstart\", hold, true);\n      el.removeEventListener(\"lostpointercapture\", lost);\n      setDragging(false);\n    };\n\n    const cancel = () => {\n      if (!active) return;\n      const started = active.started;\n      finish();\n      if (started) latest.current.onCancel?.();\n      else latest.current.onAbandon?.();\n    };\n\n    // only the part's own capture ending cancels: a finger's press is captured by the child it landed on, and moving\n    // the capture to the part fires lostpointercapture on that child, which bubbles here\n    const lost = (e: PointerEvent) => {\n      if (e.target === el && active?.started && e.pointerId === active.id) cancel();\n    };\n\n    const key = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || !active?.started) return;\n      e.preventDefault();\n      e.stopImmediatePropagation();\n      cancel();\n    };\n\n    // while the hand holds the part, the page neither selects text nor starts its own drag and drop (a selection left\n    // by an earlier drag would otherwise be dragged, and the browser cancels the pointer)\n    const hold = (e: Event) => e.preventDefault();\n\n    const move = (e: PointerEvent) => {\n      if (!active || e.pointerId !== active.id) return;\n      active.tracker.add(e.timeStamp, e.clientX, e.clientY);\n      const p = point(e);\n      if (!active.started) {\n        const limit = threshold ?? (active.type === \"mouse\" ? 3 : 8);\n        if (Math.hypot(p.dx, p.dy) < limit) return;\n        // a finger that starts across the part's axis is scrolling the page\n        const across = axis === \"x\" ? Math.abs(p.dy) > Math.abs(p.dx) : axis === \"y\" && Math.abs(p.dx) > Math.abs(p.dy);\n        if (active.type !== \"mouse\" && across) {\n          finish();\n          latest.current.onAbandon?.();\n          return;\n        }\n        active.started = true;\n        doc.getSelection()?.removeAllRanges();\n        el.setPointerCapture?.(active.id);\n        el.addEventListener(\"lostpointercapture\", lost);\n        setDragging(true);\n        latest.current.onStart?.(p);\n      }\n      latest.current.onMove?.(p, e);\n    };\n\n    const up = (e: PointerEvent) => {\n      if (!active || e.pointerId !== active.id) return;\n      active.tracker.add(e.timeStamp, e.clientX, e.clientY);\n      const p = point(e);\n      const started = active.started;\n      finish();\n      if (started) latest.current.onEnd?.(p);\n      else latest.current.onTap?.(p);\n    };\n\n    const down = (e: PointerEvent) => {\n      if (active || !e.isPrimary || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n      const tracker = new VelocityTracker();\n      tracker.add(e.timeStamp, e.clientX, e.clientY);\n      active = { id: e.pointerId, x0: e.clientX, y0: e.clientY, type: e.pointerType, started: false, tracker };\n      if (latest.current.onPress?.(point(e), e) === false) {\n        active = null;\n        return;\n      }\n      doc.addEventListener(\"pointermove\", move);\n      doc.addEventListener(\"pointerup\", up);\n      doc.addEventListener(\"pointercancel\", cancel);\n      win.addEventListener(\"keydown\", key, true);\n      doc.addEventListener(\"selectstart\", hold, true);\n      doc.addEventListener(\"dragstart\", hold, true);\n    };\n\n    el.addEventListener(\"pointerdown\", down);\n    return () => {\n      el.removeEventListener(\"pointerdown\", down);\n      if (active) finish();\n    };\n  }, [ref, axis, threshold, disabled]);\n\n  return { dragging };\n}\n"},{"path":"beamline/hooks/use-expiring.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-expiring.ts","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState, type SetStateAction } from \"react\";\n\n/**\n * A value that clears itself: a \"Saved\" note, a hint after a short press, a row lit after a jump, an undo offer. Each\n * set shows the value and restarts the countdown (a second nudge keeps it up `ms` from the last one, not the first);\n * after `ms`, or `forMs` for this one set, it is null again. Setting null clears it at once.\n */\nexport function useExpiring<T>(ms: number) {\n  const [value, setValue] = useState<T | null>(null);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  useEffect(() => () => clearTimeout(timer.current), []);\n  const set = useCallback(\n    (next: SetStateAction<T | null>, forMs = ms) => {\n      clearTimeout(timer.current);\n      setValue(next);\n      if (next !== null) timer.current = setTimeout(() => setValue(null), forMs);\n    },\n    [ms],\n  );\n  return [value, set] as const;\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/axis.ts","type":"registry:file","target":"src/ui/beamline/lib/axis.ts","content":"/**\n * One axis for every part that lays values along a line: a scrubber, a replay bar, a timeline, a budget. A linear\n * scale, readable ticks, a lens that magnifies around a point, and snapping with a little pull, so the arithmetic is\n * written once and every part that magnifies or snaps does it the same way.\n *\n * The lens is the graphical fisheye of Sarkar and Brown (CHI 1992) applied on each side of a focus: the point under\n * the focus stays where it is (so the thing under the pointer never runs away from it, the failure fisheye menus are\n * known for; Gutwin, CHI 2002), neighbours spread out, and the far ends stay pinned to the ends of the track, so the\n * whole axis remains on screen. Distortion 0 is no lens.\n */\n\nexport interface Scale {\n  (value: number): number;\n  domain: readonly [number, number];\n  range: readonly [number, number];\n  /** The value at a position on the range (clamped to the domain). */\n  invert(position: number): number;\n}\n\n/** A linear map from `domain` (values) to `range` (pixels or any position). */\nexport function linearScale(domain: readonly [number, number], range: readonly [number, number]): Scale {\n  const [d0, d1] = domain;\n  const [r0, r1] = range;\n  const k = d1 === d0 ? 0 : (r1 - r0) / (d1 - d0);\n  const scale = ((v: number) => r0 + (v - d0) * k) as Scale;\n  scale.domain = domain;\n  scale.range = range;\n  scale.invert = (p: number) => {\n    if (k === 0) return d0;\n    const v = d0 + (p - r0) / k;\n    return Math.min(Math.max(v, Math.min(d0, d1)), Math.max(d0, d1));\n  };\n  return scale;\n}\n\n/** About `count` round tick values covering [min, max] (1, 2 or 5 times a power of ten apart). */\nexport function niceTicks(min: number, max: number, count: number): number[] {\n  if (!(max > min) || count < 1) return [min];\n  const raw = (max - min) / count;\n  const power = 10 ** Math.floor(Math.log10(raw));\n  const unit = [1, 2, 5, 10].map((m) => m * power).find((s) => s >= raw) ?? 10 * power;\n  const out: number[] = [];\n  // counted in whole steps from a rounded start, so 0.1 + 0.2 never prints as 0.30000000000000004\n  const first = Math.ceil(min / unit - 1e-9);\n  for (let i = first; i * unit <= max + unit * 1e-9; i++) out.push(Number((i * unit).toPrecision(12)));\n  return out;\n}\n\n/**\n * The lens: maps a position on [start, end] to where it is drawn when magnified around `focus` with `distortion`\n * (0 none; 2–4 is a strong lens). The focus and both ends stay put; positions near the focus spread apart.\n */\nexport function fisheye(position: number, focus: number, distortion: number, start: number, end: number): number {\n  if (distortion <= 0) return position;\n  const side = position < focus ? focus - start : end - focus;\n  if (side <= 0) return position;\n  const x = Math.abs(position - focus) / side;\n  const g = ((distortion + 1) * x) / (distortion * x + 1);\n  return focus + Math.sign(position - focus) * g * side;\n}\n\n/** The undistorted position drawn at `drawn` under the same lens: what a press there selects (rule 8). */\nexport function unfisheye(drawn: number, focus: number, distortion: number, start: number, end: number): number {\n  if (distortion <= 0) return drawn;\n  const side = drawn < focus ? focus - start : end - focus;\n  if (side <= 0) return drawn;\n  const g = Math.min(1, Math.abs(drawn - focus) / side);\n  return focus + Math.sign(drawn - focus) * (g / (distortion + 1 - g * distortion)) * side;\n}\n\n/** How much the lens magnifies at `position` (1 = not at all): the slope of `fisheye` there. */\nexport function magnification(position: number, focus: number, distortion: number, start: number, end: number) {\n  if (distortion <= 0) return 1;\n  const side = position < focus ? focus - start : end - focus;\n  if (side <= 0) return 1;\n  const x = Math.abs(position - focus) / side;\n  return (distortion + 1) / (distortion * x + 1) ** 2;\n}\n\n/**\n * A value pulled onto the nearest mark when it is within `pull` of it (in the value's units), so marks feel like\n * detents without trapping the hand: past the pull radius the value moves freely again.\n */\nexport function snap(value: number, marks: readonly number[], pull: number): number {\n  let best: number | null = null;\n  for (const m of marks)\n    if (Math.abs(m - value) <= pull && (best === null || Math.abs(m - value) < Math.abs(best - value))) best = m;\n  return best ?? value;\n}\n\n/** `value` rounded to the nearest multiple of `step` from `min`, without floating point dust. */\nexport function toStep(value: number, step: number, min = 0): number {\n  if (step <= 0) return value;\n  const decimals = (String(step).split(\".\")[1] ?? \"\").length;\n  return Number((Math.round((value - min) / step) * step + min).toFixed(decimals));\n}\n\n/**\n * Which labels along an axis fit without touching: `at` is each label's position in percent of the `room` (px), `widths`\n * their widths. Both ends always stay; then each label in order that clears the last one kept and the end. Ticks stay\n * for every value; only the words fold (a slider's marks, a scrubber's labels).\n */\nexport function fitLabels(at: readonly number[], widths: readonly number[], room: number, gap: number): boolean[] {\n  const n = at.length;\n  const keep = at.map((_, i) => i === 0 || i === n - 1);\n  if (n < 3) return keep;\n  const x = at.map((p) => (p / 100) * room);\n  const left = (i: number) => Math.max(0, Math.min(room - widths[i], x[i] - widths[i] / 2));\n  const right = (i: number) => left(i) + widths[i];\n  let last = 0;\n  for (let i = 1; i < n - 1; i++) {\n    if (left(i) >= right(last) + gap && right(i) + gap <= left(n - 1)) {\n      keep[i] = true;\n      last = i;\n    }\n  }\n  return keep;\n}\n\n/**\n * A label's box along the axis: centred on its point, or extending inward from an end (`edge` \"start\" or \"end\" in the\n * reading direction), clamped inside [0, room]. Px.\n */\nexport function labelBox(\n  x: number,\n  width: number,\n  room: number,\n  edge?: \"start\" | \"end\",\n  rtl = false,\n): readonly [number, number] {\n  const inward = edge === \"start\" ? (rtl ? -1 : 1) : edge === \"end\" ? (rtl ? 1 : -1) : 0;\n  const left = inward === 1 ? x : inward === -1 ? x - width : x - width / 2;\n  const start = Math.max(0, Math.min(room - width, left));\n  return [start, start + width];\n}\n\n/**\n * Which labels show when they are kept in `order`, most important first (fold by rank: the label the job needs, then\n * the ends, then the rest): each is kept if its box clears every kept box by `gap`. A label that `shown` already shows\n * needs only `gap`; one that would appear needs `gap + hold`, so a label sitting on the edge of fitting does not flicker.\n */\nexport function fitRanked(\n  boxes: readonly (readonly [number, number])[],\n  order: readonly number[],\n  gap: number,\n  shown?: readonly boolean[],\n  hold = 6,\n): boolean[] {\n  const kept: (readonly [number, number])[] = [];\n  const keep = boxes.map(() => false);\n  for (const i of order) {\n    const [l, r] = boxes[i];\n    const need = shown?.[i] ? gap : gap + hold;\n    if (kept.every(([kl, kr]) => r + need <= kl || l >= kr + need)) {\n      kept.push(boxes[i]);\n      keep[i] = true;\n    }\n  }\n  return keep;\n}\n\n/**\n * Labels that keep their order and never overlap, each as near its own anchor as the others allow: neighbours that\n * would overlap pack into one run, centred on the mean of their own places and held inside [0, room]. The answer moves\n * continuously with the anchors, so labels a lens carries slide aside for each other instead of folding and coming\n * back. `items` in drawing order (by centre); returns each one's start, or null when they cannot all fit (fold by rank\n * with `fitRanked` instead).\n */\nexport function dodge(items: readonly { centre: number; width: number }[], gap: number, room: number): number[] | null {\n  const total = items.reduce((s, it) => s + it.width, 0) + gap * Math.max(0, items.length - 1);\n  if (total > room + 0.5) return null;\n  // a run of packed labels: its members' summed wish for the run's start, and where it sits\n  type Run = { from: number; to: number; size: number; sum: number; start: number };\n  const runs: Run[] = [];\n  const place = (r: Run) => {\n    r.start = Math.min(Math.max(r.sum / (r.to - r.from), 0), room - r.size);\n  };\n  items.forEach((it, i) => {\n    const run: Run = { from: i, to: i + 1, size: it.width, sum: it.centre - it.width / 2, start: 0 };\n    place(run);\n    runs.push(run);\n    while (runs.length > 1) {\n      const b = runs[runs.length - 1];\n      const a = runs[runs.length - 2];\n      if (a.start + a.size + gap <= b.start + 1e-6) break;\n      // b's members sit after a's, so their wish for the run's start moves back by a's size and a gap\n      a.sum += b.sum - (b.to - b.from) * (a.size + gap);\n      a.size += gap + b.size;\n      a.to = b.to;\n      runs.pop();\n      place(a);\n    }\n  });\n  const out: number[] = new Array(items.length);\n  for (const r of runs) {\n    let x = r.start;\n    for (let i = r.from; i < r.to; i++) {\n      out[i] = x;\n      x += items[i].width + gap;\n    }\n  }\n  return out;\n}\n"},{"path":"beamline/lib/gesture.ts","type":"registry:file","target":"src/ui/beamline/lib/gesture.ts","content":"import { readMotionFactor } from \"./motion-tokens\";\n\n/**\n * The arithmetic of a hand on a part, shared by every part that is dragged, thrown or pulled (useDrag is the pointer\n * side). Three pieces, each from how real interfaces feel (Apple, \"Designing Fluid Interfaces\", WWDC 2018):\n *\n * - velocity: read from the last ~100 ms of movement, so the release carries the speed of the flick, not of the whole drag;\n * - projection: where a thrown thing would come to rest if it slid on with the look's carry (`project`), so a part\n *   can decide on release (the nearest value, step or preset to that point) instead of after an animation;\n * - give: past a limit the part follows the hand less and less (`rubberband`) instead of stopping dead, and springs back.\n *\n * How much a part carries and gives is the look's material, read once per gesture from the element (`readHand`):\n * `--m-hand-carry`, `--m-hand-give` and `--m-hand-bounce` (preset-source.css), with `--m-morph-weight` scaling how\n * long things take to settle (times a motion token's duration, which carries the speed setting). Reduced motion\n * carries nothing, gives nothing and never bounces.\n */\n\nexport interface Hand {\n  /** How far a release carries: 0 stops where the hand let go, 1 slides on freely (see `project`). */\n  carry: number;\n  /** How much a part follows the hand past a limit: 0 is a hard stop, 0.55 a phone's rubber band. */\n  give: number;\n  /** Bounce of a released held object (0 critically damped). Only for what the pointer held. */\n  bounce: number;\n  /** The look's weight (`--m-morph-weight`): multiply a motion token's duration by it. The token already carries the\n   *  speed setting, so the weight never does. */\n  weight: number;\n  /** Reduced or no motion: land at once. */\n  reduced: boolean;\n}\n\n/** The hand the defaults describe; also what a server or a test without styles gets. */\nexport const DEFAULT_HAND: Hand = { carry: 0.8, give: 0.45, bounce: 0.14, weight: 1, reduced: false };\n\nconst num = (style: CSSStyleDeclaration, name: string, fallback: number) => {\n  const v = Number.parseFloat(style.getPropertyValue(name));\n  return Number.isFinite(v) ? v : fallback;\n};\n\n/** The look's physics at `el`, read once at the start of a gesture (never per frame). */\nexport function readHand(el: Element | null | undefined): Hand {\n  if (!el || typeof getComputedStyle === \"undefined\") return DEFAULT_HAND;\n  const style = getComputedStyle(el);\n  const factor = readMotionFactor(el);\n  // the motion factor is ~0.005 under reduced motion and 1 at the foundation default\n  const reduced = factor < 0.05;\n  if (reduced) return { carry: 0, give: 0, bounce: 0, weight: 1, reduced: true };\n  return {\n    carry: num(style, \"--m-hand-carry\", DEFAULT_HAND.carry),\n    give: num(style, \"--m-hand-give\", DEFAULT_HAND.give),\n    bounce: num(style, \"--m-hand-bounce\", DEFAULT_HAND.bounce),\n    weight: num(style, \"--m-morph-weight\", 1),\n    reduced: false,\n  };\n}\n\n/** Samples of a pointer's path, kept for the last `window` ms, to read its velocity at release. */\nexport class VelocityTracker {\n  private samples: { t: number; x: number; y: number }[] = [];\n  // a declared field, not a parameter property: shipped source must also compile under erasableSyntaxOnly (Vite's\n  // TypeScript template turns it on), where TypeScript-only syntax with runtime meaning is refused\n  private readonly window: number;\n  constructor(window = 100) {\n    this.window = window;\n  }\n\n  add(t: number, x: number, y: number) {\n    this.samples.push({ t, x, y });\n    const from = t - this.window;\n    while (this.samples.length > 2 && this.samples[0].t < from) this.samples.shift();\n  }\n\n  /** Units per second over the kept window; 0 when the hand stopped before letting go (a pause of more than the window). */\n  velocity(now = this.samples.at(-1)?.t ?? 0): [number, number] {\n    const s = this.samples.filter((p) => p.t >= now - this.window);\n    if (s.length < 2) return [0, 0];\n    // least squares slope over the window: one jittery last sample does not decide the throw\n    const n = s.length;\n    const mt = s.reduce((a, p) => a + p.t, 0) / n;\n    const mx = s.reduce((a, p) => a + p.x, 0) / n;\n    const my = s.reduce((a, p) => a + p.y, 0) / n;\n    let tt = 0;\n    let tx = 0;\n    let ty = 0;\n    for (const p of s) {\n      tt += (p.t - mt) ** 2;\n      tx += (p.t - mt) * (p.x - mx);\n      ty += (p.t - mt) * (p.y - my);\n    }\n    if (tt === 0) return [0, 0];\n    return [(tx / tt) * 1000, (ty / tt) * 1000];\n  }\n\n  reset() {\n    this.samples = [];\n  }\n}\n\n/**\n * How far something released at `velocity` (units/s) slides before friction stops it, with the look's carry. `rate` is\n * the speed kept per millisecond: 0.99 (the default) suits controls on a short scale (a thumb, a puck, a cut), where a\n * flick should carry a little way; 0.998 is a phone list's deceleration, for long scrolling content.\n */\nexport function project(velocity: number, carry: number, rate = 0.99): number {\n  if (carry <= 0) return 0;\n  return (((velocity / 1000) * rate) / (1 - rate)) * carry;\n}\n\n/**\n * Where a part drawn `overshoot` units past its limit sits, given the room `extent` it moves in: it follows less and\n * less the further it goes and never reaches `extent`. `give` 0 is a hard stop.\n */\nexport function rubberband(overshoot: number, extent: number, give: number): number {\n  if (give <= 0 || extent <= 0) return 0;\n  const sign = Math.sign(overshoot);\n  const x = Math.abs(overshoot);\n  return sign * (1 - 1 / ((x * give) / extent + 1)) * extent;\n}\n\n/** `value` held inside [min, max], with the part's give past either end. */\nexport function within(value: number, min: number, max: number, extent: number, give: number): number {\n  if (value < min) return min + rubberband(value - min, extent, give);\n  if (value > max) return max + rubberband(value - max, extent, give);\n  return value;\n}\n\n/** The nearest of `stops` to `value` (a projected landing point picks its step or preset this way). */\nexport function nearest(value: number, stops: readonly number[]): number {\n  let best = stops[0];\n  for (const s of stops) if (Math.abs(s - value) < Math.abs(best - value)) best = s;\n  return best;\n}\n"},{"path":"beamline/lib/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":"Image cropper is in src/ui/beamline/components/image-cropper/image-cropper.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/image-cropper"}}