{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"mention-input","type":"registry:component","title":"Mention input","description":"A composer where @people and #channels become single tokens with suggestions at the caret; the value keeps the text and where each mention sits.","categories":["forms"],"dependencies":["@radix-ui/react-direction@1.1.4","@radix-ui/react-label@2.1.15","clsx@^2.1.1","lucide-react@^1.48.0","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/_shared/field.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/field.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A field: label (with an optional aside at the end of its row), the control, then one message line. The gap under\n   the control belongs to the message (--pui-field-gap), so a message that arrives or leaves takes its gap with it. */\n  /* a column, not a grid: a message (or a fold) that leaves pulls its gap away with a negative margin, which a flex\n     column honours and a grid track does not (it would stop at zero and stretch the leaving part back open) */\n  .field {\n    --pui-field-gap: var(--pui-space-1-5);\n    /* positioned: a hidden label or a count's spoken words (sr-only, absolutely placed) stay inside the field */\n    position: relative;\n    display: flex;\n    min-width: 0;\n    flex-direction: column;\n    gap: var(--pui-field-gap);\n  }\n  .labelRow {\n    display: flex;\n    min-width: 0;\n    align-items: baseline;\n    gap: var(--pui-space-2);\n  }\n  .aside {\n    margin-inline-start: auto;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n  }\n\n  /* The message: one line at a time. A new line grows in to its own height (0fr to 1fr, no measuring) while the one it\n   replaces folds away above it, so the height glides from one to the other; a line that leaves with nothing after it\n   takes the gap with it. Arrival over --pui-motion-in, departure over --pui-motion-out; reduced motion keeps the fades. */\n  .messages {\n    display: grid;\n    margin-top: calc(var(--pui-field-gap, var(--pui-space-1-5)) * -1);\n  }\n  .line {\n    display: grid;\n    grid-template-rows: 1fr;\n    opacity: 1;\n    transition:\n      grid-template-rows var(--pui-motion-in) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n  }\n  @starting-style {\n    .line {\n      grid-template-rows: 0fr;\n      opacity: 0;\n    }\n  }\n  /* the line a field is first drawn with does not grow in */\n  .line[data-initial] {\n    transition: none;\n  }\n  /* a swap is one change of size: the old line folds and the new one grows over the same duration and curve, so their\n     sum (the field's height) moves straight from one to the other, with no swell between; the old words fade first */\n  .line[data-leaving] {\n    grid-template-rows: 0fr;\n    opacity: 0;\n    transition:\n      grid-template-rows var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n    pointer-events: none;\n  }\n  .messages:has(> .line[data-leaving]) > .line:not([data-leaving], [data-initial]) {\n    transition-duration: var(--pui-motion-out), var(--pui-fade-in, var(--pui-motion-in));\n  }\n  .clip {\n    min-height: 0;\n    overflow: clip;\n    overflow-clip-margin: var(--pui-space-1);\n  }\n  .message {\n    display: flex;\n    align-items: flex-start;\n    gap: var(--pui-space-1-5);\n    padding-top: var(--pui-field-gap, var(--pui-space-1-5));\n    font-size: var(--pui-text-xs);\n    line-height: 1.375;\n  }\n  .description {\n    display: block;\n    color: var(--pui-ink-3);\n  }\n  .error {\n    color: var(--pui-red);\n  }\n  .success {\n    color: var(--pui-green);\n  }\n  .icon {\n    flex-shrink: 0;\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    margin-top: 1px;\n  }\n\n  /* The count in the label's row: tabular, quiet, shown with attention (.reveal) and in words near the limit. */\n  .count {\n    font-variant-numeric: tabular-nums;\n    white-space: nowrap;\n  }\n  .count[data-state=\"near\"],\n  .count[data-state=\"full\"] {\n    color: var(--pui-orange);\n  }\n  .count[data-state=\"over\"] {\n    color: var(--pui-red);\n  }\n\n  /* The status at the end of the frame: the spinner arrives scaled in from its centre, and closes into the check. */\n  .status {\n    display: inline-grid;\n    flex-shrink: 0;\n    place-items: center;\n    animation: status-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  @keyframes status-in {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.4 * var(--pui-motion-travel, 1));\n    }\n  }\n\n  /* The one frame every text-like control sits in: input, textarea, password, search, number, the select and date\n   triggers. Its paint (fill, edge, hover, focus) is the field role of the look in force (looks.css); invalid,\n   read-only and disabled are drawn here so they read the same in every look. */\n  .frame {\n    --_field-height: var(--pui-control-height-md);\n    position: relative;\n    display: flex;\n    min-width: 0; /* a grid or flex parent may make it narrower than the input's natural width */\n    align-items: center;\n    gap: var(--pui-space-2);\n    min-height: var(--_field-height);\n    padding-inline: var(--pui-space-2-5);\n    border-radius: var(--pui-field-radius, var(--pui-radius-control));\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    cursor: text;\n    transition:\n      box-shadow var(--pui-motion-out) var(--pui-ease-default),\n      background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .frame:hover:not([data-disabled]) {\n    transition-duration: 0ms;\n  }\n  .frame[data-state=\"open\"] {\n    box-shadow: var(--m-field-edge-focus);\n  }\n  .frame:focus-within {\n    transition: none;\n  }\n  .frame[data-disabled] {\n    opacity: var(--pui-opacity-disabled);\n    cursor: not-allowed;\n  }\n  .frame[data-readonly] {\n    --m-field-bg: transparent;\n  }\n  .sm {\n    --_field-height: var(--pui-control-height-sm);\n    padding-inline: var(--pui-space-2);\n  }\n  .lg {\n    --_field-height: var(--pui-control-height-lg);\n    padding-inline: var(--pui-space-3);\n    font-size: var(--pui-text-base);\n  }\n  .multiline {\n    align-items: stretch;\n    padding-block: var(--pui-space-2);\n  }\n\n  /* The control itself: borderless, it takes the frame's look. Focus shows on the frame, so the control's own ring is off\n   (the frame's ring is the visible focus indicator, which is always present when the control has focus). */\n  .control {\n    flex: 1;\n    min-width: 0;\n    height: 100%;\n    min-height: calc(var(--_field-height) - 2px);\n    border: 0;\n    outline: none;\n    background: transparent;\n    color: inherit;\n    font: inherit;\n    letter-spacing: inherit;\n    padding: 0;\n  }\n  .control:focus-visible {\n    outline: none;\n  }\n  .control::placeholder {\n    color: var(--pui-ink-3);\n    opacity: 1;\n  }\n  .control:disabled {\n    cursor: not-allowed;\n  }\n  textarea.control {\n    min-height: 0;\n    resize: none;\n    line-height: 1.5;\n    field-sizing: content;\n  }\n  .adornment {\n    display: inline-flex;\n    align-items: center;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n  }\n  .adornment :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.frame {\n  composes: m-field nest-corner from global;\n}\n"},{"path":"beamline/components/_shared/field.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/field.tsx","content":"\"use client\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english } from \"./messages\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type Ref,\n  type TransitionEvent,\n} from \"react\";\nimport { AlertCircle, CircleCheck } from \"lucide-react\";\nimport { Label } from \"../label/label\";\nimport { Spinner } from \"../spinner/spinner\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./field.module.css\";\n\nexport type FieldSize = \"sm\" | \"md\" | \"lg\";\nexport type FieldClassNames = Partial<\n  Record<\"root\" | \"label\" | \"frame\" | \"leading\" | \"trailing\" | \"description\" | \"error\" | \"success\" | \"aside\", string>\n>;\n\nexport interface FieldOptions {\n  /** Visible label, also the accessible name. */\n  label?: ReactNode;\n  /** Keep the label for assistive tech only (dense rows, toolbars). */\n  hideLabel?: boolean;\n  /** One line of help under the control. */\n  description?: ReactNode;\n  /** The problem and its fix; replaces the description and marks the control invalid. */\n  error?: ReactNode;\n  /** The confirmation that the value works (\"@mira is available\"): replaces the description, with a check. */\n  success?: ReactNode;\n  required?: boolean;\n  size?: FieldSize;\n}\n\n/** Ids and aria wiring for one field: the control gets these spread onto it. */\nexport function useFieldIds(\n  id?: string,\n  { description, error, success }: Pick<FieldOptions, \"description\" | \"error\" | \"success\"> = {},\n) {\n  const auto = useId();\n  const controlId = id ?? `field-${auto}`;\n  // the line under the field is one of three, and the control points at whichever is there\n  const descriptionId = (success || description) && !error ? `${controlId}-description` : undefined;\n  const errorId = error ? `${controlId}-error` : undefined;\n  return {\n    controlId,\n    descriptionId,\n    errorId,\n    aria: {\n      id: controlId,\n      \"aria-describedby\": [descriptionId, errorId].filter(Boolean).join(\" \") || undefined,\n      \"aria-invalid\": error ? true : undefined,\n      \"aria-errormessage\": errorId,\n    },\n  };\n}\n\n/** Label above (with an optional aside at the end of its row: a counter, a link), control frame, then the message. */\nexport function Field({\n  label,\n  hideLabel,\n  description,\n  error,\n  success,\n  required,\n  controlId,\n  descriptionId,\n  errorId,\n  aside,\n  className,\n  classNames,\n  style,\n  slot = \"field\",\n  requiredOnControl,\n  children,\n}: FieldOptions & {\n  controlId: string;\n  descriptionId?: string;\n  errorId?: string;\n  /** At the end of the label's row: a character count, \"Forgot password?\", \"Optional\". */\n  aside?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  style?: CSSProperties;\n  slot?: string;\n  /** The control carries `required` itself, so the label does not repeat it in the control's name. */\n  requiredOnControl?: boolean;\n  children: ReactNode;\n}) {\n  const labelNode = label != null && (\n    <Label\n      htmlFor={controlId}\n      required={required}\n      requiredOnControl={requiredOnControl}\n      className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n    >\n      {label}\n    </Label>\n  );\n  return (\n    <div\n      data-slot={slot}\n      data-invalid={error ? \"\" : undefined}\n      style={style}\n      className={cn(styles.field, aside != null && \"reveal-host\", classNames?.root, className)}\n    >\n      {aside != null ? (\n        <div data-slot=\"field-label-row\" className={styles.labelRow}>\n          {labelNode}\n          <span data-slot=\"field-aside\" className={cn(styles.aside, classNames?.aside)}>\n            {aside}\n          </span>\n        </div>\n      ) : (\n        labelNode\n      )}\n      {children}\n      <FieldMessage\n        error={error}\n        success={success}\n        description={description}\n        errorId={errorId}\n        descriptionId={descriptionId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n\ntype Tone = \"error\" | \"success\" | \"description\";\ninterface Line {\n  key: string;\n  tone: Tone;\n  content: ReactNode;\n}\nconst ICON = { error: AlertCircle, success: CircleCheck } as const;\n\n/**\n * The line under a field: its problem when there is one, else its confirmation, else its help. Shared by every field\n * and choice group. A change of line keeps its path: the new line grows in under the control while the old one folds\n * away above it, so what sits below glides instead of jumping, and a quick reversal turns around from where it is.\n * The leaving line is a still copy, hidden from assistive tech and inert; ids and aria point only at the line that is\n * there. It is gone when its fold ends, and by the clock as well: a line that leaves in the frame it arrived has no\n * fold to run, so no end is reported.\n */\nexport function FieldMessage({\n  error,\n  success,\n  description,\n  errorId,\n  descriptionId,\n  slot = \"field\",\n  classNames,\n}: Pick<FieldOptions, \"error\" | \"description\" | \"success\"> & {\n  errorId?: string;\n  descriptionId?: string;\n  /** The slot names' prefix: \"checkbox\" names the lines checkbox-error, checkbox-description. */\n  slot?: string;\n  classNames?: Pick<FieldClassNames, \"error\" | \"description\" | \"success\">;\n}) {\n  const tone: Tone | null = error ? \"error\" : success ? \"success\" : description ? \"description\" : null;\n  const content = error || success || description;\n  // a new text is a new line (one problem replacing another also folds and grows); rich content changes in place\n  const text = typeof content === \"string\" || typeof content === \"number\" ? String(content) : \"\";\n  const current: Line | null = tone ? { key: `${tone}\\u0000${text}`, tone, content } : null;\n  const [last, setLast] = useState<Line | null>(current);\n  const [leaving, setLeaving] = useState<Line | null>(null);\n  // the line a field is drawn with is simply there; only a change of line moves\n  const [changed, setChanged] = useState(false);\n  if ((last?.key ?? null) !== (current?.key ?? null)) {\n    // the line that was there leaves (one at a time: a line already leaving is gone at once)\n    setLeaving(last && last.key !== current?.key ? last : null);\n    setLast(current);\n    setChanged(true);\n  }\n  const box = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    if (!leaving) return;\n    // the clock is the leaving line's own longest transition, read once as it starts to leave (the fold's end event\n    // usually comes first), so a field resolves no motion tokens of its own\n    const el = box.current?.querySelector<HTMLElement>(\"[data-leaving]\");\n    const longest = el\n      ? Math.max(\n          0,\n          ...getComputedStyle(el)\n            .transitionDuration.split(\",\")\n            .map((t) => parseFloat(t) * (t.trim().endsWith(\"ms\") ? 1 : 1000) || 0),\n        )\n      : 0;\n    const id = window.setTimeout(() => setLeaving((l) => (l === leaving ? null : l)), longest + 250);\n    return () => window.clearTimeout(id);\n  }, [leaving]);\n  if (!current && !leaving) return null;\n  const shown = leaving && leaving.key === current?.key ? null : leaving;\n  const ids = { error: errorId, success: descriptionId, description: descriptionId };\n  const line = (l: Line, gone: boolean) => {\n    const Icon = l.tone === \"description\" ? null : ICON[l.tone];\n    return (\n      <div\n        key={l.key}\n        data-slot=\"field-message-line\"\n        data-leaving={gone ? \"\" : undefined}\n        data-initial={changed ? undefined : \"\"}\n        aria-hidden={gone || undefined}\n        inert={gone || undefined}\n        className={styles.line}\n        onTransitionEnd={\n          gone\n            ? (e: TransitionEvent<HTMLDivElement>) => {\n                if (e.target === e.currentTarget && e.propertyName === \"grid-template-rows\") setLeaving(null);\n              }\n            : undefined\n        }\n      >\n        <div className={styles.clip}>\n          <p\n            id={gone ? undefined : ids[l.tone]}\n            data-slot={`${slot}-${l.tone}`}\n            className={cn(styles.message, styles[l.tone], classNames?.[l.tone])}\n          >\n            {Icon && <Icon aria-hidden className={styles.icon} />}\n            {l.content}\n          </p>\n        </div>\n      </div>\n    );\n  };\n  return (\n    <div ref={box} data-slot=\"field-message\" className={styles.messages}>\n      {shown && line(shown, true)}\n      {current && line(current, false)}\n    </div>\n  );\n}\n\n/** What the end of a text field says about its work: a spinner while a value is checked, a drawn check when it works. */\nexport type FieldStatus = \"busy\" | \"success\";\n\n/** The frame a text-like control sits in; clicking anywhere on it focuses the control. */\nexport function FieldFrame({\n  size = \"md\",\n  invalid,\n  disabled,\n  readOnly,\n  multiline,\n  status,\n  statusLabel: statusLabelProp,\n  leading,\n  trailing,\n  className,\n  classNames,\n  children,\n  onPointerDown,\n  ref,\n}: {\n  size?: FieldSize;\n  invalid?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  multiline?: boolean;\n  /** busy: a spinner at the end while the value is checked or saved; success: the ring closes into a check. */\n  status?: FieldStatus;\n  /** What the spinner says while busy (\"Checking\", \"Saving\"). */\n  statusLabel?: string;\n  leading?: ReactNode;\n  trailing?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  children: ReactNode;\n  onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void;\n  /** The frame itself, for a control that lays out by the frame's width (useWidthAs). */\n  ref?: Ref<HTMLDivElement>;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const statusLabel = statusLabelProp === undefined ? i18n(\"statusLabel\") : statusLabelProp;\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"field-control\"\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-readonly={readOnly ? \"\" : undefined}\n      data-status={status}\n      className={cn(styles.frame, styles[size], multiline && styles.multiline, classNames?.frame, className)}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        // a click on the frame's padding or an adornment that is not itself interactive focuses the control\n        const t = e.target as HTMLElement;\n        if (t === e.currentTarget || (t.closest(\"[data-adornment]\") && !t.closest(\"button, a, input, [role=button]\"))) {\n          e.preventDefault();\n          e.currentTarget.querySelector<HTMLElement>(\"input, textarea\")?.focus();\n        }\n      }}\n    >\n      {leading && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"leading\"\n          className={cn(styles.adornment, classNames?.leading)}\n        >\n          {leading}\n        </span>\n      )}\n      {children}\n      {status && (\n        // one ring for both: it spins while the value is checked and closes into the check when it works\n        <span data-slot=\"field-status\" data-adornment=\"status\" className={styles.status}>\n          <Spinner size=\"sm\" state={status === \"busy\" ? \"loading\" : \"success\"} label={statusLabel} />\n        </span>\n      )}\n      {trailing && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"trailing\"\n          className={cn(styles.adornment, classNames?.trailing)}\n        >\n          {trailing}\n        </span>\n      )}\n    </div>\n  );\n}\n\n/**\n * A character count for the label's row (Field `aside`): quiet (\"58/140\") and shown while the field has attention,\n * then always shown and in words as the limit nears (\"12 left\", \"Limit reached\", \"3 over\"), so it never competes with\n * the label at rest. Assistive tech hears it only when it crosses into those words, not at every key.\n */\nexport function FieldCount({\n  length,\n  max,\n  near: nearAt,\n  slot = \"field-count\",\n  className,\n}: {\n  length: number;\n  max: number;\n  /** How many left turns it into words: by default 10 % of the limit, at least 5 (characters); 1 suits a few tags. */\n  near?: number;\n  slot?: string;\n  className?: string;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const left = max - length;\n  const near = left <= (nearAt ?? Math.max(5, Math.ceil(max * 0.1)));\n  const state = left < 0 ? \"over\" : left === 0 ? \"full\" : near ? \"near\" : \"room\";\n  const words =\n    state === \"over\"\n      ? i18n(\"fieldOver\", { count: -left })\n      : state === \"full\"\n        ? i18n(\"fieldFull\")\n        : i18n(\"fieldLeft\", { count: left });\n  return (\n    <span\n      data-slot={slot}\n      data-state={state}\n      data-shown={state === \"room\" ? undefined : \"\"}\n      className={cn(\"reveal\", styles.count, className)}\n    >\n      <span aria-hidden>{state === \"room\" ? `${i18n.number(length)}/${i18n.number(max)}` : words}</span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {state === \"room\" ? \"\" : words}\n      </span>\n    </span>\n  );\n}\n\n/** The class a native input or textarea needs inside a FieldFrame. */\nexport const fieldControlClass = styles.control;\n\n/** What a choice group (segmented control, radio cards) spreads onto its own root when it is a form field. */\nexport interface ChoiceFieldAria {\n  \"aria-labelledby\": string;\n  \"aria-describedby\"?: string;\n  \"aria-invalid\"?: true;\n}\n\n/**\n * Whether a choice control (segmented control, radio cards, chip group) is laid out as a form field, its label shown\n * above like every field's: when asked (`field`), when it has a description, error or required mark, and whenever its\n * label is not hidden. A view switch or a quick filter passes `hideLabel`: its label stays for assistive tech only.\n * (Trials t16 and t19: agents wrote <SegmentedControl label=\"Catering needed\" …> and the question never showed.)\n */\nexport function choiceIsField(\n  field: boolean | undefined,\n  label: ReactNode,\n  hideLabel: boolean | undefined,\n  marked: boolean,\n) {\n  return field ?? (marked || (label != null && label !== \"\" && !hideLabel));\n}\n\n/**\n * A choice group as a form field: the visible label above (with the required marker), the group, then its help or its\n * problem — the same frame and message every text field has. Groups that name themselves only for assistive tech\n * (a view switch in a toolbar) do not use it.\n */\nexport function ChoiceField({\n  slot,\n  label,\n  hideLabel,\n  required,\n  description,\n  error,\n  className,\n  style,\n  classNames,\n  requiredOnControl,\n  children,\n}: Omit<FieldOptions, \"size\" | \"success\"> & {\n  slot: string;\n  /** The group carries `aria-required` itself (a radiogroup), so the label does not repeat it in the group's name. */\n  requiredOnControl?: boolean;\n  className?: string;\n  style?: CSSProperties;\n  classNames?: Pick<FieldClassNames, \"label\" | \"description\" | \"error\">;\n  children: (aria: ChoiceFieldAria) => ReactNode;\n}) {\n  const auto = useId();\n  const labelId = `choice-${auto}-label`;\n  const messageId = error || description ? `choice-${auto}-message` : undefined;\n  return (\n    <div data-slot={slot} data-invalid={error ? \"\" : undefined} style={style} className={cn(styles.field, className)}>\n      <Label\n        id={labelId}\n        required={required}\n        requiredOnControl={requiredOnControl}\n        className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n      >\n        {label}\n      </Label>\n      {children({\n        \"aria-labelledby\": labelId,\n        ...(messageId ? { \"aria-describedby\": messageId } : {}),\n        ...(error ? { \"aria-invalid\": true as const } : {}),\n      })}\n      <FieldMessage\n        error={error}\n        description={description}\n        errorId={messageId}\n        descriptionId={messageId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n"},{"path":"beamline/components/_shared/glide.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/glide.tsx","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type RefObject } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport overlay from \"./overlay.module.css\";\n\n/**\n * The gliding highlight for a list whose active row is tracked in state (command menu, listbox): one plate travels to\n * the active row for the arrow keys and the pointer alike, over --pui-duration-fast. It appears in place (no travel)\n * the first time and whenever `reset` changes (a new page or a new query), so typing never sends it flying.\n * The list must be the plate's offset parent (position: relative); rows stay above it (overlay.item is z-index 1).\n */\nexport function GlidePlate({\n  list,\n  selector,\n  reset,\n  axis = \"y\",\n  className,\n}: {\n  list: RefObject<HTMLElement | null>;\n  /** Finds the active row inside `list`; null when nothing is active. */\n  selector: string | null;\n  reset?: unknown;\n  /** \"y\" for a list (the plate spans the width); \"xy\" for a row or grid of cells (it takes each cell's size). */\n  axis?: \"y\" | \"xy\";\n  className?: string;\n}) {\n  const [plate, setPlate] = useState<{ x: number; y: number; w: number; h: number; jump: boolean } | null>(null);\n  const lastReset = useRef(reset);\n  const self = useRef<HTMLSpanElement>(null);\n  // eslint-disable-next-line react-hooks/exhaustive-deps -- every render on purpose; setPlate bails out when unchanged\n  useLayoutEffect(() => {\n    // the list holds the plate, and React attaches a parent's ref only after its children's layout effects: on the\n    // first commit the list is the plate's own parent (it placed itself only when something rendered again later)\n    const host = list.current ?? self.current?.parentElement;\n    const row = selector ? host?.querySelector<HTMLElement>(selector) : null;\n    const fresh = lastReset.current !== reset;\n    lastReset.current = reset;\n    if (!row) {\n      setPlate((p) => (p && p.h ? { ...p, h: 0 } : p));\n      return;\n    }\n    const x = row.offsetLeft,\n      y = row.offsetTop,\n      w = row.offsetWidth,\n      h = row.offsetHeight;\n    setPlate((p) =>\n      p && p.x === x && p.y === y && p.w === w && p.h === h && !fresh ? p : { x, y, w, h, jump: fresh || !p?.h },\n    );\n    // every render: rows can arrive or change height without the selector changing; an unchanged place keeps the state\n  });\n  return (\n    <span\n      ref={self}\n      aria-hidden\n      data-slot=\"list-highlight\"\n      className={cn(overlay.glide, className)}\n      data-on={plate?.h ? \"\" : undefined}\n      data-jump={plate?.jump ? \"\" : undefined}\n      data-axis={axis}\n      style={\n        !plate\n          ? undefined\n          : axis === \"y\"\n            ? { transform: `translateY(${plate.y}px)`, height: plate.h || undefined }\n            : { transform: `translate(${plate.x}px, ${plate.y}px)`, width: plate.w, height: plate.h || undefined }\n      }\n    />\n  );\n}\n\n/**\n * A GlidePlate for lists whose highlight is the pointer or keyboard focus rather than state (time picker, plain option\n * lists): it follows the row under the pointer or holding focus, and fades when neither is in the list. Rows drop\n * their own hover and focus tint.\n */\nexport function FollowPlate({\n  list,\n  rows,\n  className,\n}: {\n  list: RefObject<HTMLElement | null>;\n  /** Matches a row inside `list`, e.g. \"[role=option]\". */\n  rows: string;\n  className?: string;\n}) {\n  const [hot, setHot] = useState<string | null>(null);\n  // after the commit, when the list's ref is attached (a layout effect here ran before it and never listened)\n  useEffect(() => {\n    const el = list.current;\n    if (!el) return;\n    let n = 0;\n    const mark = (target: EventTarget | null) => {\n      const row = (target as HTMLElement | null)?.closest?.<HTMLElement>(rows);\n      if (!row || !el.contains(row)) return;\n      row.dataset.glideKey ??= String(++n);\n      setHot(row.dataset.glideKey);\n    };\n    const over = (e: Event) => mark(e.target);\n    const leave = () => {\n      if (!el.contains(document.activeElement)) setHot(null);\n    };\n    const blur = (e: FocusEvent) => {\n      if (!el.contains(e.relatedTarget as Node)) setHot(null);\n    };\n    el.addEventListener(\"pointerover\", over);\n    el.addEventListener(\"focusin\", over);\n    el.addEventListener(\"pointerleave\", leave);\n    el.addEventListener(\"focusout\", blur);\n    return () => {\n      el.removeEventListener(\"pointerover\", over);\n      el.removeEventListener(\"focusin\", over);\n      el.removeEventListener(\"pointerleave\", leave);\n      el.removeEventListener(\"focusout\", blur);\n    };\n  }, [list, rows]);\n  return <GlidePlate list={list} selector={hot ? `[data-glide-key=\"${hot}\"]` : null} className={className} />;\n}\n"},{"path":"beamline/components/_shared/match.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/match.tsx","content":"/** One way to rank and mark matches for every search-as-you-type list (command menu, combobox, multi-select). */\n\n/** How well `label` (and its keywords) answers `query`: label start 100, word start 80, anywhere 60, keyword 45,\n *  every word of a several-word query found across label, keywords and description 40 (50 when one starts the label:\n *  \"billing city\" finds Billing address › City), description 30, letters in order 15, no match 0. An empty query\n *  matches everything with 1. */\nexport function rank(query: string, label: string, keywords?: string[], description?: string): number {\n  const q = query.trim().toLowerCase();\n  if (!q) return 1;\n  const l = label.toLowerCase();\n  const at = l.indexOf(q);\n  if (at === 0) return 100 - l.length / 100;\n  if (at > 0 && /[\\s\\-/(_.]/.test(l[at - 1])) return 80;\n  if (at > 0) return 60;\n  if (keywords?.some((k) => k.toLowerCase().includes(q))) return 45;\n  const words = q.split(/\\s+/);\n  if (words.length > 1) {\n    const hay = [l, ...(keywords ?? []).map((k) => k.toLowerCase()), description?.toLowerCase() ?? \"\"];\n    if (words.every((w) => hay.some((h) => h.includes(w)))) return words.some((w) => l.startsWith(w)) ? 50 : 40;\n  }\n  if (description?.toLowerCase().includes(q)) return 30;\n  let i = 0;\n  for (const ch of l) if (ch === q[i]) i++;\n  return i === q.length ? 15 : 0;\n}\n\n/** Sorts by rank (best first, original order for ties) and drops non-matches. */\nexport function filterRanked<T>(\n  items: T[],\n  query: string,\n  get: (item: T) => { label: string; keywords?: string[]; description?: string },\n): T[] {\n  if (!query.trim()) return items;\n  return items\n    .map((item, order) => {\n      const g = get(item);\n      return { item, order, s: rank(query, g.label, g.keywords, g.description) };\n    })\n    .filter((r) => r.s > 0)\n    .sort((a, b) => b.s - a.s || a.order - b.order)\n    .map((r) => r.item);\n}\n\n/** The label with the matched run emphasised. */\nexport function Highlight({ text, query }: { text: string; query: string }) {\n  const q = query.trim().toLowerCase();\n  const at = q ? text.toLowerCase().indexOf(q) : -1;\n  if (at < 0) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className=\"bg-transparent font-medium text-ink\">{text.slice(at, at + q.length)}</mark>\n      {text.slice(at + q.length)}\n    </>\n  );\n}\n"},{"path":"beamline/components/_shared/messages.ts","type":"registry:file","target":"src/ui/beamline/components/_shared/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy for this helper only. */\nexport const english = {\n  results: {\n    one: \"{count} result\",\n    other: \"{count} results\",\n  },\n  noMatch: \"No match for {query}\",\n  fieldOver: \"{count} over\",\n  fieldFull: \"Limit reached\",\n  fieldLeft: \"{count} left\",\n  foldMore: \"{list} and {count} more\",\n  foldShow: \"Show {count} more {noun}: {list}\",\n  statusLabel: \"Checking\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/_shared/overlay.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/overlay.module.css","content":"@layer properties, theme, base, components, utilities;\n/* the source morph's moving parts: the clip's four sides, the radius and the offset back to the trigger */\n@property --ci-t {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-r {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-b {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-l {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-x {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --ci-y {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@layer components {\n  /* Every floating layer in the system: one surface (painted by the overlay role of the look in force), one menu-item\n   look, one scrim, one enter and exit. A layer placed against a trigger opens out of it (the source morph below); one\n   without a placement arrives in --pui-motion-in scaling from its origin. Each leaves in --pui-motion-out; Radix keeps\n   it mounted until the exit animation ends. Reduced motion makes both instant (the duration tokens become 1ms). */\n  .surface {\n    z-index: var(--pui-z-popover);\n    min-width: 8rem;\n    /* never past the screen: as wide and as tall as the room beside the trigger (Radix measures it) */\n    max-width: var(--radix-popper-available-width, none);\n    max-height: var(--radix-popper-available-height, 70vh);\n    overflow: auto;\n    border-radius: var(--pui-overlay-radius, var(--pui-radius-card));\n    color: var(--pui-ink);\n    transform-origin: var(--radix-popper-transform-origin, var(--radix-dropdown-menu-content-transform-origin, center));\n    outline: none;\n  }\n  .surface[data-state=\"open\"],\n  .surface[data-state=\"delayed-open\"],\n  .surface[data-state=\"instant-open\"] {\n    animation: pop-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .surface[data-state=\"closed\"] {\n    animation: pop-out var(--pui-motion-out) var(--pui-ease-default) forwards;\n  }\n\n  /* The source morph (research/morphing-interfaces-2026-10-04, M3): a layer placed against a trigger does not fade in\n     beside it, it opens out of it. It starts as the trigger's own box (Radix gives its size and the side and alignment\n     it was placed on), offset back onto the trigger, and grows: the side nearest the trigger is there at once, the\n     sides across follow, the far side arrives last (~200 ms), so the rows by the trigger are readable within the\n     ruled 90 ms while the outline settles. Content fades in over those 90 ms. It closes back into the trigger in the\n     ruled 180 ms. The clip ends 48px outside the box so the layer's shadow is never cut. Reduced motion: instant. */\n  .surface[data-side] {\n    --_aw: var(--radix-popper-anchor-width, 0px);\n    --_ah: var(--radix-popper-anchor-height, 0px);\n    --_gap: 6px;\n    --from-t: -48px;\n    --from-r: -48px;\n    --from-b: -48px;\n    --from-l: -48px;\n    --from-x: 0px;\n    --from-y: 0px;\n    --d-near: calc(var(--pui-duration-standard) * 0.55);\n    --d-cross: calc(var(--pui-duration-standard) * 0.85);\n    --d-far: var(--pui-duration-standard);\n    clip-path: inset(\n      var(--ci-t) var(--ci-r) var(--ci-b) var(--ci-l) round var(--pui-overlay-radius, var(--pui-radius-card))\n    );\n    translate: var(--ci-x) var(--ci-y);\n  }\n  .surface[data-side=\"bottom\"] {\n    --from-t: 0px;\n    --from-b: calc(100% - var(--_ah));\n    --from-y: calc(-1 * (var(--_ah) + var(--_gap)));\n    --d-t: var(--d-near);\n    --d-b: var(--d-far);\n    --d-l: var(--d-cross);\n    --d-r: var(--d-cross);\n  }\n  .surface[data-side=\"top\"] {\n    --from-b: 0px;\n    --from-t: calc(100% - var(--_ah));\n    --from-y: calc(var(--_ah) + var(--_gap));\n    --d-b: var(--d-near);\n    --d-t: var(--d-far);\n    --d-l: var(--d-cross);\n    --d-r: var(--d-cross);\n  }\n  .surface[data-side=\"right\"] {\n    --from-l: 0px;\n    --from-r: calc(100% - var(--_aw));\n    --from-x: calc(-1 * (var(--_aw) + var(--_gap)));\n    --d-l: var(--d-near);\n    --d-r: var(--d-far);\n    --d-t: var(--d-cross);\n    --d-b: var(--d-cross);\n  }\n  .surface[data-side=\"left\"] {\n    --from-r: 0px;\n    --from-l: calc(100% - var(--_aw));\n    --from-x: calc(var(--_aw) + var(--_gap));\n    --d-r: var(--d-near);\n    --d-l: var(--d-far);\n    --d-t: var(--d-cross);\n    --d-b: var(--d-cross);\n  }\n  /* alignment along the trigger: which part of the layer the trigger sits over */\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"start\"] {\n    --from-l: 0px;\n    --from-r: calc(100% - var(--_aw));\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"end\"] {\n    --from-l: calc(100% - var(--_aw));\n    --from-r: 0px;\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"start\"]:dir(rtl) {\n    --from-l: calc(100% - var(--_aw));\n    --from-r: 0px;\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"end\"]:dir(rtl) {\n    --from-l: 0px;\n    --from-r: calc(100% - var(--_aw));\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"center\"] {\n    --from-l: calc((100% - var(--_aw)) / 2);\n    --from-r: calc((100% - var(--_aw)) / 2);\n  }\n  .surface:is([data-side=\"left\"], [data-side=\"right\"])[data-align=\"start\"] {\n    --from-t: 0px;\n    --from-b: calc(100% - var(--_ah));\n  }\n  .surface:is([data-side=\"left\"], [data-side=\"right\"])[data-align=\"end\"] {\n    --from-t: calc(100% - var(--_ah));\n    --from-b: 0px;\n  }\n  .surface:is([data-side=\"left\"], [data-side=\"right\"])[data-align=\"center\"] {\n    --from-t: calc((100% - var(--_ah)) / 2);\n    --from-b: calc((100% - var(--_ah)) / 2);\n  }\n  .surface[data-side]:is([data-state=\"open\"], [data-state=\"delayed-open\"], [data-state=\"instant-open\"]) {\n    animation:\n      grow-t var(--d-t) var(--pui-ease-out-strong),\n      grow-r var(--d-r) var(--pui-ease-out-strong),\n      grow-b var(--d-b) var(--pui-ease-out-strong),\n      grow-l var(--d-l) var(--pui-ease-out-strong),\n      grow-xy var(--d-cross) var(--pui-ease-out-strong),\n      settling var(--d-far) step-end;\n  }\n  .surface[data-side]:is([data-state=\"open\"], [data-state=\"delayed-open\"], [data-state=\"instant-open\"]) > * {\n    animation: content-in var(--pui-motion-in) var(--pui-ease-default) both;\n  }\n  /* one duration for every part of the exit: the layer unmounts when the first of them ends */\n  .surface[data-side][data-state=\"closed\"] {\n    animation: shrink var(--pui-motion-out) var(--pui-ease-exit) forwards;\n  }\n  .surface[data-side][data-state=\"closed\"] > * {\n    animation: content-out var(--pui-motion-in) var(--pui-ease-default) forwards;\n  }\n  @keyframes grow-t {\n    from {\n      --ci-t: var(--from-t);\n    }\n  }\n  @keyframes grow-r {\n    from {\n      --ci-r: var(--from-r);\n    }\n  }\n  @keyframes grow-b {\n    from {\n      --ci-b: var(--from-b);\n    }\n  }\n  @keyframes grow-l {\n    from {\n      --ci-l: var(--from-l);\n    }\n  }\n  @keyframes grow-xy {\n    from {\n      --ci-x: var(--from-x);\n      --ci-y: var(--from-y);\n    }\n  }\n  /* it starts over its own trigger: until it has grown, the press that opened it must not land on a row */\n  @keyframes settling {\n    0%,\n    100% {\n      pointer-events: none;\n    }\n  }\n  @keyframes shrink {\n    to {\n      --ci-t: var(--from-t);\n      --ci-r: var(--from-r);\n      --ci-b: var(--from-b);\n      --ci-l: var(--from-l);\n      --ci-x: var(--from-x);\n      --ci-y: var(--from-y);\n      opacity: 0;\n    }\n  }\n  @keyframes content-in {\n    from {\n      opacity: 0;\n    }\n  }\n  @keyframes content-out {\n    to {\n      opacity: 0;\n    }\n  }\n  .padded {\n    padding: var(--pui-pad-popover);\n  }\n  .menu {\n    padding: var(--pui-space-1);\n  }\n  @keyframes pop-in {\n    from {\n      opacity: 0;\n      transform: scale(0.97);\n    }\n  }\n  @keyframes pop-out {\n    to {\n      opacity: 0;\n      transform: scale(0.98);\n    }\n  }\n\n  /* rows (and the gliding plate) nest in the list: its radius minus its padding, never rounder than a chip; on the\n     row itself, so any list that uses .item gets it */\n  .item,\n  .glide {\n    --_item-radius: min(\n      var(--pui-radius-chip),\n      calc(var(--pui-overlay-radius, var(--pui-radius-card)) - var(--pui-space-1))\n    );\n  }\n\n  /* menu rows: shared by dropdown, context menu, menubar, select, combobox lists */\n  .item {\n    position: relative;\n    z-index: 1;\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-2);\n    min-height: var(--pui-control-height-sm);\n    padding: 0 var(--pui-space-2);\n    /* nested in the list: the overlay's radius minus its padding, never rounder than a chip */\n    border-radius: var(--_item-radius);\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    cursor: default;\n    user-select: none;\n    outline: none;\n  }\n  /* on a touch screen a row is a finger's target: the touch height (44px) from the tokens; a mouse keeps the dense row */\n  @media (pointer: coarse) {\n    .item {\n      min-height: var(--pui-control-height-touch);\n    }\n  }\n  /* a destructive row speaks in the danger ink only while it can be used: disabled wins (muted ink, no alarm for an\n     action that cannot happen) */\n  .item[data-tone=\"danger\"]:not([data-disabled]) {\n    color: var(--pui-red);\n  }\n  .item[data-disabled] {\n    color: var(--pui-ink-3);\n    pointer-events: none;\n  }\n  .item :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n  }\n  .item[data-tone=\"danger\"]:not([data-disabled]) :where(svg) {\n    color: currentColor;\n  }\n  .item[data-highlighted]:not([data-glide]) {\n    background: var(--pui-hover-2);\n  }\n  /* a press is acknowledged at once, over the plate, before the menu closes or the row toggles */\n  .item:active:not([data-disabled]) {\n    background: var(--pui-hover);\n  }\n  .shortcut {\n    margin-inline-start: auto;\n    padding-inline-start: var(--pui-space-4);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    letter-spacing: var(--pui-tracking-mono-caps);\n  }\n  .heading {\n    padding: var(--pui-space-1-5) var(--pui-space-2) var(--pui-space-1);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    font-weight: var(--pui-weight-medium);\n  }\n  .separator {\n    height: 1px;\n    margin: var(--pui-space-1) calc(var(--pui-space-1) * -1);\n    background: var(--pui-line);\n  }\n  .check {\n    margin-inline-start: auto;\n    color: var(--pui-accent) !important;\n  }\n  /* a check arrives in its row as the value changes (at once): it grows from the middle over the arrival */\n  .check :where(svg) {\n    animation: check-in var(--pui-motion-in) var(--pui-ease-out-strong);\n  }\n  @keyframes check-in {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.5 * var(--pui-motion-travel, 1));\n    }\n  }\n  /* the gliding highlight: one plate moves between rows for the pointer and the arrow keys; it appears in place */\n  .glide {\n    position: absolute;\n    z-index: 0;\n    inset-inline: var(--pui-space-1);\n    top: 0;\n    border-radius: var(--_item-radius);\n    background: var(--pui-hover-2);\n    pointer-events: none;\n    opacity: 0;\n    transition: opacity var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .glide[data-on] {\n    opacity: 1;\n    transition:\n      transform var(--pui-duration-fast) var(--pui-ease-standard),\n      height var(--pui-duration-fast) var(--pui-ease-standard),\n      opacity 0ms;\n  }\n  .glide[data-jump] {\n    transition: opacity 0ms;\n  }\n  /* a plate over a row or grid of cells takes each cell's size and travels both ways */\n  .glide[data-axis=\"xy\"] {\n    inset-inline: auto;\n    left: 0;\n  }\n  .glide[data-axis=\"xy\"][data-on] {\n    transition:\n      transform var(--pui-duration-fast) var(--pui-ease-standard),\n      width var(--pui-duration-fast) var(--pui-ease-standard),\n      height var(--pui-duration-fast) var(--pui-ease-standard),\n      opacity 0ms;\n  }\n\n  /* a layer that has begun to close is out of the way at once: for the length of its exit it takes no pointer, so a tap\n     meant for the page behind (the next row, the button the panel covered) lands there, never on the leaving ghost.\n     Focus has already gone back (Radix) and it unmounts when the exit ends. */\n  .scrim[data-state=\"closed\"],\n  .surface[data-state=\"closed\"],\n  .dialog[data-state=\"closed\"],\n  .sheet[data-state=\"closed\"] {\n    pointer-events: none;\n  }\n\n  /* modal layers */\n  .scrim {\n    position: fixed;\n    inset: 0;\n    z-index: var(--pui-z-overlay);\n    background: var(--pui-scrim);\n    backdrop-filter: blur(var(--pui-blur-overlay));\n  }\n  .scrim[data-state=\"open\"] {\n    animation: fade-in var(--pui-fade-in) var(--pui-ease-default);\n  }\n  .scrim[data-state=\"closed\"] {\n    animation: fade-out var(--pui-fade-out) var(--pui-ease-default) forwards;\n  }\n  @keyframes fade-in {\n    from {\n      opacity: 0;\n    }\n  }\n  @keyframes fade-out {\n    to {\n      opacity: 0;\n    }\n  }\n  .dialog {\n    /* the content answers to the dialog's own width (a footer's buttons share the row on a phone) */\n    container-type: inline-size;\n    position: fixed;\n    z-index: var(--pui-z-modal);\n    left: 50%;\n    top: 50%;\n    width: min(calc(100vw - var(--pui-space-8)), var(--pui-dialog-width, var(--_dialog-width, 440px)));\n    max-height: calc(100dvh - var(--pui-space-12));\n    overflow: hidden;\n    translate: -50% -50%;\n    border-radius: var(--pui-dialog-radius, var(--pui-radius-window));\n    color: var(--pui-ink);\n    outline: none;\n    /* a size chosen while open (a step that needs more room) eases the width in place, centred */\n    transition: width var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  .dialog[data-state=\"open\"] {\n    animation: dialog-in var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .dialog[data-state=\"closed\"] {\n    animation: dialog-out var(--pui-motion-out) var(--pui-ease-default) forwards;\n  }\n  /* A dialog opened from something (use-open-from.ts) grows out of it: it starts as the opener's box, offset back onto\n     it, and grows to its own place over the considered duration while its content fades in; it closes back into the\n     opener in the ruled 180 ms. Shares the source morph's moving parts and guard with .surface. */\n  .dialog[data-open-from] {\n    clip-path: inset(\n      var(--ci-t) var(--ci-r) var(--ci-b) var(--ci-l) round var(--pui-dialog-radius, var(--pui-radius-window))\n    );\n    transform: translate(var(--ci-x), var(--ci-y));\n  }\n  .dialog[data-open-from][data-state=\"open\"] {\n    animation:\n      grow-t var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-r var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-b var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-l var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-xy calc(var(--pui-duration-considered) * 0.9) var(--pui-ease-out-strong),\n      settling var(--pui-duration-considered) step-end;\n  }\n  .dialog[data-open-from][data-state=\"open\"] > * {\n    animation: content-in var(--pui-duration-standard) var(--pui-ease-default) calc(var(--pui-duration-standard) * 0.35)\n      both;\n  }\n  .dialog[data-open-from][data-state=\"closed\"] {\n    animation: shrink var(--pui-motion-out) var(--pui-ease-exit) forwards;\n  }\n  .dialog[data-open-from][data-state=\"closed\"] > * {\n    animation: content-out var(--pui-motion-in) var(--pui-ease-default) forwards;\n  }\n  /* A sheet: a panel from an edge. It slides in from its edge over the considered duration on the drawer curve and\n     goes back out to that edge in the ruled departure (a layer closes back into where it came from). The edge is\n     logical: an end (\"right\") sheet comes from the left in a right-to-left page. Its content answers to its width. */\n  .sheet {\n    --_sx: 0px;\n    --_sy: 0px;\n    container-type: inline-size;\n    /* where a finger has swiped it toward its edge (sheet.tsx); it settles back over the departure */\n    translate: var(--_swipe, 0px) 0;\n  }\n  /* a side panel leaves sideways pans to the swipe and keeps vertical scrolling native, in its body too */\n  .sheet:is([data-side=\"left\"], [data-side=\"right\"]),\n  .sheet:is([data-side=\"left\"], [data-side=\"right\"]) [data-slot=\"sheet-body\"] {\n    touch-action: pan-y;\n  }\n  .sheet:is([data-side=\"left\"], [data-side=\"right\"]) {\n    transition:\n      var(--m-transition),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .sheet[data-swiping] {\n    transition: none;\n  }\n  .sheet[data-side=\"right\"] {\n    --_sx: 100%;\n  }\n  .sheet[data-side=\"left\"] {\n    --_sx: -100%;\n  }\n  .sheet[data-side=\"right\"]:dir(rtl) {\n    --_sx: -100%;\n  }\n  .sheet[data-side=\"left\"]:dir(rtl) {\n    --_sx: 100%;\n  }\n  .sheet[data-side=\"top\"] {\n    --_sy: -100%;\n  }\n  .sheet[data-side=\"bottom\"] {\n    --_sy: 100%;\n  }\n  .sheet[data-state=\"open\"] {\n    animation: sheet-in var(--pui-duration-considered) var(--pui-ease-drawer) both;\n  }\n  .sheet[data-state=\"closed\"] {\n    animation: sheet-out var(--pui-motion-out) var(--pui-ease-exit) forwards;\n  }\n  @keyframes sheet-in {\n    from {\n      translate: var(--_sx) var(--_sy);\n    }\n  }\n  @keyframes sheet-out {\n    to {\n      translate: var(--_sx) var(--_sy);\n    }\n  }\n  @keyframes dialog-in {\n    from {\n      opacity: 0;\n      transform: scale(0.96);\n    }\n  }\n  @keyframes dialog-out {\n    to {\n      opacity: 0;\n      transform: scale(0.98);\n    }\n  }\n}\n\n/* Reduced motion: surfaces and dialogs fade without scaling or growing, over the fade tokens (restrained under reduced,\n   1ms under none; APPEARANCE A06). */\n@media (prefers-reduced-motion: reduce) {\n  .surface,\n  .dialog,\n  .sheet {\n    animation-name: fade-in !important;\n    animation-duration: var(--pui-fade-in) !important;\n  }\n  .surface[data-state=\"closed\"],\n  .dialog[data-state=\"closed\"],\n  .sheet[data-state=\"closed\"] {\n    animation-name: fade-out !important;\n    animation-duration: var(--pui-fade-out) !important;\n  }\n}\n@layer components {\n  :global(.pui[data-motion=\"reduced\"]) .surface,\n  :global(.pui[data-motion=\"none\"]) .surface,\n  :global(.pui[data-motion=\"reduced\"]) .dialog,\n  :global(.pui[data-motion=\"none\"]) .dialog,\n  :global(.pui[data-motion=\"reduced\"]) .sheet,\n  :global(.pui[data-motion=\"none\"]) .sheet {\n    animation-name: fade-in;\n    animation-duration: var(--pui-fade-in);\n  }\n  :global(.pui[data-motion=\"reduced\"]) .surface[data-state=\"closed\"],\n  :global(.pui[data-motion=\"none\"]) .surface[data-state=\"closed\"],\n  :global(.pui[data-motion=\"reduced\"]) .dialog[data-state=\"closed\"],\n  :global(.pui[data-motion=\"none\"]) .dialog[data-state=\"closed\"],\n  :global(.pui[data-motion=\"reduced\"]) .sheet[data-state=\"closed\"],\n  :global(.pui[data-motion=\"none\"]) .sheet[data-state=\"closed\"] {\n    animation-name: fade-out;\n    animation-duration: var(--pui-fade-out);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.surface {\n  composes: m-overlay from global;\n}\n.dialog {\n  composes: m-overlay from global;\n}\n"},{"path":"beamline/components/avatar/avatar.module.css","type":"registry:file","target":"src/ui/beamline/components/avatar/avatar.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Avatar: a face (initials on the name's hue, or a photo) and an optional presence mark. The mark is cut out of the\n     face with a mask, so the gap around it is truly empty and reads on any ground: a card, a row hover, glass, a photo. */\n  .root {\n    --_s: 36px;\n    --_d: 10px;\n    --_g: 2px;\n    position: relative;\n    display: inline-grid;\n    flex: none;\n    width: var(--_s);\n    height: var(--_s);\n    place-items: center;\n    /* initials scale with the face (geometry), never below 80% of the smallest type step */\n    font-size: max(calc(var(--pui-text-2xs) * 0.8), calc(var(--_s) * 0.36));\n    font-weight: var(--pui-weight-medium);\n    line-height: 1;\n    user-select: none;\n  }\n  .xs {\n    --_s: 20px;\n    --_d: 6px;\n    --_g: 1.5px;\n  }\n  .sm {\n    --_s: 28px;\n    --_d: 8px;\n  }\n  .lg {\n    --_s: 44px;\n    --_d: 12px;\n  }\n  .xl {\n    --_s: 56px;\n    --_d: 14px;\n    --_g: 2.5px;\n  }\n  .face {\n    position: absolute;\n    inset: 0;\n    display: grid;\n    place-items: center;\n    overflow: hidden;\n    border-radius: 50%;\n    background: color-mix(in oklab, var(--pui-avatar-hue) 24%, var(--pui-surface-2));\n    color: color-mix(in oklab, var(--pui-avatar-hue) 55%, var(--pui-ink));\n  }\n  /* the initials keep their tint but mix it into black or white, whichever the face's fill reads better against (its\n     luminance at WCAG's 0.179 crossover): mixed into the scope's ink they fell to 3.5–4.5 on mid fills, a brand-toned\n     sidebar's above all; this keeps every face at 5.5:1 or more in every look */\n  @supports (color: color(from red xyz-d65 x y z)) {\n    .face {\n      --_ink: color(\n        from color-mix(in oklab, var(--pui-avatar-hue) 24%, var(--pui-surface-2)) xyz-d65\n          calc(0.9505 * round(0.679 - y)) round(0.679 - y) calc(1.089 * round(0.679 - y))\n      );\n      color: color-mix(in oklab, var(--pui-avatar-hue) 55%, var(--_ink));\n    }\n  }\n  /* a company tile takes the look's card corner, never more than a third of the face, so it stays a square at xs */\n  .root[data-shape=\"square\"] .face {\n    /* nesting input (O1 §14.1, as Button): a parent that holds this near its rounded corner sets --pui-nest-radius and\n       --pui-nest-inset; the corner becomes radius − inset, clamped between a floor (chip) and the part's own role radius (or --pui-nest-ceiling: a pill container sets it to the pill radius). Unset, the declaration is invalid and the own corner\n       applies. --pui-avatar-radius wins over both. */\n    --_avatar-nest: clamp(\n      var(--pui-radius-chip),\n      calc(var(--pui-nest-radius) - var(--pui-nest-inset, 0px)),\n      var(--pui-nest-ceiling, min(var(--pui-radius-card), calc(var(--_s) / 3)))\n    );\n    --_r: var(--pui-avatar-radius, var(--_avatar-nest, min(var(--pui-radius-card), calc(var(--_s) / 3))));\n    border-radius: var(--_r);\n  }\n  .photo {\n    position: absolute;\n    inset: 0;\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    transition: opacity var(--pui-fade-in) var(--pui-ease-default);\n  }\n  /* still on its way: the initials hold the place, and the photo fades in over them when it lands */\n  .photo[data-pending] {\n    opacity: 0;\n  }\n  /* The mark sits on the bottom inline-end corner; the face loses a disc one gap wider than the mark under it. */\n  .root[data-status] .face {\n    --_at: calc(var(--_d) / 2 - 1px);\n    mask: radial-gradient(\n      circle at calc(100% - var(--_at)) calc(100% - var(--_at)),\n      transparent calc(var(--_d) / 2 + var(--_g)),\n      black calc(var(--_d) / 2 + var(--_g) + 0.5px)\n    );\n  }\n  .root[data-status]:dir(rtl) .face {\n    mask: radial-gradient(\n      circle at var(--_at) calc(100% - var(--_at)),\n      transparent calc(var(--_d) / 2 + var(--_g)),\n      black calc(var(--_d) / 2 + var(--_g) + 0.5px)\n    );\n  }\n  .mark {\n    position: absolute;\n    inset-inline-end: -1px;\n    bottom: -1px;\n  }\n  /* a presence that changed settles into its new mark: the colour and shape arrive at once, the mark grows from a\n     little smaller (reduced motion keeps only the fade) */\n  .mark[data-changed] {\n    animation: presence-settle var(--pui-duration-feedback) var(--pui-ease-standard) both;\n  }\n\n  /* Presence: colour and shape together, so it reads in greyscale (DESIGN-LANGUAGE: colour is never the only signal).\n     online a full disc · away a crescent · busy a disc with a bar through it · offline a hollow ring. */\n  .presence {\n    --_d: 10px;\n    display: inline-block;\n    flex: none;\n    width: var(--_d);\n    height: var(--_d);\n    border-radius: 50%;\n    background: var(--_c);\n  }\n  .presence[data-size=\"xs\"] {\n    --_d: 6px;\n  }\n  .presence[data-size=\"sm\"] {\n    --_d: 8px;\n  }\n  .presence[data-size=\"lg\"] {\n    --_d: 12px;\n  }\n  .presence[data-size=\"xl\"] {\n    --_d: 14px;\n  }\n  .presence[data-status=\"online\"] {\n    --_c: var(--pui-green);\n  }\n  .presence[data-status=\"away\"] {\n    --_c: var(--pui-orange);\n    mask: radial-gradient(circle at 18% 18%, transparent calc(var(--_d) * 0.36), black calc(var(--_d) * 0.36 + 0.5px));\n  }\n  .presence[data-status=\"away\"]:dir(rtl) {\n    mask: radial-gradient(circle at 82% 18%, transparent calc(var(--_d) * 0.36), black calc(var(--_d) * 0.36 + 0.5px));\n  }\n  .presence[data-status=\"busy\"] {\n    --_c: var(--pui-red);\n    mask:\n      linear-gradient(black 0 0) center / 56% max(1.5px, 22%) no-repeat,\n      linear-gradient(black 0 0);\n    mask-composite: exclude;\n  }\n  .presence[data-status=\"offline\"] {\n    --_c: var(--pui-ink-3);\n    mask: radial-gradient(circle, transparent calc(var(--_d) * 0.24), black calc(var(--_d) * 0.24 + 0.5px));\n  }\n}\n@keyframes presence-settle {\n  from {\n    opacity: 0.4;\n    scale: calc(1 - 0.45 * var(--pui-motion-travel, 1));\n  }\n}\n"},{"path":"beamline/components/avatar/avatar.tsx","type":"registry:file","target":"src/ui/beamline/components/avatar/avatar.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { AvatarDefaults } from \"./defaults\";\nexport type { AvatarDefaults } from \"./defaults\";\n\nimport { useCallback, useState, type CSSProperties, type HTMLAttributes, type Ref } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./avatar.module.css\";\n\nexport type AvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\nexport type PresenceStatus = \"online\" | \"away\" | \"busy\" | \"offline\";\n\nconst SIZE: Record<AvatarSize, string | undefined> = {\n  xs: styles.xs,\n  sm: styles.sm,\n  md: undefined,\n  lg: styles.lg,\n  xl: styles.xl,\n};\n\n/** A person's (or account's) series hue, the same everywhere their name appears. */\nexport function hueOf(name: string) {\n  let h = 0;\n  for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) | 0;\n  return `var(--pui-series-${(Math.abs(h) % 8) + 1})`;\n}\nexport const initialsOf = (name: string) =>\n  name\n    .trim()\n    .split(/\\s+/)\n    .filter((w) => /\\p{L}|\\d/u.test(w[0] ?? \"\"))\n    .slice(0, 2)\n    .map((w) => w[0].toUpperCase())\n    .join(\"\") || \"?\";\n\n/** A presence mark for avatars and lists. Colour and shape together: online a disc, away a crescent, busy a disc with\n *  a bar, offline a hollow ring, so the status survives greyscale and colour-blindness. */\nexport function PresenceDot({\n  status,\n  size = \"md\",\n  className,\n  style,\n  ref,\n  ...props\n}: {\n  status: PresenceStatus;\n  size?: AvatarSize;\n  className?: string;\n  style?: CSSProperties;\n  ref?: Ref<HTMLSpanElement>;\n} & Omit<HTMLAttributes<HTMLSpanElement>, \"children\">) {\n  const i18n = useMessages(\"avatar\", englishMessages);\n  return (\n    <span\n      {...props}\n      style={style}\n      ref={ref}\n      role=\"img\"\n      aria-label={props[\"aria-label\"] ?? i18n(status)}\n      data-slot=\"presence-dot\"\n      data-status={status}\n      data-size={size}\n      className={cn(styles.presence, className)}\n    />\n  );\n}\n\nexport interface AvatarClassNames {\n  root?: string;\n  face?: string;\n  photo?: string;\n  presence?: string;\n}\n\nexport interface AvatarProps extends HTMLAttributes<HTMLSpanElement> {\n  name: string;\n  /** A photo; initials show while it loads and if it fails. */\n  src?: string;\n  size?: AvatarSize;\n  /** circle for people, square for companies and workspaces. */\n  shape?: \"circle\" | \"square\";\n  status?: PresenceStatus;\n  ref?: Ref<HTMLSpanElement>;\n  classNames?: AvatarClassNames;\n}\n\n/** A person or account in a small space: a photo, or initials on the colour their name always gets. A photo still on\n *  its way fades in over the initials instead of popping; a presence that changes settles into its new mark. */\nexport function Avatar({\n  name,\n  src,\n  size: sizeProp,\n  shape = \"circle\",\n  status,\n  className,\n  classNames,\n  style,\n  ref,\n  ...props\n}: AvatarProps) {\n  const i18n = useMessages(\"avatar\", englishMessages);\n  const scopeDefaults = useComponentDefaults<AvatarDefaults>(\"avatar\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n\n  const [failed, setFailed] = useState<string | null>(null);\n  const photo = src && failed !== src;\n  // a photo that is not decoded yet when it mounts waits unseen and fades in over the initials when it lands; one that\n  // is already there (cached, or loaded before the page hydrated) shows at once, so a server render never hides it\n  const [pending, setPending] = useState<string | null>(null);\n  const watch = useCallback((img: HTMLImageElement | null) => {\n    if (!img) return;\n    if (!img.complete) setPending(img.getAttribute(\"src\"));\n    // A cached/decode failure can finish before React attaches onError during hydration.\n    else if (img.naturalWidth === 0) setFailed(img.getAttribute(\"src\"));\n  }, []);\n  // a presence that changes (online → away) settles into its new mark; the first render does not play it\n  const [seen, setSeen] = useState(status);\n  const [changes, setChanges] = useState(0);\n  if (seen !== status) {\n    setSeen(status);\n    setChanges((n) => n + 1);\n  }\n  return (\n    <span\n      ref={ref}\n      data-slot=\"avatar\"\n      data-shape={shape}\n      data-status={status}\n      role=\"img\"\n      aria-label={status ? i18n(\"presence\", { name, status: i18n(status) }) : name}\n      {...props}\n      className={cn(styles.root, SIZE[size], classNames?.root, className)}\n      style={{ \"--pui-avatar-hue\": hueOf(name), ...style } as CSSProperties}\n    >\n      <span aria-hidden data-slot=\"avatar-face\" className={cn(styles.face, classNames?.face)}>\n        {initialsOf(name)}\n        {photo && (\n          <img\n            key={src}\n            ref={watch}\n            data-slot=\"avatar-photo\"\n            data-pending={pending === src || undefined}\n            src={src}\n            alt=\"\"\n            draggable={false}\n            onLoad={() => setPending(null)}\n            onError={() => setFailed(src)}\n            className={cn(styles.photo, classNames?.photo)}\n          />\n        )}\n      </span>\n      {status && (\n        <PresenceDot\n          key={changes}\n          status={status}\n          size={size}\n          data-changed={changes > 0 || undefined}\n          className={cn(styles.mark, classNames?.presence)}\n        />\n      )}\n    </span>\n  );\n}\n\nexport default Avatar;\n"},{"path":"beamline/components/avatar/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/avatar/defaults.ts","content":"/** Pure allowed presentation defaults for Avatar; no component or runtime imports. */\nexport interface AvatarDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"xs\" | \"lg\" | \"xl\";\n}\n"},{"path":"beamline/components/avatar/messages.ts","type":"registry:file","target":"src/ui/beamline/components/avatar/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  online: \"Online\",\n  away: \"Away\",\n  busy: \"Do not disturb\",\n  offline: \"Offline\",\n  presence: \"{name}, {status}\",\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/mention-input/mention-input.module.css","type":"registry:file","target":"src/ui/beamline/components/mention-input/mention-input.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* The composer wears the look's field role; invalid and disabled are drawn the way every field draws them. */\n  .root {\n    position: relative;\n    display: grid;\n    border-radius: var(--pui-field-radius, var(--pui-radius-control));\n  }\n  .root[data-disabled] {\n    opacity: var(--pui-opacity-disabled);\n  }\n  /* a mention is a tint under its own words (people take the accent, channels green); a new one buds: its tint arrives\n     over --pui-motion-in, then holds still */\n  .mention {\n    border-radius: calc(var(--pui-radius-chip) - 2px);\n    background: var(--pui-accent-tint);\n    color: transparent;\n    animation: bud var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .mention[data-kind=\"channel\"] {\n    background: var(--pui-green-tint);\n  }\n  @keyframes bud {\n    from {\n      background-color: transparent;\n    }\n  }\n  /* the list stays inside a narrow composer */\n  .suggestions {\n    position: absolute;\n    z-index: var(--pui-z-popover);\n    width: min(16rem, 100%);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.root {\n  composes: m-field from global;\n}\n"},{"path":"beamline/components/mention-input/mention-input.tsx","type":"registry:file","target":"src/ui/beamline/components/mention-input/mention-input.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 {\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type KeyboardEvent,\n  type CSSProperties,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { Hash } from \"lucide-react\";\nimport { Avatar } from \"../avatar/avatar\";\nimport { Field, useFieldIds } from \"../_shared/field\";\nimport { GlidePlate } from \"../_shared/glide\";\nimport { Highlight, filterRanked } from \"../_shared/match\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport overlay from \"../_shared/overlay.module.css\";\nimport styles from \"./mention-input.module.css\";\n\nexport interface MentionPerson {\n  /** Stable identifier used to match this record across updates. Required. */\n  id: string;\n  /** Human-readable name of this record. Required. */\n  name: string;\n  /** Role assigned to this person. Omitted by default. */\n  role?: string;\n  /** URL of the person avatar. Omitted by default. */\n  avatarSrc?: string;\n}\nexport interface MentionChannel {\n  /** Stable identifier used to match this record across updates. Required. */\n  id: string;\n  /** Human-readable name of this record. Required. */\n  name: string;\n  /** Supporting content describing this item. Omitted by default. */\n  description?: string;\n}\nexport interface Mention {\n  /** Kind of record, determining how it is rendered. Required. */\n  kind: \"person\" | \"channel\";\n  /** Stable identifier used to match this record across updates. Required. */\n  id: string;\n  /** Text displayed for this item. Required. */\n  label: string;\n  /** Inclusive UTF-16 offset of the mention in the text. Required. */\n  start: number;\n  /** Exclusive UTF-16 offset of the mention in the text. Required. */\n  end: number;\n}\n/** The text and where each mention sits in it (start inclusive, end exclusive). */\nexport interface MentionValue {\n  /** Text content of this record. Required. */\n  text: string;\n  /** Mention tokens with their text offsets. Required. */\n  mentions: Mention[];\n}\n\nexport type MentionInputClassNames = Partial<\n  Record<\n    | \"root\"\n    | \"backdrop\"\n    | \"mention\"\n    | \"field\"\n    | \"mirror\"\n    | \"suggestions\"\n    | \"option\"\n    | \"optionBody\"\n    | \"optionName\"\n    | \"optionDescription\"\n    | \"fieldRoot\"\n    | \"label\"\n    | \"description\"\n    | \"error\",\n    string\n  >\n>;\n\nexport interface MentionInputProps {\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: MentionInputClassNames;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the native textarea; no ref by default. */\n  ref?: Ref<HTMLTextAreaElement>;\n  /** Receives the outer visual root; no ref by default. */\n  rootRef?: Ref<HTMLDivElement>;\n  /** Controlled text together with its mention tokens and offsets; omit to use defaultValue. */\n  value?: MentionValue;\n  /** Initial text together with its mention tokens and offsets when uncontrolled. Defaults to empty text with no mentions. */\n  defaultValue?: MentionValue;\n  /** Legacy value-change callback; omitted by default. Prefer onValueChange.\n   *  @deprecated Use onValueChange; this value callback remains supported and is omitted by default. */\n  onChange?: (value: MentionValue) => void;\n  /** Reports edited text and mention offsets; no callback by default. */\n  onValueChange?: (value: MentionValue) => void;\n  /** People offered by mention suggestions. Defaults to an empty list. */\n  people?: MentionPerson[];\n  /** Channels offered by mention suggestions. Defaults to an empty list. */\n  channels?: MentionChannel[];\n  /** Receives a mention token after it is inserted; no handler by default. */\n  onMentionAdd?: (mention: Mention) => void;\n  /** Submits the current value; no handler by default. */\n  onSubmit?: (value: MentionValue) => void;\n  /** Enter sends (Shift+Enter adds a line). Default false: ⌘/Ctrl+Enter sends. */\n  submitOnEnter?: boolean;\n  /** Text shown before a value is entered or selected. Defaults to no placeholder. */\n  placeholder?: string;\n  /** Minimum visible text rows. Defaults to 2. */\n  minRows?: number;\n  /** Maximum visible text rows before scrolling. Defaults to 8. */\n  maxRows?: number;\n  /** Suggestions above or below the caret; auto picks the side with room. */\n  placement?: \"auto\" | \"top\" | \"bottom\";\n  /** Maximum mention suggestions shown at once. Defaults to 6. */\n  maxSuggestions?: number;\n  /** Prevents interaction with the control. Defaults to false. */\n  disabled?: boolean;\n  /** Field name used in native form submission. Defaults to no form field name. */\n  name?: string;\n  /** DOM id used to connect the control and its label. Defaults to an automatically generated control id. */\n  id?: string;\n  /** Accessible name read by assistive technology. Defaults to the visible label when available. */\n  \"aria-label\"?: string;\n  /** Ids of elements that describe this control. Defaults to the field help and error ids when present. */\n  \"aria-describedby\"?: string;\n  /** A visible label above the composer, like every field's (the accessible name too). Without it, pass aria-label. */\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 composer. */\n  description?: ReactNode;\n  /** The problem and its fix; marks the composer invalid. */\n  error?: ReactNode;\n  /** Lands on the composer's frame (its documented root); classNames.fieldRoot addresses the field around it. */\n  className?: string;\n}\n\ntype Suggestion = { kind: \"person\"; item: MentionPerson } | { kind: \"channel\"; item: MentionChannel };\nconst TRIGGER = /(^|\\s)([@#])([^\\s@#]{0,30})$/;\n/** The suggestions' width at most (16rem at the default type size), so the list can be kept inside the composer. */\nconst SUGGESTIONS_WIDTH = 256;\n/** Rows of the composer as a height: its own line height (1lh follows the type size) and its block padding. */\nconst rowsHeight = (rows: number) => `calc(${rows} * 1lh + 2 * var(--pui-size-2, 0.5rem))`;\n\n/** Shifts mentions after an edit and drops the ones the edit went through. */\nfunction reconcile(prev: MentionValue, text: string): Mention[] {\n  const a = prev.text;\n  let p = 0;\n  while (p < a.length && p < text.length && a[p] === text[p]) p++;\n  let s = 0;\n  while (s < a.length - p && s < text.length - p && a[a.length - 1 - s] === text[text.length - 1 - s]) s++;\n  const delta = text.length - a.length;\n  return prev.mentions.flatMap((m) =>\n    m.end <= p ? [m] : m.start >= a.length - s ? [{ ...m, start: m.start + delta, end: m.end + delta }] : [],\n  );\n}\n\n/**\n * A composer where @people and #channels become single tokens. Type @ or # for suggestions at the caret (↑ ↓, Enter\n * or Tab to pick, Escape to close); Backspace right after a mention removes it whole. The value keeps the text and\n * where each mention sits, ready for your API.\n */\nexport function MentionInput({\n  value: valueProp,\n  defaultValue = { text: \"\", mentions: [] },\n  onChange,\n  onValueChange,\n  people = [],\n  channels = [],\n  onMentionAdd,\n  onSubmit,\n  submitOnEnter = false,\n  placeholder,\n  minRows = 2,\n  maxRows = 8,\n  placement = \"auto\",\n  maxSuggestions = 6,\n  disabled,\n  name,\n  id,\n  \"aria-label\": ariaLabel,\n  \"aria-describedby\": describedBy,\n  label,\n  hideLabel: hideLabelProp,\n  showLabel,\n  description,\n  error,\n  className,\n  classNames,\n  style,\n  ref,\n  rootRef,\n}: MentionInputProps) {\n  const i18n = useMessages(\"mention-input\", englishMessages);\n\n  useDeprecatedProp(\"MentionInput\", \"hideLabel\", \"showLabel={false}\", hideLabelProp !== undefined);\n  const hideLabel = showLabel === undefined ? hideLabelProp : !showLabel;\n  useDeprecatedProp(\"MentionInput\", \"onChange\", \"onValueChange\", onChange !== undefined);\n  const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange);\n  const set = (v: MentionValue) => {\n    setValue(v);\n    onChange?.(v);\n  };\n  const area = useRef<HTMLTextAreaElement>(null);\n  const fieldRef = useComposedRef(area, ref);\n  const mirror = useRef<HTMLDivElement>(null);\n  const backdrop = useRef<HTMLDivElement>(null);\n  const [query, setQuery] = useState<{ char: \"@\" | \"#\"; text: string; at: number } | null>(null);\n  const [active, setActive] = useState(0);\n  const [caret, setCaret] = useState({ x: 0, y: 0, line: 0, below: true });\n  const listId = useId();\n  const list = useRef<HTMLDivElement>(null);\n  const { controlId, descriptionId, errorId, aria } = useFieldIds(id, { description, error });\n  const asField = label != null;\n\n  const suggestions: Suggestion[] = useMemo(() => {\n    if (!query) return [];\n    const list: Suggestion[] =\n      query.char === \"@\"\n        ? filterRanked(people, query.text, (p) => ({ label: p.name, keywords: p.role ? [p.role] : [] })).map(\n            (item) => ({ kind: \"person\" as const, item }),\n          )\n        : filterRanked(channels, query.text, (c) => ({ label: c.name, description: c.description })).map((item) => ({\n            kind: \"channel\" as const,\n            item,\n          }));\n    return list.slice(0, maxSuggestions);\n  }, [query, people, channels, maxSuggestions]);\n  const open = !!query && suggestions.length > 0;\n\n  // where the caret is, from a mirror of the textarea's text up to it\n  const locate = () => {\n    const el = area.current,\n      m = mirror.current;\n    if (!el || !m) return;\n    const before = el.value.slice(0, el.selectionStart);\n    const hit = before.match(TRIGGER);\n    setQuery(hit ? { char: hit[2] as \"@\" | \"#\", text: hit[3], at: before.length - hit[3].length - 1 } : null);\n    if (!hit) return;\n    m.textContent = before;\n    const mark = document.createElement(\"span\");\n    mark.textContent = \"​\";\n    m.appendChild(mark);\n    const x = mark.offsetLeft,\n      y = mark.offsetTop - el.scrollTop;\n    const room = window.innerHeight - el.getBoundingClientRect().top - y;\n    setCaret({\n      x,\n      y,\n      line: parseFloat(getComputedStyle(el).lineHeight) || mark.offsetHeight,\n      below: placement === \"bottom\" || (placement === \"auto\" && room > 240),\n    });\n    setActive(0);\n  };\n  useLayoutEffect(() => {\n    if (backdrop.current && area.current) backdrop.current.scrollTop = area.current.scrollTop;\n  });\n\n  const pick = (s: Suggestion) => {\n    if (!query) return;\n    const label = `${query.char}${s.item.name}`;\n    const end = query.at + 1 + query.text.length;\n    const text = value.text.slice(0, query.at) + label + \" \" + value.text.slice(end);\n    const delta = label.length + 1 - (end - query.at);\n    const mention: Mention = { kind: s.kind, id: s.item.id, label, start: query.at, end: query.at + label.length };\n    const kept = value.mentions.map((m) => (m.start >= end ? { ...m, start: m.start + delta, end: m.end + delta } : m));\n    set({ text, mentions: [...kept, mention].sort((a, b) => a.start - b.start) });\n    onMentionAdd?.(mention);\n    setQuery(null);\n    requestAnimationFrame(() => {\n      const el = area.current;\n      if (el) {\n        const pos = query.at + label.length + 1;\n        el.focus();\n        el.setSelectionRange(pos, pos);\n      }\n    });\n  };\n  const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {\n    if (open) {\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n        e.preventDefault();\n        setActive((i) => (i + (e.key === \"ArrowDown\" ? 1 : -1) + suggestions.length) % suggestions.length);\n        return;\n      }\n      if (e.key === \"Enter\" || e.key === \"Tab\") {\n        e.preventDefault();\n        pick(suggestions[active]);\n        return;\n      }\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        e.stopPropagation();\n        setQuery(null);\n        return;\n      }\n    }\n    const el = e.currentTarget;\n    if (e.key === \"Backspace\" && el.selectionStart === el.selectionEnd) {\n      const m = value.mentions.find((x) => x.end === el.selectionStart);\n      if (m) {\n        e.preventDefault();\n        set({\n          text: value.text.slice(0, m.start) + value.text.slice(m.end),\n          mentions: reconcile(value, value.text.slice(0, m.start) + value.text.slice(m.end)),\n        });\n        requestAnimationFrame(() => el.setSelectionRange(m.start, m.start));\n        return;\n      }\n    }\n    if (\n      e.key === \"Enter\" &&\n      onSubmit &&\n      ((submitOnEnter && !e.shiftKey) || (!submitOnEnter && (e.metaKey || e.ctrlKey)))\n    ) {\n      e.preventDefault();\n      if (value.text.trim()) onSubmit(value);\n    }\n  };\n  const onText = (e: ChangeEvent<HTMLTextAreaElement>) => {\n    set({ text: e.target.value, mentions: reconcile(value, e.target.value) });\n    requestAnimationFrame(locate);\n  };\n\n  // the backdrop draws a chip under each mention; the textarea's own text sits on top\n  // each mention keeps its identity while the text around it changes (kind, id and which occurrence), so only a new one\n  // buds in (its tint arrives) and the others hold still\n  const parts: { text: string; mention?: Mention; key: string }[] = [];\n  const seen = new Map<string, number>();\n  let i = 0;\n  for (const m of value.mentions) {\n    if (m.start > i) parts.push({ text: value.text.slice(i, m.start), key: `t${parts.length}` });\n    const who = `${m.kind}:${m.id}`;\n    seen.set(who, (seen.get(who) ?? 0) + 1);\n    parts.push({ text: value.text.slice(m.start, m.end), mention: m, key: `m:${who}:${seen.get(who)}` });\n    i = m.end;\n  }\n  parts.push({ text: value.text.slice(i) + \"\\n\", key: \"tail\" });\n  const box =\n    \"col-start-1 row-start-1 whitespace-pre-wrap break-words px-3 py-[var(--pui-size-2,0.5rem)] text-base leading-[var(--pui-leading-reading)]\";\n  const composer = (\n    <div\n      ref={rootRef}\n      style={style}\n      data-slot=\"mention-input\"\n      data-disabled={disabled ? \"\" : undefined}\n      data-invalid={error ? \"\" : undefined}\n      className={cn(styles.root, classNames?.root, className)}\n    >\n      <div\n        data-slot=\"mention-input-backdrop\"\n        ref={backdrop}\n        aria-hidden\n        className={cn(box, \"overflow-hidden text-transparent\", classNames?.backdrop)}\n        style={{ maxHeight: rowsHeight(maxRows) }}\n      >\n        {parts.map((p) =>\n          p.mention ? (\n            <mark\n              data-slot=\"mention-input-mention\"\n              data-kind={p.mention.kind}\n              key={p.key}\n              className={cn(styles.mention, classNames?.mention)}\n            >\n              {p.text}\n            </mark>\n          ) : (\n            <span key={p.key}>{p.text}</span>\n          ),\n        )}\n      </div>\n      <textarea // organism-allow: raw-control -- the composer field itself (it is the component)\n        data-slot=\"mention-input-field\"\n        ref={fieldRef}\n        id={asField ? controlId : id}\n        name={name}\n        value={value.text}\n        disabled={disabled}\n        placeholder={placeholder}\n        aria-label={asField ? undefined : ariaLabel}\n        aria-describedby={\n          [describedBy, asField ? aria[\"aria-describedby\"] : undefined].filter(Boolean).join(\" \") || undefined\n        }\n        aria-invalid={error ? true : undefined}\n        aria-errormessage={asField ? errorId : undefined}\n        role=\"combobox\"\n        aria-expanded={open}\n        aria-controls={listId}\n        aria-autocomplete=\"list\"\n        aria-activedescendant={open ? `${listId}-${active}` : undefined}\n        onChange={onText}\n        onKeyDown={onKey}\n        onClick={locate}\n        onKeyUp={(e) => {\n          if (e.key.startsWith(\"Arrow\") && !open) locate();\n        }}\n        onBlur={() => setQuery(null)}\n        onScroll={(e) => {\n          if (backdrop.current) backdrop.current.scrollTop = e.currentTarget.scrollTop;\n        }}\n        className={cn(\n          box,\n          \"relative [field-sizing:content] resize-none overflow-y-auto bg-transparent text-ink caret-ink outline-none placeholder:text-ink-3\",\n          classNames?.field,\n        )}\n        style={{ minHeight: rowsHeight(minRows), maxHeight: rowsHeight(maxRows) }}\n      />\n      <div\n        data-slot=\"mention-input-mirror\"\n        ref={mirror}\n        aria-hidden\n        className={cn(box, \"pointer-events-none invisible absolute inset-x-0 top-0\", classNames?.mirror)}\n      />\n      {open && (\n        <div\n          data-slot=\"mention-input-suggestions\"\n          id={listId}\n          role=\"listbox\"\n          aria-label={query?.char === \"@\" ? i18n(\"people\") : i18n(\"channels\")}\n          ref={list}\n          // it opens out of the caret (the shared source morph, from a point) and stays inside a narrow composer\n          data-state=\"open\"\n          data-side={caret.below ? \"bottom\" : \"top\"}\n          data-align=\"start\"\n          className={cn(overlay.surface, overlay.menu, styles.suggestions, classNames?.suggestions)}\n          style={{\n            left: Math.max(0, Math.min(caret.x, (area.current?.clientWidth ?? 300) - SUGGESTIONS_WIDTH)),\n            ...(caret.below\n              ? { top: caret.y + caret.line + 8 }\n              : { bottom: (area.current?.clientHeight ?? 0) - caret.y + 8 }),\n          }}\n        >\n          <GlidePlate list={list} selector={`#${CSS.escape(`${listId}-${active}`)}`} reset={query?.char} />\n          {suggestions.map((s, k) => (\n            <div\n              key={s.item.id}\n              id={`${listId}-${k}`}\n              role=\"option\"\n              aria-selected={k === active}\n              onMouseDown={(e) => {\n                e.preventDefault();\n                pick(s);\n              }}\n              onPointerMove={() => setActive(k)}\n              data-slot=\"mention-input-option\"\n              data-active={k === active ? \"\" : undefined}\n              className={cn(overlay.item, \"cursor-pointer py-1.5\", classNames?.option)}\n            >\n              {s.kind === \"person\" ? <Avatar name={s.item.name} src={s.item.avatarSrc} size=\"xs\" /> : <Hash />}\n              <span\n                data-slot=\"mention-input-optionBody\"\n                className={cn(\"grid min-w-0 gap-px leading-snug\", classNames?.optionBody)}\n              >\n                <span data-slot=\"mention-input-optionName\" className={cn(\"truncate\", classNames?.optionName)}>\n                  <Highlight text={s.item.name} query={query?.text ?? \"\"} />\n                </span>\n                {(s.kind === \"person\" ? s.item.role : s.item.description) && (\n                  <span\n                    data-slot=\"mention-input-optionDescription\"\n                    className={cn(\"truncate text-xs text-ink-3\", classNames?.optionDescription)}\n                  >\n                    {s.kind === \"person\" ? s.item.role : s.item.description}\n                  </span>\n                )}\n              </span>\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n  if (!asField) return composer;\n  return (\n    <Field\n      label={label}\n      hideLabel={hideLabel}\n      description={description}\n      error={error}\n      controlId={controlId}\n      descriptionId={descriptionId}\n      errorId={errorId}\n      classNames={{\n        root: classNames?.fieldRoot,\n        label: classNames?.label,\n        description: classNames?.description,\n        error: classNames?.error,\n      }}\n    >\n      {composer}\n    </Field>\n  );\n}\n\nexport default MentionInput;\n"},{"path":"beamline/components/mention-input/messages.ts","type":"registry:file","target":"src/ui/beamline/components/mention-input/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  people: \"People\",\n  channels: \"Channels\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/spinner/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/defaults.ts","content":"/** Pure allowed presentation defaults for Spinner; no component or runtime imports. */\nexport interface SpinnerDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/spinner/messages.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  label: \"Loading\",\n  done: \"Done\",\n  error: \"Failed\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/spinner/spinner.module.css","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* With a delay the ring waits unseen and then fades in, so quick work never flashes it; the result (a check or a\n     cross) always shows at once. */\n  .root[data-delay] {\n    animation: appear var(--pui-fade-in) var(--pui-ease-default) var(--_delay) both;\n  }\n  @keyframes appear {\n    from {\n      opacity: 0;\n    }\n  }\n  /* The turn runs on the compositor (a CSS animation on the svg box) and costs no JS per frame. When the work ends the\n     arc closes into a full ring over the standard duration, so stopping the turn is invisible, and the check or cross\n     draws itself a beat later. All timing comes from the motion tokens; the loop period follows --pui-motion-scale. */\n  .svg[data-state=\"loading\"] {\n    animation: turn calc(0.8s * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1)) linear infinite;\n  }\n  .track {\n    stroke-opacity: 0.18;\n  }\n  .arc {\n    stroke-dasharray: 0.3 1;\n    transition: stroke-dasharray var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .svg:not([data-state=\"loading\"]) .arc {\n    stroke-dasharray: 1 1;\n  }\n  .mark {\n    stroke-dasharray: 1 1;\n    animation: draw var(--pui-duration-standard) var(--pui-ease-enter) var(--pui-duration-fast) both;\n  }\n  @keyframes turn {\n    to {\n      transform: rotate(1turn);\n    }\n  }\n  @keyframes draw {\n    from {\n      stroke-dashoffset: 1;\n    }\n    to {\n      stroke-dashoffset: 0;\n    }\n  }\n  .svg[data-paused] {\n    animation-play-state: paused;\n  }\n  .svg[data-reduced],\n  .svg[data-reduced] .mark {\n    animation: none;\n  }\n  /* Reduction retains the loading/result shape without a decorative loop. */\n  @media (prefers-reduced-motion: reduce) {\n    .svg[data-state=\"loading\"],\n    .mark {\n      animation: none;\n    }\n  }\n}\n"},{"path":"beamline/components/spinner/spinner.tsx","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SpinnerDefaults } from \"./defaults\";\nexport type { SpinnerDefaults } from \"./defaults\";\n\nimport { useRef, type CSSProperties, type Ref } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useActive } from \"../../hooks/use-active\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./spinner.module.css\";\n\nexport interface SpinnerClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the svg part; no override by default. */\n  svg?: string;\n  /** Classes for the track part; no override by default. */\n  track?: string;\n  /** Classes for the arc part; no override by default. */\n  arc?: string;\n  /** Classes for the mark part; no override by default. */\n  mark?: string;\n}\n\nexport interface SpinnerProps {\n  /** `success` draws a check out of the ring, `error` a cross; both stop spinning. */\n  state?: \"loading\" | \"success\" | \"error\";\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Accessible name while loading. */\n  label?: string;\n  /** ms to wait before it shows: work that ends sooner never flashes a spinner (about 300 is a good start). Default 0. */\n  delay?: number;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLSpanElement>;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: SpinnerClassNames;\n}\n\n/** Icon box from the foundation icon steps (§14.2): sm 14, md 16, lg 20. */\nconst PX = { sm: \"var(--pui-icon-sm)\", md: \"var(--pui-icon-md)\", lg: \"var(--pui-icon-lg)\" } as const;\n\n/**\n * A ring that spins while work runs and closes into a drawn check (or cross) when it ends — the result is a shape as\n * well as a colour. With reduced motion the loading ring holds still.\n */\nexport function Spinner({\n  state = \"loading\",\n  size: sizeProp,\n  label: labelProp,\n  delay = 0,\n  className,\n  classNames,\n  style,\n  ref,\n}: SpinnerProps) {\n  const i18n = useMessages(\"spinner\", englishMessages);\n  const label = labelProp === undefined ? i18n(\"label\") : labelProp;\n\n  const scopeDefaults = useComponentDefaults<SpinnerDefaults>(\"spinner\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n  const host = useRef<HTMLSpanElement>(null);\n  const attach = useComposedRef(host, ref);\n  const active = useActive(host);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = tokens.reduced || managedReduced;\n\n  const box = PX[size];\n  const done = state !== \"loading\";\n  const tone = state === \"success\" ? \"text-green\" : state === \"error\" ? \"text-red\" : \"text-ink-2\";\n  return (\n    <span\n      ref={attach}\n      data-slot=\"spinner\"\n      data-state={state}\n      data-delay={delay > 0 && state === \"loading\" ? \"\" : undefined}\n      role={done ? \"img\" : \"status\"}\n      aria-label={state === \"success\" ? i18n(\"done\") : state === \"error\" ? i18n(\"error\") : label}\n      className={cn(styles.root, \"relative inline-grid shrink-0 place-items-center\", tone, classNames?.root, className)}\n      style={{ width: box, height: box, \"--_delay\": `${delay}ms`, ...style } as CSSProperties}\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        width=\"100%\"\n        height=\"100%\"\n        fill=\"none\"\n        aria-hidden=\"true\"\n        data-state={state}\n        data-slot=\"spinner-svg\"\n        data-reduced={reduced ? \"\" : undefined}\n        data-paused={active ? undefined : \"\"}\n        className={cn(styles.svg, classNames?.svg)}\n      >\n        <circle\n          data-slot=\"spinner-track\"\n          className={cn(styles.track, classNames?.track)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n        />\n        <circle\n          data-slot=\"spinner-arc\"\n          className={cn(styles.arc, classNames?.arc)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          pathLength={1}\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n          strokeLinecap=\"round\"\n          transform=\"rotate(-90 12 12)\"\n        />\n        {state === \"success\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M7.5 12.5l3 3 6-6.5\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        )}\n        {state === \"error\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M8.5 8.5l7 7M15.5 8.5l-7 7\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n          />\n        )}\n      </svg>\n    </span>\n  );\n}\n\nexport default Spinner;\n"},{"path":"beamline/hooks/use-active.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-active.ts","content":"\"use client\";\n\nimport { useContext, useEffect, useState, type RefObject } from \"react\";\n\nimport { ActivityContext } from \"../runtime/activity\";\n\n/**\n * Whether an animated element should be doing work: it is (nearly) on screen and the tab is visible. Every\n * continuously animated component gates its loop, shader or CSS animation on this, so an effect scrolled out of view,\n * behind another tab, or in a collapsed panel costs nothing. One IntersectionObserver per element; no polling.\n */\nexport function useActive(\n  ref: RefObject<Element | null>,\n  {\n    margin = \"120px\",\n    key,\n    idle = true,\n  }: {\n    margin?: string;\n    /** Re-attach when this changes (an element that mounts later). */\n    key?: unknown;\n    /** Pause continuous work at the host idle boundary. Finite sequences set false so they finish. */\n    idle?: boolean;\n  } = {},\n) {\n  const active = useContext(ActivityContext);\n  const [inView, setInView] = useState(false);\n  const [visible, setVisible] = useState(\n    () => typeof document === \"undefined\" || document.visibilityState === \"visible\",\n  );\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { rootMargin: margin });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, margin, key]);\n  useEffect(() => {\n    const on = () => setVisible(document.visibilityState === \"visible\");\n    document.addEventListener(\"visibilitychange\", on);\n    return () => document.removeEventListener(\"visibilitychange\", on);\n  }, []);\n  return (!idle || active) && inView && visible;\n}\n"},{"path":"beamline/hooks/use-composed-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-composed-ref.ts","content":"\"use client\";\n\nimport { useCallback, type Ref, type RefObject } from \"react\";\n\n/** Keep a control's measurement/focus ref and the caller's ref, including React 19 callback cleanup. */\nexport function useComposedRef<T>(internal: RefObject<T | null>, external?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      internal.current = node;\n      if (typeof external === \"function\") {\n        const cleanup = external(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            internal.current = null;\n            cleanup();\n          };\n      } else if (external) external.current = node;\n    },\n    [internal, external],\n  );\n}\n"},{"path":"beamline/hooks/use-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-media-query.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-media-query.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// one MediaQueryList per query: React reads the snapshot on every render, and a part that renders thirty times a\n// second (a playing audio player's clock) would otherwise ask the browser for a new list each time\nconst lists = new Map<string, MediaQueryList>();\nconst list = (query: string) => {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return undefined;\n  let m = lists.get(query);\n  if (!m) lists.set(query, (m = window.matchMedia(query)));\n  return m;\n};\n\n/** True while the media query matches; false on the server and in the first client render of SSR output. */\nexport function useMediaQuery(query: string): boolean {\n  return useSyncExternalStore(\n    (notify) => {\n      const m = list(query);\n      m?.addEventListener(\"change\", notify);\n      return () => m?.removeEventListener(\"change\", notify);\n    },\n    () => list(query)?.matches ?? false,\n    () => false,\n  );\n}\n\n/** Phone-width layouts: below 768px. */\nexport const usePhone = () => useMediaQuery(\"(max-width: 767px)\");\n"},{"path":"beamline/hooks/use-motion-tokens.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-motion-tokens.ts","content":"\"use client\";\n\nimport { useMemo, type RefObject } from \"react\";\nimport { useAppearanceRoot } from \"../runtime/appearance\";\nimport { useResolvedAppearance } from \"./use-resolved-appearance\";\nimport { motionAt, motionEase, motionTokens, type MotionTokens } from \"../lib/motion-tokens\";\nimport { semanticMotion } from \"../lib/motion-source\";\n\nconst durationToken = (name: string) =>\n  name === \"exit\" ? \"motion-out\" : name === \"in\" || name === \"out\" ? `motion-${name}` : `duration-${name}`;\nconst easeToken = (name: string) => `ease-${name === \"inOut\" ? \"in-out\" : name}`;\nconst REQUEST = {\n  times: [\n    ...Object.keys(semanticMotion.duration).map(durationToken),\n    ...Object.keys(semanticMotion.spring).map((name) => `spring-${name}`),\n    ...Object.keys(semanticMotion.stagger).map((name) => `stagger-${name}`),\n    \"fade-in\",\n    \"fade-out\",\n  ],\n  easings: [...Object.keys(semanticMotion.ease).map(easeToken), \"ease-spring\", \"ease-spring-bounce\", \"ease-overshoot\"],\n  numbers: [\"motion-scale\", \"motion-look\", \"press-scale\"],\n  // timing only: the host's own type would make every control resolve differently from the page around it\n  typography: false,\n};\n\n/**\n * Resolve each timing at the actual host; omitted host uses the nearest managed root. The first/server snapshot is\n * the pure foundation default. Local CSS values, scope changes, font readiness, OS preference and explicit refresh\n * update the same consumer; no document-wide first-root fallback or persistent global listener.\n */\nexport function useMotionTokens(hostRef?: RefObject<HTMLElement | null>): MotionTokens & {\n  ready: boolean;\n  /** Reduced or none: movement, springs and staggers are instant. */\n  reduced: boolean;\n  mode: \"full\" | \"reduced\" | \"none\";\n  /** Opacity-only arrival and departure (seconds): kept under reduced, instant under none (APPEARANCE A06). */\n  fade: { in: number; out: number };\n} {\n  const root = useAppearanceRoot();\n  const scopeRef = useMemo(() => ({ current: root }), [root]);\n  const resolved = useResolvedAppearance(hostRef ?? scopeRef, REQUEST);\n  return useMemo(() => {\n    if (!resolved.ready)\n      return {\n        ...motionTokens,\n        ready: false,\n        reduced: false,\n        mode: \"full\" as const,\n        fade: { in: motionTokens.duration.in, out: motionTokens.duration.out },\n      };\n    const reduced = resolved.reducedMotion;\n    const speed = (resolved.numbers[\"motion-scale\"] ?? 1) * (resolved.numbers[\"motion-look\"] ?? 1);\n    const fade =\n      resolved.motionMode === \"none\"\n        ? { in: 0.001, out: 0.001 }\n        : {\n            in: resolved.times[\"fade-in\"] ?? semanticMotion.duration.in * speed,\n            out: resolved.times[\"fade-out\"] ?? semanticMotion.duration.out * speed,\n          };\n    const factor = reduced ? 0.001 / semanticMotion.duration.standard : speed;\n    const base = motionAt(factor);\n    const duration = Object.fromEntries(\n      Object.entries(base.duration).map(([name, fallback]) => [\n        name,\n        reduced ? 0.001 : (resolved.times[durationToken(name)] ?? fallback),\n      ]),\n    ) as MotionTokens[\"duration\"];\n    const stagger = Object.fromEntries(\n      Object.entries(base.stagger).map(([name, fallback]) => [\n        name,\n        reduced ? 0 : (resolved.times[`stagger-${name}`] ?? fallback),\n      ]),\n    ) as MotionTokens[\"stagger\"];\n    const ease = Object.fromEntries(\n      Object.entries(base.ease).map(([name, fallback]) => [\n        name,\n        motionEase(resolved.easings[easeToken(name)], fallback),\n      ]),\n    ) as MotionTokens[\"ease\"];\n    const spring = { ...base.spring };\n    for (const name of Object.keys(semanticMotion.spring) as (keyof typeof semanticMotion.spring)[])\n      spring[name] = {\n        ...base.spring[name],\n        visualDuration: reduced ? 0.001 : (resolved.times[`spring-${name}`] ?? base.spring[name].visualDuration),\n        bounce: reduced ? 0 : base.spring[name].bounce,\n      };\n    return {\n      ...base,\n      ready: true,\n      reduced,\n      mode: resolved.motionMode,\n      fade,\n      duration,\n      stagger,\n      ease,\n      spring,\n      pressScale: resolved.numbers[\"press-scale\"] ?? base.pressScale,\n      css: {\n        spring: resolved.easings[\"ease-spring\"] ?? base.css.spring,\n        springBounce: resolved.easings[\"ease-spring-bounce\"] ?? base.css.springBounce,\n        overshoot: resolved.easings[\"ease-overshoot\"] ?? base.css.overshoot,\n      },\n    };\n  }, [resolved]);\n}\n"},{"path":"beamline/hooks/use-reduced-motion-safe.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-reduced-motion-safe.ts","content":"\"use client\";\n\nimport { useMediaQuery } from \"./use-media-query\";\nimport { useScopeMotion } from \"../runtime/appearance\";\n\n/** Managed motion policy plus the OS preference, with a stable server snapshot. */\nexport function useReducedMotionSafe() {\n  const requested = useScopeMotion();\n  const os = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  return requested !== \"auto\" || os;\n}\n"},{"path":"beamline/lib/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":"Mention input is in src/ui/beamline/components/mention-input/mention-input.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/mention-input"}}