{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"document-viewer","type":"registry:component","title":"Document viewer","description":"A PDF or a paged document read and marked up in place: page thumbnails beside it, zoom to the width, the page or a percentage, pages by keyboard, a search that marks every hit and steps through them, and annotations (highlight text or mark an area, attach a comment) listed beside the pages, each jumping to its place; pages not drawn yet are hatched, and loading, empty, failed and password-locked documents each say so. pdf.js loads only when a PDF is opened.","categories":["display"],"dependencies":["@radix-ui/react-dialog@1.1.23","@radix-ui/react-direction@1.1.4","@radix-ui/react-dropdown-menu@2.1.24","@radix-ui/react-label@2.1.15","@radix-ui/react-progress@1.1.16","@radix-ui/react-slot@1.3.3","@radix-ui/react-tooltip@1.2.16","clsx@^2.1.1","lucide-react@^1.48.0","motion@^13.4.4","pdfjs-dist@6.4.299","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/_shared/delta-ghost.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/delta-ghost.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* The ghost spans the two real values. A loss sits under the fill in a pale wash of its colour, holds for a flash and\n     drains toward the new end; a gain sits over the fill, lit, and cools. Reduced motion: shown still, then gone. */\n  .ghost {\n    --g-color: var(--pui-ink-3);\n    position: absolute;\n    inset-block: 0;\n    inset-inline-start: calc(var(--g-lo) * 100%);\n    width: calc((var(--g-hi) - var(--g-lo)) * 100%);\n    pointer-events: none;\n  }\n  .ghost[data-kind=\"loss\"] {\n    z-index: 0;\n    background: color-mix(in oklab, var(--g-color) 42%, transparent);\n    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--g-color) 55%, transparent);\n    animation: drain calc(var(--pui-duration-flash) * 2.4) var(--pui-ease-standard) both;\n  }\n  .ghost[data-kind=\"gain\"] {\n    z-index: 2;\n    /* a lift of the fill's own light, not a white flash */\n    /* organism-allow-start: literal-colour -- a lift of the fill's own light (white at low alpha over any tone), not a theme colour */\n    background: light-dark(rgb(255 255 255 / 0.4), rgb(255 255 255 / 0.26));\n    box-shadow: inset 0 0 0 1px light-dark(rgb(255 255 255 / 0.55), rgb(255 255 255 / 0.3));\n    /* organism-allow-end */\n    animation: cool calc(var(--pui-duration-flash) * 2.2) var(--pui-ease-exit) both;\n  }\n  /* reduced motion: the same moment, without movement */\n  .ghost[data-reduced] {\n    /* organism-allow: literal-motion -- reduced motion collapses duration tokens to 1ms; this still-frame must stay readable for its moment */\n    animation: hold 1.1s steps(1, end) both;\n  }\n  /* the loss drains toward where the bar now ends (the start side of the ghost) */\n  @keyframes drain {\n    0%,\n    46% {\n      clip-path: inset(0 0 0 0);\n    }\n    100% {\n      clip-path: inset(0 100% 0 0);\n    }\n  }\n  .ghost[data-kind=\"loss\"]:dir(rtl) {\n    animation-name: drain-rtl;\n  }\n  @keyframes drain-rtl {\n    0%,\n    46% {\n      clip-path: inset(0 0 0 0);\n    }\n    100% {\n      clip-path: inset(0 0 0 100%);\n    }\n  }\n  @keyframes cool {\n    0%,\n    25% {\n      opacity: 1;\n    }\n    to {\n      opacity: 0;\n    }\n  }\n  @keyframes hold {\n    from {\n      opacity: 1;\n    }\n    to {\n      opacity: 0;\n    }\n  }\n}\n"},{"path":"beamline/components/_shared/delta-ghost.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/delta-ghost.tsx","content":"\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./delta-ghost.module.css\";\n\n/**\n * Magnitude memory for a bar (research/morphing-interfaces-2026-10-04, M4). When a value drops, the part it lost stays\n * as a pale ghost for a moment and then drains toward the new end; when it rises, the part it gained arrives lit and\n * cools. You see how much changed, not only the new value. The ghost always spans two real values (before and after),\n * never an invented in-between. A run of changes the same way extends one ghost; a reversal starts a new one. With\n * reduced motion the ghost is shown still for the same moment and then removed, so the information stays.\n */\nexport interface Ghost {\n  /** Fractions of the track (0–1): where the bar was and where it is now. */\n  from: number;\n  to: number;\n  /** Increments per change, to restart the CSS animation. */\n  key: number;\n}\n\nexport function useDeltaGhost(value: number | null | undefined) {\n  const before = useRef(value);\n  const [ghost, setGhost] = useState<Ghost | null>(null);\n  useLayoutEffect(() => {\n    const was = before.current;\n    before.current = value;\n    if (was == null || value == null || !Number.isFinite(was) || !Number.isFinite(value) || was === value) return;\n    setGhost((g) => {\n      const sameWay = g && Math.sign(g.to - g.from) === Math.sign(value - was) && g.to === was;\n      return { from: sameWay ? g.from : was, to: value, key: (g?.key ?? 0) + 1 };\n    });\n  }, [value]);\n  const clear = useCallback(() => setGhost(null), []);\n  return [ghost, clear] as const;\n}\n\n/** The ghost itself, absolutely placed in a relative, clipped track. `color` is the fill's colour. */\nexport function DeltaGhost({\n  ghost,\n  onDone,\n  color,\n  className,\n}: {\n  ghost: Ghost | null;\n  onDone: () => void;\n  color?: string;\n  className?: string;\n}) {\n  const reduced = useReducedMotionSafe();\n  if (!ghost) return null;\n  const lo = Math.max(0, Math.min(1, Math.min(ghost.from, ghost.to)));\n  const hi = Math.max(0, Math.min(1, Math.max(ghost.from, ghost.to)));\n  if (hi - lo < 0.002) return null;\n  return (\n    <span\n      key={ghost.key}\n      aria-hidden\n      data-slot=\"delta-ghost\"\n      data-kind={ghost.to < ghost.from ? \"loss\" : \"gain\"}\n      data-reduced={reduced ? \"\" : undefined}\n      className={cn(styles.ghost, className)}\n      style={{ \"--g-lo\": lo, \"--g-hi\": hi, ...(color ? { \"--g-color\": color } : {}) } as CSSProperties}\n      onAnimationEnd={(e) => {\n        if (e.target === e.currentTarget) onDone();\n      }}\n    />\n  );\n}\n"},{"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/icon-action.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/icon-action.module.css","content":"@layer properties, theme, base, components, utilities;\n/* The small square action that sits inside another element: a field's clear and open buttons, a chip's remove, a\n   toast's close, a carousel's previous and next. One recipe so every one of them answers alike: quiet ink at rest,\n   the hover plate at once, the press at once, both let go over --pui-motion-out. Its corner is the chip radius: under\n   the nested-corner rule (.nest-corner) a part whose own corner is the chip never changes inside a rounded frame. */\n@layer components {\n  .root {\n    position: relative;\n    display: grid;\n    flex-shrink: 0;\n    place-items: center;\n    inline-size: var(--_size);\n    block-size: var(--_size);\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink-3);\n    /* opacity and translate stay in the list so a .reveal action keeps its own arrival whichever rule wins */\n    transition:\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      transform var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .root:hover:not(:disabled),\n  .root[data-pressed] {\n    background-color: var(--pui-hover);\n    color: var(--pui-ink);\n  }\n  .root:hover {\n    transition-duration: 0ms;\n  }\n  .root:active:not(:disabled) {\n    transform: scale(var(--pui-press-scale));\n    transition-duration: 0ms;\n  }\n  .root:disabled {\n    cursor: default;\n    opacity: 0.4;\n  }\n  .root[data-tone=\"ink-2\"] {\n    color: var(--pui-ink-2);\n  }\n  .root[data-tone=\"inherit\"] {\n    color: inherit;\n  }\n  .root[data-tone=\"inherit\"]:hover:not(:disabled) {\n    color: inherit;\n  }\n  /* on an inverted (ink) surface: inverse ink, a faint inverse plate */\n  .root[data-tone=\"inverse\"] {\n    color: color-mix(in oklab, var(--pui-ink-inverse) 72%, transparent);\n  }\n  .root[data-tone=\"inverse\"]:hover:not(:disabled),\n  .root[data-tone=\"inverse\"][data-pressed] {\n    background-color: color-mix(in oklab, var(--pui-ink-inverse) 12%, transparent);\n    color: var(--pui-ink-inverse);\n  }\n\n  .xs {\n    --_size: 1.25rem;\n  }\n  /* a 20px action still takes a 24px hit (WCAG 2.5.8): the bleed is outside the drawn square */\n  .xs::after {\n    position: absolute;\n    inset: calc((var(--pui-hit-min) - 1.25rem) / -2);\n    content: \"\";\n  }\n  .sm {\n    --_size: 1.5rem;\n  }\n  .md {\n    --_size: 1.75rem;\n  }\n  .lg {\n    --_size: 2rem;\n  }\n}\n"},{"path":"beamline/components/_shared/icon-action.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/icon-action.tsx","content":"\"use client\";\n// organism-allow-file: raw-control -- the shared part every element draws this control from\n\nimport type { ButtonHTMLAttributes, Ref } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./icon-action.module.css\";\n\n/** 20 · 24 · 28 · 32 px. A 20px action keeps a 24px hit outside its square. */\nexport type IconActionSize = \"xs\" | \"sm\" | \"md\" | \"lg\";\n\nexport interface IconActionProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n  /** The action's name: the button holds only an icon. */\n  \"aria-label\": string;\n  size?: IconActionSize;\n  /** Rest ink: ink-3 (the default, an action beside content), ink-2 (an action that is the row's point), inverse (on\n   *  an inverted ink surface) or inherit (on a painted surface that brings its own colours). */\n  tone?: \"ink-3\" | \"ink-2\" | \"inverse\" | \"inherit\";\n  /** Holds the hover plate while what it opens is open, or while it is switched on. */\n  pressed?: boolean;\n  ref?: Ref<HTMLButtonElement>;\n}\n\n/**\n * The small square icon action inside another element (clear, remove, dismiss, previous, next, retry). One recipe for\n * all of them; a full-size action with words is a Button, and Button with iconOnly is the standalone square.\n */\nexport function IconAction({\n  size = \"sm\",\n  tone = \"ink-3\",\n  pressed,\n  type = \"button\",\n  className,\n  ref,\n  ...props\n}: IconActionProps) {\n  return (\n    <button\n      ref={ref}\n      type={type}\n      data-tone={tone === \"ink-3\" ? undefined : tone}\n      data-pressed={pressed ? \"\" : undefined}\n      className={cn(styles.root, styles[size], className)}\n      {...props}\n    />\n  );\n}\n"},{"path":"beamline/components/_shared/menu.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/menu.tsx","content":"\"use client\";\n\nimport {\n  Fragment,\n  useCallback,\n  useRef,\n  useState,\n  type ComponentType,\n  type FocusEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { Check, ChevronRight } from \"lucide-react\";\nimport { useDirection } from \"@radix-ui/react-direction\";\nimport { cn } from \"../../lib/utils\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport overlay from \"./overlay.module.css\";\n\nexport interface MenuClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the trigger part; no override by default. */\n  trigger?: string;\n  /** Classes for the content part; no override by default. */\n  content?: string;\n  /** Classes for the submenu part; no override by default. */\n  submenu?: string;\n  /** Classes for the indicator part; no override by default. */\n  indicator?: string;\n  /** Classes for the item part; no override by default. */\n  item?: string;\n  /** Classes for the label part; no override by default. */\n  label?: string;\n  /** Classes for the shortcut part; no override by default. */\n  shortcut?: string;\n  /** Classes for the check part; no override by default. */\n  check?: string;\n  /** Classes for the separator part; no override by default. */\n  separator?: string;\n  /** Classes for the heading part; no override by default. */\n  heading?: string;\n  /** Classes for the chevron part; no override by default. */\n  chevron?: string;\n}\n\n/** One row of any menu in the system (dropdown, context menu, menu bar). The same data renders the same everywhere. */\nexport interface MenuItem {\n  /** Stable key; the label is used when absent. */\n  id?: string;\n  /** Text displayed for this item. Required. */\n  label: ReactNode;\n  /** Icon element displayed with this item. Omitted by default. */\n  icon?: ReactNode;\n  /** A shortcut shown at the end, e.g. \"⌘D\". The app binds the key. */\n  shortcut?: string;\n  /** Runs when this item is activated. Omitted by default. */\n  onSelect?: (event: Event) => void;\n  /** Whether this option is unavailable for selection. Omitted by default. */\n  disabled?: boolean;\n  /** danger: a destructive action (red). */\n  tone?: \"danger\";\n  /** A line above this item. */\n  separatorBefore?: boolean;\n  /** A small heading above this item, starting a group. */\n  heading?: string;\n  /** A checkable item; shows a check when true. */\n  checked?: boolean;\n  /** checkbox (default when checked is set) stays open when toggled; radio is one of a set and closes the menu. */\n  type?: \"checkbox\" | \"radio\";\n  /** A submenu. */\n  items?: MenuItem[];\n}\n\ntype Part = ComponentType<any>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Radix menu namespaces share part names, not prop types\n/** The Radix parts a menu needs; DropdownMenu, ContextMenu and Menubar all provide them. */\nexport interface MenuKit {\n  Content: Part;\n  SubContent: Part;\n  Item: Part;\n  CheckboxItem: Part;\n  ItemIndicator: Part;\n  Sub: Part;\n  SubTrigger: Part;\n  Separator: Part;\n  Label: Part;\n  Portal: Part;\n}\n\n/** The reading direction a menu should open in: an explicit one, else the direction in force around its trigger (read\n *  when the trigger mounts and again as it is pressed, right-clicked or keyed), else a Direction provider. Without it\n *  Radix falls back to \"ltr\", so a portalled menu on an RTL page reads left to right, opens submenus to the right and\n *  answers → instead of ←. Spread `triggerProps` on the Radix trigger and pass `dir` to the Radix root. */\nexport function useMenuDirection(explicit?: \"ltr\" | \"rtl\") {\n  const provided = useDirection();\n  const [around, setAround] = useState<\"ltr\" | \"rtl\">();\n  const read = useCallback((el: Element | null) => {\n    if (el) setAround(getComputedStyle(el).direction === \"rtl\" ? \"rtl\" : \"ltr\");\n  }, []);\n  const measure = useCallback((e: { currentTarget: Element }) => read(e.currentTarget), [read]);\n  return {\n    dir: explicit ?? around ?? provided,\n    triggerProps: {\n      ref: read,\n      onPointerDownCapture: measure,\n      onKeyDownCapture: measure,\n      onContextMenuCapture: measure,\n    },\n  };\n}\n\n/** The floating list: one surface and one highlight plate that glides between rows, under the pointer and the arrow\n *  keys alike. Extra props (align, side, sideOffset) go to the Radix content part. */\nexport function MenuSurface({\n  kit,\n  sub = false,\n  label,\n  className,\n  classNames,\n  children,\n  ref,\n  ...rest\n}: {\n  kit: MenuKit;\n  sub?: boolean;\n  label?: string;\n  className?: string;\n  classNames?: MenuClassNames;\n  children: ReactNode;\n  ref?: Ref<HTMLDivElement>;\n} & Record<string, unknown>) {\n  const surface = useRef<HTMLDivElement | null>(null);\n  // the portal's presence passes its own ref (it watches the exit animation); keep both\n  const attach = useComposedRef(surface, ref);\n  const [plate, setPlate] = useState<{ y: number; h: number; jump: boolean } | null>(null);\n  const Part = sub ? kit.SubContent : kit.Content;\n  const { onFocusOutside, ...others } = rest as { onFocusOutside?: (event: Event) => void };\n  return (\n    <Part\n      ref={attach}\n      data-slot={sub ? \"menu-submenu\" : \"menu\"}\n      aria-label={label}\n      collisionPadding={8}\n      loop\n      {...(sub ? { sideOffset: 2, alignOffset: -4 } : {})}\n      {...others}\n      // a surface still playing its exit is already closed: the focus that moves into the next menu of a menu bar\n      // must not count as \"outside\" and close the whole bar (Radix would reset the bar's open menu to none)\n      onFocusOutside={(event: Event) => {\n        if (surface.current?.dataset.state === \"closed\") event.preventDefault();\n        onFocusOutside?.(event);\n      }}\n      className={cn(\n        overlay.surface,\n        overlay.menu,\n        \"relative min-w-[11rem]\",\n        sub ? classNames?.submenu : classNames?.content,\n        className,\n      )}\n      // React events cross portals: only rows of this surface move its plate (a submenu has its own)\n      onFocusCapture={(e: FocusEvent<HTMLDivElement>) => {\n        const item = (e.target as HTMLElement).closest<HTMLElement>(\"[data-menu-item]\");\n        if (item && !surface.current?.contains(item)) return;\n        if (!item) {\n          setPlate((p) => (p ? { ...p, h: 0 } : p));\n          return;\n        }\n        setPlate((p) => ({ y: item.offsetTop, h: item.offsetHeight, jump: !p?.h }));\n      }}\n      // while a submenu is open, its trigger keeps the plate\n      onPointerLeave={() => {\n        if (!surface.current?.querySelector(\":scope > [data-menu-item][data-state=open]\"))\n          setPlate((p) => (p ? { ...p, h: 0 } : p));\n      }}\n    >\n      <span\n        aria-hidden\n        data-slot=\"menu-indicator\"\n        className={cn(overlay.glide, classNames?.indicator)}\n        data-on={plate?.h ? \"\" : undefined}\n        data-jump={plate?.jump ? \"\" : undefined}\n        style={plate ? { transform: `translateY(${plate.y}px)`, height: plate.h || undefined } : undefined}\n      />\n      {children}\n    </Part>\n  );\n}\n\n/** Rows for a MenuItem list, in any kit. */\nexport function MenuItems({\n  kit,\n  items,\n  container,\n  classNames,\n}: {\n  kit: MenuKit;\n  items: MenuItem[];\n  container?: HTMLElement | null;\n  classNames?: MenuClassNames;\n}) {\n  return items.map((item, i) => {\n    const tone = item.tone;\n    const common = {\n      \"data-menu-item\": \"\",\n      \"data-slot\": \"menu-item\",\n      \"data-glide\": \"\",\n      \"data-tone\": tone,\n      className: cn(overlay.item, classNames?.item),\n      disabled: item.disabled,\n    };\n    const content = (\n      <>\n        {item.icon}\n        <span data-slot=\"menu-label\" dir=\"auto\" className={cn(\"min-w-0 truncate\", classNames?.label)}>\n          {item.label}\n        </span>\n        {item.shortcut && (\n          <span data-slot=\"menu-shortcut\" dir=\"ltr\" className={cn(overlay.shortcut, classNames?.shortcut)}>\n            {item.shortcut}\n          </span>\n        )}\n      </>\n    );\n    let node: ReactNode;\n    if (item.items?.length) {\n      node = (\n        <kit.Sub>\n          <kit.SubTrigger {...common}>\n            {content}\n            <ChevronRight data-slot=\"menu-chevron\" className={cn(\"ms-auto rtl:rotate-180\", classNames?.chevron)} />\n          </kit.SubTrigger>\n          {container !== null && (\n            <kit.Portal container={container}>\n              <MenuSurface kit={kit} sub classNames={classNames}>\n                <MenuItems kit={kit} items={item.items} container={container} classNames={classNames} />\n              </MenuSurface>\n            </kit.Portal>\n          )}\n        </kit.Sub>\n      );\n    } else if (item.checked !== undefined && item.type === \"radio\") {\n      node = (\n        <kit.Item {...common} role=\"menuitemradio\" aria-checked={item.checked} onSelect={item.onSelect}>\n          {content}\n          <span data-slot=\"menu-check\" className={cn(overlay.check, classNames?.check)}>\n            {item.checked && <Check />}\n          </span>\n        </kit.Item>\n      );\n    } else if (item.checked !== undefined) {\n      node = (\n        <kit.CheckboxItem\n          {...common}\n          checked={item.checked}\n          onSelect={(e: Event) => {\n            e.preventDefault();\n            item.onSelect?.(e);\n          }}\n        >\n          {content}\n          <kit.ItemIndicator data-slot=\"menu-check\" className={cn(overlay.check, classNames?.check)}>\n            <Check />\n          </kit.ItemIndicator>\n        </kit.CheckboxItem>\n      );\n    } else {\n      node = (\n        <kit.Item {...common} onSelect={item.onSelect}>\n          {content}\n        </kit.Item>\n      );\n    }\n    return (\n      <Fragment key={item.id ?? (typeof item.label === \"string\" ? item.label : i)}>\n        {item.separatorBefore && i > 0 && (\n          <kit.Separator data-slot=\"menu-separator\" className={cn(overlay.separator, classNames?.separator)} />\n        )}\n        {item.heading && (\n          <kit.Label data-slot=\"menu-heading\" className={cn(overlay.heading, classNames?.heading)}>\n            {item.heading}\n          </kit.Label>\n        )}\n        {node}\n      </Fragment>\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/_shared/use-return-focus.ts","type":"registry:file","target":"src/ui/beamline/components/_shared/use-return-focus.ts","content":"\"use client\";\n\nimport { useRef } from \"react\";\nimport { useAppearanceRoot } from \"../../runtime/appearance\";\n\n/**\n * Where focus goes when a modal layer closes. Radix returns it to its own Trigger; a layer opened another way (a\n * controlled `open`, a menu item, a shortcut) would drop focus on <body>. Spread the result on the Radix content: it\n * remembers what had focus as the layer opened and returns there, unless the caller's own handler prevents it.\n * If that opener is removed, return to its surviving owning region, or the managed scope's visual root.\n */\nexport function useReturnFocus({\n  onOpenAutoFocus,\n  onCloseAutoFocus,\n}: { onOpenAutoFocus?: (event: Event) => void; onCloseAutoFocus?: (event: Event) => void } = {}) {\n  track();\n  const before = useRef<HTMLElement | null>(null);\n  const regions = useRef<HTMLElement[]>([]);\n  const scope = useAppearanceRoot();\n  return {\n    onOpenAutoFocus: (event: Event) => {\n      before.current = outside(document.activeElement, event.currentTarget as Element | null);\n      const owners: HTMLElement[] = [];\n      let ancestor = before.current;\n      while (ancestor) {\n        if (ancestor !== document.body && ancestor.matches(\"[data-return-region]\")) owners.push(ancestor);\n        ancestor = ancestor.parentElement;\n      }\n      const root = scope ?? before.current?.closest<HTMLElement>(\".pui\");\n      if (root && root !== document.body && !owners.includes(root)) owners.push(root);\n      regions.current = owners;\n      onOpenAutoFocus?.(event);\n    },\n    /** `layer` is the layer's own element, for a caller that runs this itself instead of Radix. */\n    onCloseAutoFocus: (event: Event, layer: Element | null = event.currentTarget as Element | null) => {\n      onCloseAutoFocus?.(event);\n      if (event.defaultPrevented) return;\n      // a layer whose field took focus as it mounted (autoFocus) never hears that it opened (Radix skips the mount\n      // focus): the opener is then the last element focused outside it\n      const el =\n        before.current && before.current.isConnected && !layer?.contains?.(before.current)\n          ? before.current\n          : outside(null, layer);\n      if (el && el !== document.body && el.isConnected) {\n        event.preventDefault();\n        el.focus({ preventScroll: true });\n        return;\n      }\n      const region = regions.current.find((owner) => owner.isConnected);\n      if (region) {\n        event.preventDefault();\n        // Regions need not be tab stops, but they must receive programmatic return focus.\n        if (!region.hasAttribute(\"tabindex\")) region.tabIndex = -1;\n        region.focus({ preventScroll: true });\n      }\n    },\n  };\n}\n\n/** The last elements that took focus or were pressed, newest last, noted as it happened (capture phase, once per\n *  page). Presses count too: a click on a button does not focus it everywhere (Safari), and the control that was\n *  pressed is the opener a layer should give focus back to. */\nconst trail: Element[] = [];\nlet tracking = false;\nconst PRESSABLE =\n  \"button, a[href], [role=button], [role=menuitem], [role=option], [role=tab], [tabindex], input, select, textarea\";\nfunction note(el: Element | null) {\n  if (!el || el === document.body) return;\n  if (trail.at(-1) === el) return;\n  trail.push(el);\n  if (trail.length > 24) trail.shift();\n}\nfunction track() {\n  if (tracking || typeof document === \"undefined\") return;\n  tracking = true;\n  // A lazily mounted layer may be the first focus consumer on the page. Remember its opener before child autofocus.\n  note(document.activeElement);\n  document.addEventListener(\"focusin\", (event) => note(event.target as Element | null), true);\n  document.addEventListener(\n    \"pointerdown\",\n    (event) => note((event.target as Element | null)?.closest?.(PRESSABLE) ?? null),\n    true,\n  );\n}\n\n/** `active` unless it is inside `layer`; then the most recent element focused outside the layer that still exists. */\nfunction outside(active: Element | null, layer: Element | null): HTMLElement | null {\n  if (active && active !== document.body && !layer?.contains?.(active)) return active as HTMLElement;\n  for (let i = trail.length - 1; i >= 0; i--) {\n    const el = trail[i];\n    if (el.isConnected && el !== document.body && !layer?.contains?.(el)) return el as HTMLElement;\n  }\n  return null;\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/bottom-sheet/bottom-sheet.tsx","type":"registry:file","target":"src/ui/beamline/components/bottom-sheet/bottom-sheet.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type Ref, type ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\";\nimport * as RadixDialog from \"@radix-ui/react-dialog\";\nimport { Button } from \"../button/button\";\nimport { useReturnFocus } from \"../_shared/use-return-focus\";\nimport { usePortalContainer } from \"../../runtime/appearance\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useDrag } from \"../../hooks/use-drag\";\nimport { useResolvedAppearance } from \"../../hooks/use-resolved-appearance\";\nimport { motionEase } from \"../../lib/motion-tokens\";\nimport { semanticMotion } from \"../../lib/motion-source\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport overlay from \"../_shared/overlay.module.css\";\n\nexport interface BottomSheetProps {\n  /** An element that opens it (uncontrolled use). */\n  trigger?: ReactNode;\n  /** Controlled open state; omit to use defaultOpen. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Defaults to false. */\n  defaultOpen?: boolean;\n  /** Reports open state after a change; no callback by default. */\n  onOpenChange?: (open: boolean) => void;\n  /** Heading shown above the content. Required. */\n  title: string;\n  /** Supporting text beneath the label or heading. Defaults to no supporting text. */\n  description?: string;\n  /** Resting heights as fractions of the screen, lowest first. Default [0.45, 0.92]. */\n  detents?: number[];\n  /** Which resting height it opens at. Default 0. */\n  initialDetent?: number;\n  /** Reports sheet resting height after a change; no callback by default. */\n  onDetentChange?: (index: number) => void;\n  /** Accessible text for the close control. Defaults to \"Close\". */\n  closeLabel?: string;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: Partial<\n    Record<\"root\" | \"backdrop\" | \"header\" | \"handle\" | \"title\" | \"description\" | \"close\" | \"body\", string>\n  >;\n  /** Content rendered inside the component. Defaults to no children. */\n  children?: ReactNode;\n}\n\n/**\n * A phone panel that rests at a peek and follows the finger to full height. Drag the handle (or the header) between\n * resting heights; a flick down from the lowest closes it. The handle is a button: Enter cycles heights, ↑ ↓ move.\n * Escape, the scrim and Close also close it: focus goes back at once and the sheet slides away after.\n */\nexport function BottomSheet({\n  trigger,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  title,\n  description,\n  detents = [0.45, 0.92],\n  initialDetent = 0,\n  onDetentChange,\n  closeLabel: closeLabelProp,\n  className,\n  classNames,\n  style,\n  ref,\n  children,\n}: BottomSheetProps) {\n  const i18n = useMessages(\"bottom-sheet\", englishMessages);\n  const closeLabel = closeLabelProp === undefined ? i18n(\"close\") : closeLabelProp;\n\n  const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);\n  const container = usePortalContainer();\n  // the panel outlives `open`: focus returns and the trap lets go as `open` turns false, the panel leaves after;\n  // opening again starts a fresh panel\n  const [shown, setShown] = useState(open);\n  const [round, setRound] = useState(0);\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) {\n      setShown(true);\n      setRound((r) => r + 1);\n    }\n  }\n  return (\n    <RadixDialog.Root open={open} onOpenChange={setOpen}>\n      {trigger && <RadixDialog.Trigger asChild>{trigger}</RadixDialog.Trigger>}\n      {container !== null && shown && (\n        <RadixDialog.Portal container={container} forceMount>\n          <Panel\n            key={round}\n            open={open}\n            title={title}\n            description={description}\n            detents={detents}\n            initialDetent={initialDetent}\n            onDetentChange={onDetentChange}\n            closeLabel={closeLabel}\n            className={className}\n            classNames={classNames}\n            style={style}\n            ref={ref}\n            onClose={() => setOpen(false)}\n            onGone={() => setShown(false)}\n          >\n            {children}\n          </Panel>\n        </RadixDialog.Portal>\n      )}\n    </RadixDialog.Root>\n  );\n}\n\nfunction Panel({\n  open,\n  title,\n  description,\n  detents,\n  initialDetent,\n  onDetentChange,\n  closeLabel,\n  className,\n  classNames,\n  style,\n  ref,\n  onClose,\n  onGone,\n  children,\n}: Required<Pick<BottomSheetProps, \"title\" | \"detents\" | \"initialDetent\" | \"closeLabel\">> &\n  Pick<\n    BottomSheetProps,\n    \"description\" | \"onDetentChange\" | \"className\" | \"classNames\" | \"style\" | \"ref\" | \"children\"\n  > & { open: boolean; onClose: () => void; onGone: () => void }) {\n  const i18n = useMessages(\"bottom-sheet\", englishMessages);\n\n  const host = useRef<HTMLDivElement>(null);\n  const attach = useComposedRef(host, ref);\n  const appearance = useResolvedAppearance(host, { times: [\"duration-considered\"], easings: [\"ease-drawer\"] });\n  const entered = useRef(false);\n  const [vh, setVh] = useState(() => (typeof window === \"undefined\" ? 800 : window.innerHeight));\n  const maxH = Math.round(vh * detents[detents.length - 1]);\n  const yOf = (i: number) => maxH - Math.round(vh * detents[i]);\n  const [detent, setDetent] = useState(Math.min(initialDetent, detents.length - 1));\n  const y = useMotionValue(maxH);\n  const scrim = useTransform(y, [yOf(0), maxH], [1, 0]);\n  const drag = useRef<{ from: number } | null>(null);\n  // held: the handle answers the finger at once (wider and brighter) for as long as the sheet is held\n  const [held, setHeld] = useState(false);\n  const header = useRef<HTMLDivElement>(null);\n  // focus goes back as the sheet closes (below), so the unmount after it leaves must not move focus again\n  const released = useRef(false);\n  const focus = useReturnFocus({\n    onCloseAutoFocus: (event) => {\n      if (released.current) event.preventDefault();\n    },\n  });\n  // closed from inside (Escape, Close, the scrim, a flick): it slides away; closed by its owner: it goes at once\n  const [dismissed, setDismissed] = useState(false);\n  const away = !open && !dismissed;\n  const [headerH, setHeaderH] = useState(76);\n  useLayoutEffect(() => {\n    const element = header.current;\n    if (!element) return;\n    const measure = () => setHeaderH(element.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const on = () => setVh(window.innerHeight);\n    window.addEventListener(\"resize\", on);\n    return () => window.removeEventListener(\"resize\", on);\n  }, []);\n  // the glide in flight, so a hand that grabs the sheet mid-glide takes it where it is\n  const gliding = useRef<ReturnType<typeof animate> | null>(null);\n  const go = (target: number, after?: () => void) => {\n    gliding.current?.stop();\n    const run = animate(y, target, {\n      duration: appearance.reducedMotion\n        ? 0\n        : (appearance.times[\"duration-considered\"] ?? semanticMotion.duration.considered),\n      // the drawer curve the scope resolves; before it has, the system's entrance curve\n      ease: motionEase(appearance.easings[\"ease-drawer\"], semanticMotion.ease.enter),\n    });\n    gliding.current = run;\n    if (after) run.then(after);\n  };\n  const settle = (i: number) => {\n    setDetent(i);\n    onDetentChange?.(i);\n    go(yOf(i));\n  };\n  const close = () => {\n    setDismissed(true);\n    onClose();\n  };\n  useLayoutEffect(() => {\n    if (!appearance.ready || entered.current) return;\n    entered.current = true;\n    y.set(maxH);\n    go(yOf(detent));\n  }, [appearance.ready]); // eslint-disable-line react-hooks/exhaustive-deps -- slide in once\n  useEffect(() => {\n    if (!drag.current && open) y.set(yOf(detent));\n  }, [vh]); // eslint-disable-line react-hooks/exhaustive-deps -- keep the resting height on rotation\n  // `open` turned false and the trap has let go: focus returns now (not after the slide, never through <body>), then\n  // the panel leaves; an effect, so the trap's own cleanup has already run\n  useEffect(() => {\n    if (open) return;\n    const active = document.activeElement;\n    if (!active || active === document.body || host.current?.contains(active))\n      focus.onCloseAutoFocus(new Event(\"close\", { cancelable: true }), host.current);\n    released.current = true;\n    // a leaving panel takes no focus back: not by Tab, not by a child's autofocus that lands a frame late\n    if (host.current) host.current.inert = true;\n    if (!dismissed) {\n      onGone();\n      return;\n    }\n    entered.current = true;\n    let live = true;\n    go(maxH, () => live && onGone());\n    return () => {\n      live = false;\n    };\n  }, [open]); // eslint-disable-line react-hooks/exhaustive-deps -- on close only\n\n  // the header drags the sheet through the shared drag (BUILDING §7): it follows the hand, resisting past the top; a\n  // release projects 180 ms of the throw and closes past half the way down (or on a fast flick from the lowest detent),\n  // else settles on the nearest detent; Escape mid-drag puts the sheet back on its detent and keeps it open\n  useDrag(\n    header,\n    {\n      onPress: (_, e) => {\n        if ((e.target as HTMLElement).closest(\"button:not([data-handle]), a, input, textarea, select\")) return false;\n        // a sheet still settling stops under the hand and is dragged from where it is\n        gliding.current?.stop();\n        drag.current = { from: y.get() };\n        setHeld(true);\n      },\n      onMove: (p) => {\n        const d = drag.current;\n        if (!d) return;\n        const next = d.from + p.dy;\n        y.set(next < 0 ? next * 0.2 : next); // resist past the top\n      },\n      onEnd: (p) => {\n        drag.current = null;\n        setHeld(false);\n        const v = p.vy / 1000; // px per ms\n        const projected = y.get() + v * 180;\n        if (projected > yOf(0) + (maxH - yOf(0)) * 0.5 || (v > 0.9 && detentAt(y.get()) === 0)) {\n          close();\n          return;\n        }\n        settle(detentAt(projected));\n      },\n      onTap: () => {\n        const d = drag.current;\n        drag.current = null;\n        setHeld(false);\n        if (d && Math.abs(y.get() - yOf(detent)) > 0.5) settle(detent);\n      },\n      onCancel: () => {\n        const d = drag.current;\n        drag.current = null;\n        setHeld(false);\n        if (d) settle(detentAt(d.from));\n      },\n      // a press the page took before it became a drag (a sideways move scrolls instead): let the handle go, and put a\n      // sheet stopped mid-glide on its detent\n      onAbandon: () => {\n        const d = drag.current;\n        drag.current = null;\n        setHeld(false);\n        if (d && Math.abs(y.get() - yOf(detent)) > 0.5) settle(detent);\n      },\n    },\n    { axis: \"y\" },\n  );\n  const detentAt = (pos: number) =>\n    detents.reduce((best, _, i) => (Math.abs(yOf(i) - pos) < Math.abs(yOf(best) - pos) ? i : best), 0);\n\n  return (\n    <>\n      <RadixDialog.Overlay asChild>\n        <motion.div\n          data-slot=\"bottom-sheet-backdrop\"\n          // a leaving sheet lets taps through to the page\n          className={cn(\n            overlay.scrim,\n            \"!animate-none data-[state=closed]:pointer-events-none\",\n            away && \"!opacity-0\",\n            classNames?.backdrop,\n          )}\n          style={{ opacity: scrim }}\n          onPointerDown={(e) => {\n            e.preventDefault();\n            close();\n          }}\n        />\n      </RadixDialog.Overlay>\n      <RadixDialog.Content\n        asChild\n        onEscapeKeyDown={(e) => {\n          e.preventDefault();\n          close();\n        }}\n        onPointerDownOutside={(e) => e.preventDefault()}\n        {...focus}\n      >\n        <motion.div\n          ref={attach}\n          data-slot=\"bottom-sheet\"\n          style={{ y, height: maxH, ...style }}\n          // at a lower detent part of the sheet is below the screen: a control focused down there raises the sheet\n          onFocusCapture={(e) => {\n            const last = detents.length - 1;\n            if (\n              detent < last &&\n              !drag.current &&\n              (e.target as HTMLElement).getBoundingClientRect().bottom - e.currentTarget.getBoundingClientRect().top >\n                Math.round(vh * detents[detent])\n            )\n              settle(last);\n          }}\n          className={cn(\n            \"m-overlay fixed inset-x-0 bottom-0 z-[var(--pui-z-modal)] mx-auto flex max-w-[640px] flex-col rounded-t-[var(--pui-radius-window)] text-ink outline-none data-[state=closed]:pointer-events-none\",\n            // closed by its owner it is gone from the first frame, still focusable until focus has gone back\n            away && \"opacity-0\",\n            classNames?.root,\n            className,\n          )}\n        >\n          <div\n            ref={header}\n            data-slot=\"bottom-sheet-header\"\n            data-held={held ? \"\" : undefined}\n            className={cn(\n              \"group/sheet shrink-0 cursor-grab touch-pan-x select-none active:cursor-grabbing\",\n              classNames?.header,\n            )}\n          >\n            <button // organism-allow: raw-control -- the sheet's drag handle (a grip, not an action)\n              type=\"button\"\n              data-slot=\"bottom-sheet-handle\"\n              data-handle\n              aria-label={detent === detents.length - 1 ? i18n(\"collapseSheet\") : i18n(\"expandSheet\")}\n              onClick={() => settle((detent + 1) % detents.length)}\n              onKeyDown={(e) => {\n                if (e.key === \"ArrowUp\") {\n                  e.preventDefault();\n                  settle(Math.min(detents.length - 1, detent + 1));\n                } else if (e.key === \"ArrowDown\") {\n                  e.preventDefault();\n                  if (detent === 0) close();\n                  else settle(detent - 1);\n                }\n              }}\n              className={cn(\"mx-auto mt-2 mb-1 grid h-5 w-16 place-items-center rounded-pill\", classNames?.handle)}\n            >\n              <span className=\"h-1 w-9 rounded-pill bg-line-strong transition-[width,background-color] duration-[var(--pui-motion-out)] group-data-held/sheet:w-12 group-data-held/sheet:bg-ink-3 group-data-held/sheet:duration-0\" />\n            </button>\n            <div className=\"flex items-start gap-3 px-[var(--pui-pad-panel)] pb-3\">\n              <div className=\"min-w-0 flex-1\">\n                <RadixDialog.Title\n                  data-slot=\"bottom-sheet-title\"\n                  className={cn(\"text-subheading leading-snug text-ink\", classNames?.title)}\n                >\n                  {title}\n                </RadixDialog.Title>\n                {description ? (\n                  <RadixDialog.Description\n                    data-slot=\"bottom-sheet-description\"\n                    className={cn(\"text-sm text-ink-2\", classNames?.description)}\n                  >\n                    {description}\n                  </RadixDialog.Description>\n                ) : (\n                  <RadixDialog.Description className=\"sr-only\">{title}</RadixDialog.Description>\n                )}\n              </div>\n              <Button\n                data-slot=\"bottom-sheet-close\"\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={close}\n                aria-label={closeLabel}\n                className={cn(\"-me-1\", classNames?.close)}\n              >\n                <X />\n              </Button>\n            </div>\n          </div>\n          <div\n            data-slot=\"bottom-sheet-body\"\n            className={cn(\"min-h-0 overflow-y-auto overscroll-contain border-t border-line-soft\", classNames?.body)}\n            style={{ height: Math.round(vh * detents[detent]) - headerH }}\n          >\n            {children}\n          </div>\n        </motion.div>\n      </RadixDialog.Content>\n    </>\n  );\n}\n\nexport default BottomSheet;\n"},{"path":"beamline/components/bottom-sheet/messages.ts","type":"registry:file","target":"src/ui/beamline/components/bottom-sheet/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  collapseSheet: \"Collapse sheet\",\n  expandSheet: \"Expand sheet\",\n  close: \"Close\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/button/button-variants.ts","type":"registry:file","target":"src/ui/beamline/components/button/button-variants.ts","content":"import { cn } from \"../../lib/utils\";\nimport styles from \"./button.module.css\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"danger\" | \"accent\";\n/** sm 32 · md 36 · lg 40 · touch 44 (a bottom action bar on a phone). */\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"touch\";\n/** Market side: buy fills with --pui-up, sell with --pui-down (a red-up market flips the two tokens, not the code). */\nexport type ButtonTone = \"up\" | \"down\";\n\n/** Which paint role paints each variant: filled variants are solids, the quiet ones keep their own light paint. */\nconst ROLE: Record<ButtonVariant, string | undefined> = {\n  primary: \"m-solid\",\n  accent: \"m-solid\",\n  secondary: \"m-control\",\n  outline: undefined,\n  ghost: undefined,\n  danger: undefined,\n};\n\n/** A variant's paint without the button's layout, for a group that wears one button's look (SplitButton paints the\n *  pair as one button and leaves its halves transparent). */\nexport function buttonPaint(variant: ButtonVariant = \"primary\", tone?: ButtonTone) {\n  return cn(ROLE[variant], styles[variant], tone && styles[tone]);\n}\n\n/** The classes that make any element look like a Button, for parts that render their own element. */\nexport function buttonClassName({\n  variant = \"primary\",\n  size = \"md\",\n  tone,\n  className,\n}: { variant?: ButtonVariant; size?: ButtonSize; tone?: ButtonTone; className?: string } = {}) {\n  return cn(styles.root, ROLE[variant], styles[variant], tone && styles[tone], styles[size], className);\n}\n"},{"path":"beamline/components/button/button.module.css","type":"registry:file","target":"src/ui/beamline/components/button/button.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Button. Every colour, size and duration is a token; a variant only sets the four local properties below. */\n  .root {\n    --_button-bg: transparent;\n    --_button-bg-hover: var(--pui-hover);\n    --_button-fg: var(--pui-ink);\n    --_button-edge: none;\n    --_button-height: var(--pui-control-height-md);\n    --_button-pad: 14px;\n    position: relative;\n    isolation: isolate;\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    gap: var(--pui-space-1-5);\n    flex-shrink: 0;\n    height: var(--_button-height);\n    min-width: var(--_button-height);\n    /* never wider than its room: in a narrow card the label ends in an ellipsis instead of spilling (the full words\n       stay the accessible name) */\n    max-width: 100%;\n    padding-inline: var(--_button-pad);\n    border: 0;\n    /* Nested context: inside a frame that sets --pui-nest-radius and --pui-nest-inset (and --pui-nest-ceiling for pills)\n       the corner is the shared nested corner (.nest-corner, utilities.css: radius − inset, or the button's own radius\n       where that would fall under the chip radius); elsewhere the control radius. --pui-button-radius wins over both. */\n    border-radius: var(--pui-button-radius, var(--pui-nested, var(--pui-radius-control)));\n    color: var(--_button-fg);\n    font-family: var(--pui-font-body);\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-medium);\n    line-height: 1;\n    letter-spacing: var(--pui-tracking-body);\n    white-space: nowrap;\n    text-decoration: none;\n    cursor: pointer;\n    user-select: none;\n    -webkit-tap-highlight-color: transparent;\n    /* frequency law: hover arrives at 0ms (below), leaves over the ruled departure; opacity and translate are listed\n       so a button that waits for attention (.reveal) fades away like every reveal instead of vanishing */\n    transition:\n      var(--m-transition),\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  /* the quiet variants paint themselves; filled and secondary variants are painted by their look role (m-solid,\n   m-control), which these zero-specificity rules can never override */\n  :where(.root) {\n    background: var(--_button-bg);\n    box-shadow: var(--_button-edge);\n  }\n  :where(.root:hover:not(:disabled, [aria-disabled=\"true\"])) {\n    background: var(--_button-bg-hover);\n  }\n  .root:hover:not(:disabled, [aria-disabled=\"true\"]) {\n    transition-duration: 0ms;\n  }\n  /* the asChild form has no motion: a CSS press instead */\n  .root:not(button):active {\n    transform: scale(0.97);\n  }\n  .root:disabled,\n  .root[aria-disabled=\"true\"]:not([data-loading]) {\n    opacity: var(--pui-opacity-disabled);\n    cursor: not-allowed;\n  }\n  .root[data-loading] {\n    cursor: progress;\n  }\n\n  /* Icons: 16px unless the icon says otherwise (zero specificity, so size-5 or an inline size wins) */\n  :where(.root) :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n    flex-shrink: 0;\n  }\n\n  .sm {\n    --_button-height: var(--pui-control-height-sm);\n    --_button-pad: var(--pui-space-2-5);\n    /* the base gap (--pui-space-1-5); 5px had no scale step, and 6px also lets the loader's negative margin cancel\n       the gap exactly at every size */\n  }\n  .md {\n    --_button-height: var(--pui-control-height-md);\n    --_button-pad: 14px;\n  }\n  .lg {\n    --_button-height: var(--pui-control-height-lg);\n    --_button-pad: var(--pui-space-4);\n    font-size: var(--pui-text-base);\n  }\n  /* a bottom action bar on a phone: the 44 px touch target (a platform minimum, not a spacing step, so it holds when\n     the spacing parameter changes — O1 §14.2), the large label */\n  .touch {\n    --_button-height: calc(var(--pui-control-height-lg) + 0.25rem);\n    --_button-pad: var(--pui-space-5);\n    font-size: var(--pui-text-base);\n  }\n  .root[data-icon-only] {\n    padding-inline: 0;\n    width: var(--_button-height);\n  }\n\n  /* the main action: ink on the page, inverted per theme */\n  .primary {\n    --m-color: var(--pui-ink);\n    --_button-fg: var(--pui-ink-inverse);\n  }\n  .secondary {\n    --_button-fg: var(--pui-ink);\n  }\n  .outline {\n    --_button-edge: inset 0 0 0 1px var(--pui-line-strong);\n    --_button-bg-hover: var(--pui-hover);\n  }\n  .ghost {\n    --_button-fg: var(--pui-ink-2);\n    --_button-bg-hover: var(--pui-hover);\n  }\n  .ghost:hover:not(:disabled) {\n    color: var(--pui-ink);\n  }\n  .danger {\n    --_button-fg: var(--pui-red);\n    --_button-edge: inset 0 0 0 1px color-mix(in oklab, var(--pui-red) 42%, transparent);\n    --_button-bg-hover: var(--pui-red-tint);\n  }\n  .accent {\n    --m-color: var(--pui-accent);\n    --_button-fg: var(--pui-accent-foreground);\n  }\n\n  /* Market side. Declared after the variants so it wins: a filled variant takes the side's colour and its on-colour\n     ink; a quiet variant keeps its paint and colours the label. */\n  .up {\n    --m-color: var(--pui-up);\n    --_button-fg: var(--pui-up);\n  }\n  .down {\n    --m-color: var(--pui-down);\n    --_button-fg: var(--pui-down);\n  }\n  .ghost:is(.up, .down):hover:not(:disabled) {\n    color: var(--_button-fg);\n  }\n  .primary.up,\n  .accent.up {\n    --_button-fg: var(--pui-on-up);\n  }\n  .primary.down,\n  .accent.down {\n    --_button-fg: var(--pui-on-down);\n  }\n\n  /* The main actions' light, where the look provides one, as a spectrum. Glow spills a soft light in the brand's\n     spectrum from under the button's bottom edge (clipped at it, so it never tints the face) that brightens under the\n     pointer. Prism keeps it inside the glass (--m-solid-glow-fit: 1): the face the light leaves by, the bottom edge,\n     splits it into a crisp line with a faint wash rising into the face, only while pointed at. Every other look\n     leaves --m-solid-glow at none, so the layer does not exist. */\n  .primary::after,\n  .accent::after {\n    --_fit: var(--m-solid-glow-fit, 0);\n    content: var(--m-solid-glow);\n    position: absolute;\n    z-index: -1;\n    top: calc((1 - var(--_fit)) * (100% + var(--m-solid-glow-gap, 0px)));\n    inset-inline: var(--m-solid-glow-inset, 14%);\n    height: var(--m-solid-glow-height, 14px);\n    border-radius: calc(\n      var(--_fit) * var(--pui-button-radius, var(--pui-nested, var(--pui-radius-control))) + (1 - var(--_fit)) *\n        var(--pui-radius-pill)\n    );\n    background: var(--m-spectrum);\n    background-size: var(--m-spectrum-size, 100% 100%);\n    /* the part of the spectrum that shows: this button's own angle (--m-prism-seed, new at every press), turned by\n       where the pointer is across the face (--m-prism-x) in a look that turns it (--m-solid-glow-turn) */\n    background-position: calc((var(--m-prism-seed, 0.3) + var(--m-prism-x, 0.5) * var(--m-solid-glow-turn, 0)) * 100%) 0;\n    filter: blur(var(--m-solid-glow-blur, 9px));\n    clip-path: var(--m-solid-glow-clip, inset(0 -32px -32px -32px));\n    mask: var(--m-solid-glow-mask, none);\n    mask-position:\n      0 0,\n      calc(var(--m-prism-seed, 0.3) * 100%) 0;\n    mask-composite: intersect;\n    opacity: var(--m-solid-glow-rest, 0.75);\n    scale: var(--m-solid-glow-scale, 1) 1;\n    pointer-events: none;\n    transition:\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      scale var(--pui-motion-out) var(--pui-ease-default),\n      background-position var(--pui-duration-considered) var(--pui-ease-default),\n      mask-position var(--pui-duration-considered) var(--pui-ease-default);\n  }\n  /* pointed at: the light brightens (glow) or grows out from the centre and drifts along while the pointer stays\n     (prism) */\n  .primary:hover::after,\n  .accent:hover::after {\n    opacity: 1;\n    scale: 1 1;\n    transition-duration: var(--m-solid-glow-in, 0ms);\n    animation: var(--m-solid-glow-flow, none);\n  }\n  /* A change of word or colour (Save → Saved, a tone turning green) passes a band of split light across the face once,\n     in a look that has one (prism: --m-solid-shift-light; the pen hook's ::before is free on a solid otherwise). */\n  .primary[data-shift]::before,\n  .accent[data-shift]::before {\n    content: var(--m-solid-shift-light, none);\n    position: absolute;\n    inset: 0;\n    z-index: -1;\n    border: 0;\n    border-radius: inherit;\n    background: var(--m-spectrum) 0 0 / var(--m-spectrum-size, 100% 100%);\n    mask: linear-gradient(105deg, transparent 39%, #000 47.5%, #000 52.5%, transparent 61%) 0 0 / 300% 100% no-repeat;\n    opacity: 0;\n    animation: prism-shift calc(var(--pui-duration-considered) * 2.2) var(--pui-ease-default);\n    pointer-events: none;\n  }\n  @keyframes prism-shift {\n    from {\n      mask-position: 100% 0;\n      opacity: 0.25;\n    }\n    35% {\n      opacity: 0.55;\n    }\n    to {\n      mask-position: 0 0;\n      opacity: 0;\n    }\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .primary::after,\n    .accent::after {\n      transition: none;\n      animation: none !important;\n    }\n    .primary[data-shift]::before,\n    .accent[data-shift]::before {\n      content: none;\n    }\n  }\n  .root:disabled::after,\n  .root[aria-disabled=\"true\"]::after {\n    content: none;\n  }\n\n  /* The loader opens its own width, and the negative margin cancels the gap so the label does not jump */\n  .loader {\n    display: inline-flex;\n    overflow: hidden;\n    margin-inline-end: calc(var(--pui-space-1-5) * -1);\n  }\n  .loader > * {\n    margin-inline-end: var(--pui-space-1-5);\n  }\n  .spinner {\n    color: currentColor;\n  }\n  .root[data-icon-only] .loader {\n    margin-inline-end: 0;\n  }\n  .root[data-icon-only] .loader > * {\n    margin-inline-end: 0;\n  }\n\n  /* The label holds its place while the text inside swaps; the leaving text pops out of flow against it */\n  .label {\n    position: relative;\n    display: inline-flex;\n    justify-content: center;\n    min-width: 0;\n    overflow: visible;\n  }\n  /* the clip box is let out by a little over a line so descenders are never cut, without changing the layout */\n  .text {\n    display: inline-block;\n    max-width: 100%;\n    margin-block: -0.2em;\n    padding-block: 0.2em;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .label[data-morphing] > .text {\n    overflow: visible;\n    text-overflow: clip;\n  }\n}\n\n@layer components {\n  :global(.pui[data-motion=\"reduced\"]) .root:not(button):active,\n  :global(.pui[data-motion=\"none\"]) .root:not(button):active {\n    transform: none;\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .root:not(button):active {\n      transform: none;\n    }\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.root {\n  composes: nest-corner from global;\n}\n"},{"path":"beamline/components/button/button.tsx","type":"registry:file","target":"src/ui/beamline/components/button/button.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport {\n  Children,\n  Fragment,\n  isValidElement,\n  useLayoutEffect,\n  useRef,\n  type ButtonHTMLAttributes,\n  type PointerEvent,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  type ComponentProps,\n} from \"react\";\nimport { AnimatePresence, LazyMotion, m, spring, useIsPresent } from \"motion/react\";\nimport { animate } from \"motion/mini\";\nimport * as Slot from \"@radix-ui/react-slot\";\nimport { Spinner } from \"../spinner/spinner\";\nimport type { BuiltInLook as Look } from \"../../runtime/preset\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./button.module.css\";\n\nexport type { ButtonVariant, ButtonSize, ButtonTone } from \"./button-variants\";\nexport { buttonPaint, buttonClassName } from \"./button-variants\";\nimport { buttonClassName, type ButtonVariant, type ButtonSize, type ButtonTone } from \"./button-variants\";\nimport type { ButtonDefaults } from \"./defaults\";\nexport type { ButtonDefaults } from \"./defaults\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\n\ntype NativeProps = Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\"\n>;\n\nexport interface ButtonProps extends NativeProps {\n  /** Visual weight: primary (ink-filled), secondary, outline, ghost, danger, or accent (filled with the brand). */\n  variant?: ButtonVariant;\n  /** Height 32 / 36 / 40 px; touch 44 px for a bottom action bar on a phone. */\n  size?: ButtonSize;\n  /** Market side. On primary/accent the fill becomes --pui-up (buy) or --pui-down (sell); on the quiet variants the\n   *  label takes that colour. Pair it with a word or arrow: colour is never the only signal. */\n  tone?: ButtonTone;\n  /** Shows a loader in front of the label, sets aria-busy and ignores clicks; size and focus stay. */\n  loading?: boolean;\n  classNames?: Partial<Record<\"root\" | \"label\" | \"loader\", string>>;\n  /** Paint this button in another look than the one in force around it (DESIGN-LANGUAGE.md \"Looks\"). */\n  look?: Look;\n  /** Render the single child (a link, a Radix trigger) with the button's look instead of a <button>. */\n  asChild?: boolean;\n  /** A square button holding only an icon (give it an aria-label). Decided by the children when left out: icon-only when\n   *  no words sit anywhere among them (an icon, or a frame of icons), so `<span>Close</span>` is a label. */\n  iconOnly?: boolean;\n  ref?: Ref<HTMLButtonElement>;\n}\n\n// Motion's animation features arrive in a deferred chunk (AT11): the button renders and works at once, and its press,\n// loader and label motion run once they land, a frame or two after mount. Buttons that mount after that get the\n// loaded features directly, so they render once instead of again when a lazy load resolves.\ntype Features = typeof import(\"./motion-features\").default;\nlet loaded: Features | null = null;\nconst features = () =>\n  import(\"./motion-features\").then((module) => {\n    loaded = module.default;\n    return loaded;\n  });\n\n/** Children with fragments opened up, so `{busy ? <><Icon />Saving</> : …}` reads as icon plus text. */\nconst flat = (children: ReactNode): ReactNode[] =>\n  Children.toArray(children).flatMap((c) =>\n    isValidElement<{ children?: ReactNode }>(c) && c.type === Fragment ? flat(c.props.children) : [c],\n  );\n\n/** Whether any words sit anywhere inside (an icon, or a frame of icons, has none). */\nfunction hasText(node: ReactNode): boolean {\n  if (typeof node === \"string\") return node.trim() !== \"\";\n  if (typeof node === \"number\") return true;\n  if (Array.isArray(node)) return node.some(hasText);\n  return isValidElement<{ children?: ReactNode }>(node) && hasText(node.props.children);\n}\n\n/** Splits children into the text run (the label that can change) and everything else (icons), in order, and says\n *  whether any element carries content of its own (a `<span>Close</span>` label, unlike an icon). */\nfunction split(children: ReactNode) {\n  const parts = flat(children);\n  const text = parts\n    .filter((p) => typeof p === \"string\" || typeof p === \"number\")\n    .join(\"\")\n    .trim();\n  const firstText = parts.findIndex((p) => typeof p === \"string\" || typeof p === \"number\");\n  const before = firstText === -1 ? parts : parts.slice(0, firstText).filter(isValidElement);\n  const after = firstText === -1 ? [] : parts.slice(firstText).filter(isValidElement);\n  const content = parts.some((p) => isValidElement<{ children?: ReactNode }>(p) && hasText(p.props.children));\n  return { text, before, after, content };\n}\n\n/**\n * The action control. Hover answers at once and lets go over --pui-motion-out; a press scales down at once, or swells\n * on the snappy spring where the look's --pui-press-scale is above 1 (glass), never on a trigger that anchors a\n * floating layer, which would measure a resized box; a changed label slides in while the\n * width follows on a critically damped spring; loading keeps the size and the focus.\n */\nexport function Button({\n  variant: variantProp,\n  size: sizeProp,\n  tone: toneProp,\n  loading = false,\n  look,\n  asChild = false,\n  iconOnly: iconOnlyProp,\n  className,\n  classNames,\n  children,\n  type,\n  onClick,\n  onPointerMove,\n  onPointerDown,\n  disabled,\n  ref,\n  ...props\n}: ButtonProps) {\n  const i18n = useMessages(\"button\", englishMessages);\n\n  const defaults = useComponentDefaults<ButtonDefaults>(\"button\", [\"variant\", \"size\", \"tone\"]);\n  const variant = variantProp ?? defaults.variant ?? \"primary\";\n  const size = sizeProp ?? defaults.size ?? \"md\";\n  const tone = toneProp ?? defaults.tone;\n  const host = useRef<HTMLButtonElement>(null);\n  const attach = useComposedRef(host, ref);\n  const motionTokens = useMotionTokens(host);\n  const requestedReduced = useReducedMotionSafe();\n  const reduced = requestedReduced || motionTokens.reduced;\n  // Reduced motion keeps a restrained fade; none removes it (APPEARANCE A06).\n  const fade = motionTokens.mode === \"none\" ? 0 : motionTokens.fade.in;\n  // a caller's aria-disabled keeps the button focusable (focus never drops after Save resolves) but inert to presses\n  const softDisabled = props[\"aria-disabled\"] === true || props[\"aria-disabled\"] === \"true\";\n  const { text, before, after, content } = split(children);\n  const prism = usePrismLight(host, variant === \"primary\" || variant === \"accent\", `${text}|${tone}|${variant}`);\n  // with asChild the text sits inside the child (the link), not among our own children\n  const inner = asChild && isValidElement<{ children?: ReactNode }>(children) ? split(children.props.children) : null;\n  const iconOnly = iconOnlyProp ?? (inner ? !inner.text && !inner.content : !text && !content);\n  const anchor =\n    (props[\"aria-haspopup\"] != null && props[\"aria-haspopup\"] !== false && props[\"aria-haspopup\"] !== \"false\") ||\n    \"data-state\" in props;\n  const shared = {\n    \"data-slot\": \"button\",\n    \"data-variant\": variant,\n    \"data-size\": size,\n    \"data-tone\": tone,\n    \"data-icon-only\": iconOnly ? \"\" : undefined,\n    \"data-loading\": loading ? \"\" : undefined,\n    \"data-look\": look,\n    className: buttonClassName({ variant, size, tone, className: cn(classNames?.root, className) }),\n  };\n\n  if (asChild)\n    return (\n      <Slot.Root {...shared} {...props} ref={attach}>\n        {children}\n      </Slot.Root>\n    );\n\n  const button = (\n    <m.button\n      ref={attach}\n      type={type ?? \"button\"}\n      {...shared}\n      {...props}\n      disabled={disabled}\n      aria-busy={loading || undefined}\n      aria-disabled={loading || softDisabled || undefined}\n      onPointerMove={(e) => {\n        prism.turn(e);\n        onPointerMove?.(e);\n      }}\n      onPointerDown={(e) => {\n        prism.press();\n        onPointerDown?.(e);\n      }}\n      onClick={(e) => {\n        if (loading || softDisabled) {\n          e.preventDefault();\n          return;\n        }\n        onClick?.(e);\n      }}\n      whileTap={\n        reduced || anchor || disabled || loading || softDisabled\n          ? undefined\n          : {\n              scale: motionTokens.pressScale,\n              // a press that sinks lands at once; one that swells toward the finger (glass) grows on the spring\n              transition: motionTokens.pressScale > 1 ? motionTokens.spring.snappy : { duration: 0 },\n            }\n      }\n      transition={{ scale: motionTokens.spring.snappy }}\n    >\n      <AnimatePresence initial={false}>\n        {loading && (\n          <m.span\n            key=\"loader\"\n            data-slot=\"button-loader\"\n            className={cn(styles.loader, classNames?.loader)}\n            initial={reduced ? { opacity: 0 } : { width: 0, opacity: 0 }}\n            animate={{ width: \"auto\", opacity: 1 }}\n            exit={reduced ? { opacity: 0 } : { width: 0, opacity: 0 }}\n            transition={{ duration: reduced ? fade : motionTokens.duration.standard, ease: motionTokens.ease.default }}\n          >\n            <Spinner size=\"sm\" label={i18n(\"working\")} className={styles.spinner} />\n          </m.span>\n        )}\n      </AnimatePresence>\n      {iconOnly ? !loading && before : before}\n      {!iconOnly && text && (\n        <Label text={text} reduced={!!reduced} fade={fade} motionTokens={motionTokens} className={classNames?.label} />\n      )}\n      {after}\n    </m.button>\n  );\n  return <LazyMotion features={loaded ?? features}>{button}</LazyMotion>;\n}\n\n/**\n * The main action's light inside the glass, in a look that draws one (prism): its own angle from the start, turned by\n * where the pointer is across the face, a new angle at every press, and a sweep of split light (data-shift) when the\n * button's word, tone or variant changes after it first appears. Writes two style properties and one attribute on the\n * button; nothing runs at rest. Looks without this light ignore all three.\n */\nfunction usePrismLight(host: RefObject<HTMLButtonElement | null>, solid: boolean, state: string) {\n  const frame = useRef(0);\n  const shown = useRef(state);\n  const ending = useRef(0);\n  useLayoutEffect(() => {\n    const el = host.current;\n    if (el && solid) el.style.setProperty(\"--m-prism-seed\", Math.random().toFixed(3));\n  }, [host, solid]);\n  useLayoutEffect(() => {\n    const el = host.current;\n    if (!el || !solid || shown.current === state) return;\n    shown.current = state;\n    // restart the sweep if it is still running\n    delete el.dataset.shift;\n    void el.offsetWidth;\n    el.dataset.shift = \"\";\n    clearTimeout(ending.current);\n    ending.current = window.setTimeout(() => delete el.dataset.shift, 1200);\n  }, [host, solid, state]);\n  useLayoutEffect(\n    () => () => {\n      clearTimeout(ending.current);\n      cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n  return {\n    turn: (e: PointerEvent<HTMLButtonElement>) => {\n      if (!solid || e.pointerType !== \"mouse\" || frame.current) return;\n      const el = e.currentTarget;\n      const x = e.clientX;\n      frame.current = requestAnimationFrame(() => {\n        frame.current = 0;\n        const r = el.getBoundingClientRect();\n        if (r.width) el.style.setProperty(\"--m-prism-x\", ((x - r.left) / r.width).toFixed(3));\n      });\n    },\n    press: () => {\n      if (solid) host.current?.style.setProperty(\"--m-prism-seed\", Math.random().toFixed(3));\n    },\n  };\n}\n\n/** The text run. When it changes, the old words leave upward and the new ones rise in, while the width moves from the\n *  old measure to the new one; no measuring happens while the label is unchanged. */\nfunction Label({\n  text,\n  reduced,\n  fade,\n  motionTokens,\n  className,\n}: {\n  text: string;\n  reduced: boolean;\n  /** Seconds of the label crossfade under reduced motion (0 under none). */\n  fade: number;\n  /** The button's own, resolved at the button: the label sits inside it, in the same look and motion. */\n  motionTokens: ReturnType<typeof useMotionTokens>;\n  className?: string;\n}) {\n  const slot = useRef<HTMLSpanElement>(null);\n  const latest = useRef(motionTokens);\n  useLayoutEffect(() => {\n    latest.current = motionTokens;\n  }, [motionTokens]);\n  const last = useRef<{ text: string; width: number } | null>(null);\n  useLayoutEffect(() => {\n    const el = slot.current;\n    if (!el) return;\n    const prev = last.current;\n    // first mount: read the width after paint, when layout is already clean, instead of forcing a layout per button\n    if (!prev) {\n      last.current = { text, width: Number.NaN };\n      const id = requestAnimationFrame(() => {\n        if (last.current?.text === text) last.current = { text, width: el.getBoundingClientRect().width };\n      });\n      return () => cancelAnimationFrame(id);\n    }\n    el.style.width = \"\";\n    const width = el.getBoundingClientRect().width;\n    last.current = { text, width };\n    // a label that is not rendered (a hidden toolbar on a phone) has nothing to animate\n    if (prev.text === text || reduced || Number.isNaN(prev.width) || !el.getClientRects().length) return;\n    // The same spring, played natively: motion/mini samples the generator into a linear() easing.\n    const { visualDuration, bounce } = latest.current.spring.morph;\n    // while the width eases the words are clipped, not cut short with an ellipsis (that is for a room too narrow)\n    el.dataset.morphing = \"\";\n    const run = animate(el, { width: [`${prev.width}px`, `${width}px`] }, { type: spring, visualDuration, bounce });\n    run.then(() => {\n      el.style.width = \"\";\n      delete el.dataset.morphing;\n    });\n    // cancel, not stop: stop commits the running value, which throws once the element stops being rendered, and the\n    // next change measures afresh anyway\n    return () => {\n      run.cancel();\n      el.style.width = \"\";\n      delete el.dataset.morphing;\n    };\n  }, [text, reduced]);\n  // A label that changes faster than its words can rise in (a count read live while a hand drags) shows each new\n  // value at once: otherwise every value would start invisible and the button would read blank for the whole drag.\n  const rapid = useRapid(text, motionTokens.duration.standard * 1000);\n  const enter = reduced ? { opacity: 0 } : { opacity: 0, y: \"0.45em\", filter: `blur(${motionTokens.blur.subtle}px)` };\n  const leave = reduced\n    ? { opacity: 0 }\n    : {\n        opacity: 0,\n        y: \"-0.45em\",\n        filter: `blur(${motionTokens.blur.subtle}px)`,\n        transition: { duration: motionTokens.duration.fast },\n      };\n  return (\n    <span ref={slot} data-slot=\"button-label\" className={cn(styles.label, className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={rapid}>\n        <Words\n          key={text}\n          className={styles.text}\n          custom={rapid}\n          variants={{\n            enter: (fast: boolean) => (fast ? { opacity: 1, y: \"0em\", filter: \"blur(0px)\" } : enter),\n            shown: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n            leave: (fast: boolean) => (fast ? { opacity: 0, transition: { duration: 0 } } : leave),\n          }}\n          initial=\"enter\"\n          animate=\"shown\"\n          exit=\"leave\"\n          transition={{ duration: reduced ? fade : motionTokens.duration.standard, ease: motionTokens.ease.default }}\n        >\n          {text}\n        </Words>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Whether `text` changed again within `ms` of its last change. Read in render: the answer belongs to this text. */\nfunction useRapid(text: string, ms: number) {\n  const seen = useRef({ text, at: Number.NEGATIVE_INFINITY, rapid: false });\n  if (seen.current.text !== text) {\n    const at = performance.now();\n    seen.current = { text, at, rapid: at - seen.current.at < ms };\n  }\n  return seen.current.rapid;\n}\n\n/** One label's words. Leaving, they are already gone for assistive tech: the button's name is the new label at once,\n *  not both while the old words animate away (or for good, where time stands still in a test). */\nfunction Words(props: ComponentProps<typeof m.span>) {\n  const present = useIsPresent();\n  return <m.span {...props} aria-hidden={present ? undefined : true} />;\n}\n\nexport default Button;\n"},{"path":"beamline/components/button/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/button/defaults.ts","content":"import type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-variants\";\n/** Scoped design choices only; no loading, events or domain state. */\nexport interface ButtonDefaults {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  tone?: ButtonTone;\n}\n"},{"path":"beamline/components/button/messages.ts","type":"registry:file","target":"src/ui/beamline/components/button/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  working: \"Working\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/button/motion-features.ts","type":"registry:file","target":"src/ui/beamline/components/button/motion-features.ts","content":"// REASON: Motion's DOM animation features (tween, spring, presence, press) are loaded after the first paint, so a\n// Button costs a small part of Motion on first load; LazyMotion in button.tsx renders until they arrive.\nimport { domAnimation } from \"motion/react\";\n\nexport default domAnimation;\n"},{"path":"beamline/components/document-viewer/document-viewer.module.css","type":"registry:file","target":"src/ui/beamline/components/document-viewer/document-viewer.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A document answers to its room: the rail and the annotations panel sit beside the pages when there is space and\n     open as sheets on a phone; the toolbar folds. Pages are paper in every theme (the PDF paints itself), set on the\n     inset well so they read as sheets lying in it. */\n  .root {\n    display: flex;\n    min-width: min(16rem, 100%);\n    block-size: var(--_height);\n    flex-direction: column;\n    overflow: hidden;\n    border-radius: var(--pui-radius-panel);\n    container-type: inline-size;\n  }\n  .fill {\n    block-size: 100%;\n    min-block-size: 0;\n  }\n\n  /* ── toolbar ── */\n  .toolbar {\n    display: flex;\n    min-height: calc(var(--pui-size-8) + var(--pui-space-2) * 2);\n    flex: none;\n    align-items: center;\n    gap: var(--pui-space-1-5);\n    padding: var(--pui-space-2) var(--pui-space-2-5);\n    border-block-end: 1px solid var(--pui-line-soft);\n  }\n  .pager,\n  .zoom {\n    display: flex;\n    flex: none;\n    align-items: center;\n    gap: var(--pui-space-0-5);\n  }\n  .pageField {\n    display: inline-flex;\n    align-items: center;\n    gap: var(--pui-space-1-5);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    font-variant-numeric: tabular-nums;\n    white-space: nowrap;\n  }\n  .pageInput {\n    min-inline-size: 2.75em;\n    block-size: var(--pui-size-7);\n    padding-inline: var(--pui-space-1-5);\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink);\n    font: inherit;\n    font-size: var(--pui-text-sm);\n    text-align: center;\n    transition: var(--m-transition);\n  }\n  .pageTotal {\n    min-inline-size: 3.5ch;\n  }\n  .pageButton {\n    gap: var(--pui-space-1-5);\n    font-variant-numeric: tabular-nums;\n  }\n  .pageCount {\n    display: inline-flex;\n    gap: var(--pui-space-1);\n  }\n  .pageOf {\n    color: var(--pui-ink-3);\n  }\n  .root[data-compact] .toolbar {\n    gap: var(--pui-space-1);\n    padding-inline: var(--pui-space-1-5);\n  }\n  .root[data-compact] .zoomTrigger {\n    min-inline-size: 0;\n  }\n  .zoomTrigger {\n    min-inline-size: 5.5rem;\n    justify-content: space-between;\n    font-variant-numeric: tabular-nums;\n  }\n  .search {\n    display: flex;\n    min-width: 0;\n    flex: 1 1 12rem;\n    align-items: center;\n    gap: var(--pui-space-1-5);\n    margin-inline-start: var(--pui-space-1);\n  }\n  .search > :first-child {\n    min-width: 0;\n    max-inline-size: 18rem;\n    flex: 1;\n  }\n  .hits {\n    flex: none;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    font-variant-numeric: tabular-nums;\n    white-space: nowrap;\n  }\n  .stepper {\n    display: flex;\n    flex: none;\n  }\n  .end {\n    display: flex;\n    flex: none;\n    align-items: center;\n    gap: var(--pui-space-0-5);\n    margin-inline-start: auto;\n  }\n  /* a toolbar toggle that is on sits pressed in (DESIGN-LANGUAGE \"Looks\"): the inset well's paint, its icon in accent */\n  .toggle[aria-pressed=\"true\"] {\n    background: var(--pui-current-tint, var(--pui-accent-tint));\n    box-shadow: var(--m-inset-edge, inset 0 0 0 1px var(--pui-line));\n    color: var(--pui-current-edge, var(--pui-accent-ink));\n  }\n  .count {\n    font-size: var(--pui-text-xs);\n    font-variant-numeric: tabular-nums;\n  }\n  /* a phone: search opens across the whole toolbar */\n  .root[data-compact] .search {\n    flex: 1;\n    margin: 0;\n  }\n  .root[data-compact] .search > :first-child {\n    max-inline-size: none;\n  }\n\n  /* ── body: rail · pages · panel ── */\n  .body {\n    display: flex;\n    min-height: 0;\n    flex: 1 1 0;\n  }\n  .rail,\n  .panel {\n    display: flex;\n    min-height: 0;\n    flex: none;\n    flex-direction: column;\n    animation: pui-dv-side-in var(--pui-motion-in) var(--pui-ease-enter) both;\n  }\n  .rail {\n    inline-size: calc(96px + var(--pui-space-4) * 2);\n    border-inline-end: 1px solid var(--pui-line-soft);\n  }\n  .panel {\n    inline-size: clamp(16rem, 30%, 21rem);\n    border-inline-start: 1px solid var(--pui-line-soft);\n  }\n  @keyframes pui-dv-side-in {\n    from {\n      opacity: 0;\n    }\n  }\n  .railScroll {\n    min-height: 0;\n    flex: 1;\n    overflow-y: auto;\n    overscroll-behavior: contain;\n  }\n  .thumbs {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: var(--pui-space-2);\n    padding: var(--pui-space-3) var(--pui-space-2);\n  }\n  /* the plate that travels to the current page, in the look's current paint (pop: its ink; elsewhere the accent) */\n  .plate {\n    position: absolute;\n    border-radius: var(--pui-radius-control);\n    background: var(--pui-current-tint, var(--pui-accent-tint));\n    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--pui-current-edge, var(--pui-accent)) 45%, transparent);\n    pointer-events: none;\n  }\n  .thumb {\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: var(--pui-space-1);\n    padding: var(--pui-space-1-5) var(--pui-space-2) var(--pui-space-1);\n    border: 0;\n    border-radius: var(--pui-radius-control);\n    background: none;\n    cursor: pointer;\n  }\n  .thumb:focus-visible {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: 0;\n    transition: none;\n  }\n  .thumbPaper {\n    position: relative;\n    display: block;\n    overflow: hidden;\n    border-radius: 2px;\n    box-shadow: var(--m-surface-edge, 0 0 0 1px var(--pui-line));\n    transition: box-shadow var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .thumb:hover .thumbPaper {\n    box-shadow:\n      var(--m-surface-edge, 0 0 0 1px var(--pui-line)),\n      0 0 0 1px var(--pui-line-strong);\n    transition-duration: 0ms;\n  }\n  .thumb[aria-current=\"page\"] .thumbPaper {\n    box-shadow: 0 0 0 2px var(--pui-current-edge, var(--pui-accent));\n  }\n  .thumbNumber {\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-2xs);\n    font-variant-numeric: tabular-nums;\n  }\n  .thumb[aria-current=\"page\"] .thumbNumber {\n    color: var(--pui-current-edge, var(--pui-accent-ink));\n    font-weight: var(--pui-weight-medium);\n  }\n  .thumbMarks {\n    position: absolute;\n    inset-block-start: var(--pui-space-1);\n    inset-inline-end: var(--pui-space-1);\n    display: flex;\n    gap: 2px;\n  }\n  .thumbHits,\n  .thumbNotes {\n    display: inline-flex;\n    align-items: center;\n    gap: 2px;\n    min-inline-size: 16px;\n    block-size: 16px;\n    padding-inline: 3px;\n    border-radius: var(--pui-radius-pill);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-semibold);\n    font-variant-numeric: tabular-nums;\n    justify-content: center;\n    line-height: 1;\n  }\n  .thumbHits {\n    background: var(--pui-orange);\n    color: var(--pui-surface);\n  }\n  /* a page's comment count says how many, never that something is wrong: the look's current paint, not the first data\n     colour (pop's tomato) */\n  .thumbNotes {\n    background: var(--pui-current-edge, var(--pui-series-1));\n    color: var(--pui-surface);\n  }\n\n  /* ── pages ── */\n  .frame {\n    position: relative;\n    display: flex;\n    min-width: 0;\n    min-height: 0;\n    flex: 1 1 0;\n    flex-direction: column;\n  }\n  .pages {\n    position: relative;\n    min-height: 0;\n    flex: 1 1 0;\n    overflow: auto;\n    overscroll-behavior: contain;\n    /* a scrollbar that takes room must not change the width it is measured by (fit width, the states' layout) */\n    scrollbar-gutter: stable both-edges;\n    outline: none;\n    background: var(--pui-inset);\n    /* the viewer's own pinch zooms the pages; the browser keeps panning */\n    touch-action: pan-x pan-y;\n  }\n  .pages:focus-visible {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: calc(-1 * var(--pui-focus-width));\n  }\n  /* the pins sit in the margin beside each page, so the stack keeps room for them on both sides (the page stays\n     centred); a page wider than the view scrolls sideways */\n  .stack {\n    display: flex;\n    min-inline-size: min-content;\n    min-block-size: 100%;\n    flex-direction: column;\n    align-items: center;\n    gap: var(--pui-space-4);\n    padding: var(--pui-space-4) var(--pui-space-8) var(--pui-space-8);\n  }\n  /* a phone keeps only the pins' margin at the end, so the page gets the rest of the width */\n  .root[data-compact] .stack {\n    gap: var(--pui-space-3);\n    padding: var(--pui-space-3) var(--pui-space-7) var(--pui-space-6) var(--pui-space-2);\n  }\n\n  .page {\n    position: relative;\n    flex: none;\n    border-radius: 1px;\n    box-shadow: var(--m-surface-edge, var(--pui-shadow-hairline)), var(--pui-shadow-md);\n    --scale-round-x: 1px;\n    --scale-round-y: 1px;\n  }\n  .page.areaMode {\n    cursor: crosshair;\n    touch-action: none;\n  }\n  .areaMode .textLayer {\n    pointer-events: none;\n  }\n  .placeholder {\n    position: absolute;\n    inset: 0;\n    display: grid;\n    place-items: center;\n    color: var(--pui-ink-3);\n    font-family: var(--pui-font-numeric, var(--pui-font-code));\n    font-size: var(--pui-text-xl);\n  }\n  /* a drawn page crossfades over its hatching; later redraws (a zoom) swap in place */\n  .canvas {\n    position: absolute;\n    inset: 0;\n    opacity: 0;\n    transition: opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n  }\n  .canvas > canvas {\n    display: block;\n    inline-size: 100%;\n    block-size: 100%;\n  }\n  img.canvas {\n    inline-size: 100%;\n    block-size: 100%;\n    object-fit: fill;\n  }\n  [data-drawn] > .canvas,\n  img.canvas[data-loaded] {\n    opacity: 1;\n  }\n  [data-drawn] > .placeholder {\n    opacity: 0;\n    transition: opacity var(--pui-fade-out, var(--pui-motion-out)) var(--pui-ease-default);\n  }\n\n  /* marks on paper: a highlighter's tint, thin enough for the ink to show through, worked out from each tone at one\n     lightness so a mark reads the same on paper in a dark or a light app (no blend mode: inside a look's backdrop\n     filter Chromium can draw a multiplied layer inverted) */\n  .marks {\n    position: absolute;\n    inset: 0;\n    pointer-events: none;\n  }\n  .hit,\n  .mark,\n  .draft {\n    position: absolute;\n  }\n  .hit {\n    border-radius: 2px;\n    background: oklch(from var(--pui-orange) 0.84 0.16 80 / 0.45);\n  }\n  /* the current hit is outlined as well as deeper: the ring is the signal, not the colour */\n  .hit[data-current] {\n    background: oklch(from var(--pui-orange) 0.78 0.17 65 / 0.55);\n    box-shadow: 0 0 0 2px oklch(from var(--pui-orange) 0.5 0.16 55);\n    animation: pui-dv-settle var(--pui-duration-standard) var(--pui-ease-out-strong) both;\n  }\n  @keyframes pui-dv-settle {\n    from {\n      box-shadow: 0 0 0 8px oklch(from var(--pui-orange) 0.5 0.16 55 / 0);\n    }\n  }\n  /* an annotation's default colour is the first data colour: it follows the brand, and stays a colour where a look's\n     accent is white light or ink (prism, mono), which would not show on paper */\n  .mark {\n    --_tone: var(--pui-series-1);\n    border-radius: 2px;\n  }\n  .mark[data-tone=\"info\"],\n  .pin[data-tone=\"info\"],\n  .item[data-tone=\"info\"] {\n    --_tone: var(--pui-blue);\n  }\n  .mark[data-tone=\"success\"],\n  .pin[data-tone=\"success\"],\n  .item[data-tone=\"success\"] {\n    --_tone: var(--pui-green);\n  }\n  .mark[data-tone=\"warning\"],\n  .pin[data-tone=\"warning\"],\n  .item[data-tone=\"warning\"] {\n    --_tone: var(--pui-orange);\n  }\n  .mark[data-tone=\"danger\"],\n  .pin[data-tone=\"danger\"],\n  .item[data-tone=\"danger\"] {\n    --_tone: var(--pui-red);\n  }\n  .mark[data-tone=\"neutral\"],\n  .pin[data-tone=\"neutral\"],\n  .item[data-tone=\"neutral\"] {\n    --_tone: var(--pui-ink-3);\n  }\n  .mark[data-kind=\"text\"] {\n    background: oklch(from var(--_tone) 0.8 min(c, 0.15) h / 0.38);\n  }\n  /* an area is framed, not filled: a shape the eye tells from a highlight without its colour */\n  .mark[data-kind=\"area\"] {\n    border-radius: 3px;\n    background: oklch(from var(--_tone) 0.9 min(c, 0.08) h / 0.25);\n    box-shadow: inset 0 0 0 2px oklch(from var(--_tone) 0.55 max(c, 0.12) h);\n  }\n  .mark[data-resolved] {\n    opacity: 0.45;\n  }\n  .mark[data-selected] {\n    outline: 2px solid oklch(from var(--_tone) 0.5 max(c, 0.12) h);\n    outline-offset: 1px;\n    opacity: 1;\n  }\n  /* a jump lights the place once */\n  .mark[data-flash] {\n    animation: pui-dv-flash var(--pui-duration-flash) var(--pui-ease-out-strong) both;\n  }\n  @keyframes pui-dv-flash {\n    from {\n      box-shadow: 0 0 0 10px oklch(from var(--_tone) 0.75 max(c, 0.12) h / 0.55);\n    }\n    40% {\n      box-shadow: 0 0 0 3px oklch(from var(--_tone) 0.6 max(c, 0.12) h / 0.4);\n    }\n  }\n  .mark[data-kind=\"area\"][data-flash] {\n    animation-name: pui-dv-flash-area;\n  }\n  @keyframes pui-dv-flash-area {\n    from {\n      box-shadow:\n        inset 0 0 0 2px oklch(from var(--_tone) 0.55 max(c, 0.12) h),\n        0 0 0 10px oklch(from var(--_tone) 0.75 max(c, 0.12) h / 0.5);\n    }\n  }\n  /* a new highlight draws in along its lines, one after the other; a new area's frame from its corner */\n  .mark[data-fresh][data-kind=\"text\"] {\n    animation: pui-dv-wipe var(--pui-duration-standard) var(--pui-ease-out-strong) both;\n    animation-delay: calc(var(--i, 0) * var(--pui-stagger-line));\n  }\n  @keyframes pui-dv-wipe {\n    from {\n      clip-path: inset(0 100% 0 0);\n    }\n    to {\n      clip-path: inset(0);\n    }\n  }\n  .mark[data-fresh][data-kind=\"area\"] {\n    animation: pui-dv-frame var(--pui-duration-standard) var(--pui-ease-out-strong) both;\n  }\n  @keyframes pui-dv-frame {\n    from {\n      clip-path: inset(0 100% 100% 0);\n    }\n    to {\n      clip-path: inset(0);\n    }\n  }\n  .draft {\n    border-radius: 3px;\n    background: oklch(from var(--pui-series-1) 0.9 0.06 h / 0.25);\n    box-shadow: inset 0 0 0 1.5px oklch(from var(--pui-series-1) 0.55 max(c, 0.12) h);\n  }\n\n  /* pins: the annotation's number in the margin beside its place; a number and a shape as well as the tone */\n  .pin {\n    --_tone: var(--pui-series-1);\n    position: absolute;\n    inset-inline-start: calc(100% + var(--pui-space-1-5));\n    display: grid;\n    place-items: center;\n    min-inline-size: 20px;\n    block-size: 20px;\n    padding-inline: 4px;\n    border: 0;\n    border-radius: 10px 10px 10px 2px;\n    background: color-mix(in oklab, var(--_tone) 22%, var(--pui-surface));\n    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--_tone) 60%, transparent);\n    color: var(--pui-ink);\n    cursor: pointer;\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-semibold);\n    font-variant-numeric: tabular-nums;\n    line-height: 1;\n    transition:\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .pin:dir(rtl) {\n    border-radius: 10px 10px 2px 10px;\n  }\n  .pin[data-selected] {\n    background: var(--_tone);\n    color: var(--pui-surface);\n    transition-duration: 0ms;\n  }\n  .pin[data-resolved]:not([data-selected]) {\n    background: var(--pui-surface);\n    color: var(--pui-ink-3);\n  }\n  .pin:focus-visible {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: 2px;\n    transition: none;\n  }\n\n  /* pdf.js lays the page's text out as transparent spans over the drawing, so it can be selected and searched; these\n     rules follow the layout contract its TextLayer writes to (sizes from --total-scale-factor and each span's\n     --font-height, --scale-x and --rotate) */\n  .textLayer {\n    position: absolute;\n    inset: 0;\n    overflow: clip;\n    opacity: 1;\n    line-height: 1;\n    letter-spacing: normal;\n    word-spacing: normal;\n    text-align: initial;\n    text-size-adjust: none;\n    forced-color-adjust: none;\n    transform-origin: 0 0;\n    caret-color: transparent;\n    z-index: 0;\n    --min-font-size: 1;\n    --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));\n    --min-font-size-inv: calc(1 / var(--min-font-size));\n  }\n  .textLayer :is(span, br) {\n    position: absolute;\n    color: transparent;\n    white-space: pre;\n    cursor: text;\n    transform-origin: 0% 0%;\n    user-select: text;\n  }\n  .textLayer > span {\n    z-index: 1;\n    --font-height: 0;\n    font-size: calc(var(--text-scale-factor) * var(--font-height));\n    --scale-x: 1;\n    --rotate: 0deg;\n    transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));\n  }\n  .textEnd {\n    position: absolute;\n    inset: 100% 0 0;\n    z-index: 0;\n    display: block;\n    cursor: default;\n    user-select: none;\n  }\n  .textLayer[data-selecting] .textEnd {\n    top: 0;\n  }\n  .textLayer ::selection {\n    background: oklch(from var(--pui-series-1) 0.62 max(c, 0.14) h / 0.35);\n    color: transparent;\n  }\n\n  /* over selected text: the offer to keep it */\n  .selectionBar {\n    position: absolute;\n    z-index: 2;\n    display: flex;\n    gap: var(--pui-space-0-5);\n    padding: var(--pui-space-1);\n    border-radius: var(--pui-radius-control);\n    transform: translate(-50%, calc(-100% - var(--pui-space-2)));\n    animation: pui-dv-bar-in var(--pui-motion-in) var(--pui-ease-enter) both;\n  }\n  @keyframes pui-dv-bar-in {\n    from {\n      opacity: 0;\n      translate: 0 calc(var(--pui-space-1) * var(--pui-motion-travel, 1));\n    }\n  }\n  .areaHint {\n    position: absolute;\n    inset-block-end: var(--pui-space-4);\n    inset-inline-start: 50%;\n    margin: 0;\n    padding: var(--pui-space-1-5) var(--pui-space-3);\n    border-radius: var(--pui-radius-pill);\n    color: var(--pui-ink-2);\n    font-size: var(--pui-text-xs);\n    pointer-events: none;\n    transform: translateX(-50%);\n    white-space: nowrap;\n    animation: pui-dv-bar-in var(--pui-motion-in) var(--pui-ease-enter) both;\n  }\n\n  /* ── annotations panel ── */\n  .panelHead {\n    display: flex;\n    flex: none;\n    align-items: baseline;\n    gap: var(--pui-space-2);\n    padding: var(--pui-space-3) var(--pui-space-4) var(--pui-space-2);\n  }\n  .panelTitle {\n    color: var(--pui-ink);\n    font-family: var(--pui-font-heading, inherit);\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-medium);\n  }\n  .panelCount {\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    font-variant-numeric: tabular-nums;\n  }\n  .panelBody {\n    min-height: 0;\n    flex: 1;\n    overflow-y: auto;\n    overscroll-behavior: contain;\n  }\n  .list {\n    display: flex;\n    flex-direction: column;\n    gap: var(--pui-space-1);\n    margin: 0;\n    padding: 0 var(--pui-space-2) var(--pui-space-3);\n    list-style: none;\n  }\n  .item {\n    --_tone: var(--pui-series-1);\n    position: relative;\n    border-radius: var(--pui-radius-control);\n    transition: background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .item:hover {\n    background: var(--pui-hover);\n    transition-duration: 0ms;\n  }\n  .item[data-selected] {\n    background: var(--pui-current-tint, var(--pui-accent-tint));\n    box-shadow: inset 2px 0 0 var(--pui-current-edge, var(--pui-accent));\n  }\n  .item[data-selected]:dir(rtl) {\n    box-shadow: inset -2px 0 0 var(--pui-current-edge, var(--pui-accent));\n  }\n  .itemGo {\n    display: flex;\n    inline-size: 100%;\n    flex-direction: column;\n    gap: var(--pui-space-1-5);\n    padding: var(--pui-space-2-5) var(--pui-space-3);\n    border: 0;\n    border-radius: inherit;\n    background: none;\n    color: inherit;\n    cursor: pointer;\n    font: inherit;\n    text-align: start;\n  }\n  .itemGo:focus-visible {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: -2px;\n    transition: none;\n  }\n  .itemHead {\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-1-5);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n  }\n  .itemPin {\n    display: grid;\n    place-items: center;\n    min-inline-size: 18px;\n    block-size: 18px;\n    padding-inline: 3px;\n    border-radius: 9px 9px 9px 2px;\n    background: color-mix(in oklab, var(--_tone) 22%, var(--pui-surface));\n    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--_tone) 60%, transparent);\n    color: var(--pui-ink);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-semibold);\n    font-variant-numeric: tabular-nums;\n  }\n  .itemKind {\n    min-width: 0;\n    flex: 1;\n  }\n  .itemResolved {\n    color: var(--pui-green);\n    font-weight: var(--pui-weight-medium);\n  }\n  .quote {\n    display: -webkit-box;\n    overflow: hidden;\n    padding-inline-start: var(--pui-space-2);\n    border-inline-start: 2px solid oklch(from var(--_tone) 0.75 min(c, 0.13) h);\n    color: var(--pui-ink-2);\n    font-size: var(--pui-text-xs);\n    font-style: italic;\n    -webkit-box-orient: vertical;\n    -webkit-line-clamp: 2;\n    quotes: \"“\" \"”\";\n  }\n  .comment {\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    text-wrap: pretty;\n  }\n  .itemFoot {\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-1-5);\n    color: var(--pui-ink-2);\n    font-size: var(--pui-text-xs);\n  }\n  .itemTime {\n    color: var(--pui-ink-3);\n  }\n  .itemTime::before {\n    content: \"·\";\n    margin-inline-end: var(--pui-space-1-5);\n  }\n  .item[data-resolved] .comment,\n  .item[data-resolved] .quote {\n    color: var(--pui-ink-3);\n  }\n  .itemTools {\n    position: absolute;\n    inset-block-start: var(--pui-space-1-5);\n    inset-inline-end: var(--pui-space-1-5);\n    display: flex;\n    gap: var(--pui-space-0-5);\n  }\n  .editor {\n    display: flex;\n    flex-direction: column;\n    gap: var(--pui-space-2);\n    padding: 0 var(--pui-space-3) var(--pui-space-3);\n    animation: pui-dv-bar-in var(--pui-motion-in) var(--pui-ease-enter) both;\n  }\n  .editorActions {\n    display: flex;\n    justify-content: flex-end;\n    gap: var(--pui-space-1-5);\n  }\n\n  /* ── states over the pages ── */\n  .skeleton {\n    display: flex;\n    inline-size: 100%;\n    flex-direction: column;\n    align-items: center;\n    gap: var(--pui-space-4);\n  }\n  .progress {\n    inline-size: min(100%, 22rem);\n  }\n  .skeletonPage {\n    inline-size: min(100%, 36rem);\n    aspect-ratio: 210 / 297;\n    flex: none;\n    border-radius: 2px;\n  }\n  .state {\n    /* the full width of the pages area: an empty state picks its layout by its own width, so it must not shrink to\n       its content (narrow stacks the action full width, which widens it, which un-narrows it, every frame) */\n    align-self: stretch;\n    margin-block: auto;\n  }\n  .locked {\n    display: flex;\n    inline-size: min(100%, 22rem);\n    flex-direction: column;\n    gap: var(--pui-space-3);\n    margin-block: auto;\n    padding: var(--pui-pad-card);\n    border-radius: var(--pui-radius-card);\n    animation: pui-dv-bar-in var(--pui-motion-in) var(--pui-ease-enter) both;\n  }\n  .lockIcon {\n    display: grid;\n    place-items: center;\n    inline-size: var(--pui-size-10);\n    block-size: var(--pui-size-10);\n    border-radius: var(--pui-radius-control);\n    background: var(--pui-hover);\n    color: var(--pui-ink-2);\n  }\n  .lockTitle {\n    margin: 0;\n    color: var(--pui-ink);\n    font-size: var(--pui-text-lg);\n    font-weight: var(--pui-weight-medium);\n    text-wrap: balance;\n  }\n  .lockText {\n    margin: calc(var(--pui-space-2) * -1) 0 0;\n    color: var(--pui-ink-2);\n    font-size: var(--pui-text-sm);\n  }\n\n  /* saving the markup as a copy: the phase in words while it runs, why when it fails, the file once attached */\n  .commit {\n    display: flex;\n    min-width: 0;\n    align-items: center;\n    gap: var(--pui-space-2);\n  }\n  .commitWords {\n    display: inline-flex;\n    min-width: 0;\n    align-items: center;\n    gap: var(--pui-space-1-5);\n    color: var(--pui-ink-2);\n    font-size: var(--pui-text-xs);\n  }\n  .commitText {\n    min-width: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .commitFailed {\n    color: var(--pui-red);\n  }\n  .commitDone {\n    flex: none;\n    inline-size: var(--pui-icon-sm);\n    block-size: var(--pui-icon-sm);\n    color: var(--pui-green);\n  }\n  .commitStrip {\n    justify-content: space-between;\n    padding: var(--pui-space-1-5) var(--pui-space-3);\n    border-bottom: 1px solid var(--pui-line-soft);\n  }\n}\n"},{"path":"beamline/components/document-viewer/document-viewer.tsx","type":"registry:file","target":"src/ui/beamline/components/document-viewer/document-viewer.tsx","content":"\"use client\";\n\nimport { createFormatters, FALLBACK_LOCALE } from \"../../runtime/messages\";\nimport { createTranslator } from \"../../runtime/messages\";\nimport { type Translator } from \"../../runtime/messages\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport {\n  Check,\n  ChevronDown,\n  ChevronLeft,\n  ChevronRight,\n  ChevronUp,\n  FileText,\n  FileWarning,\n  Highlighter,\n  Lock,\n  MessageSquarePlus,\n  MessageSquareText,\n  Minus,\n  PanelLeft,\n  Plus,\n  Search,\n  SquareDashed,\n  Trash2,\n  X,\n  FilePlus2,\n} from \"lucide-react\";\nimport { Spinner } from \"../spinner/spinner\";\nimport { Avatar } from \"../avatar/avatar\";\nimport { BottomSheet } from \"../bottom-sheet/bottom-sheet\";\nimport { Button } from \"../button/button\";\nimport { DropdownMenu, type MenuItem } from \"../dropdown-menu/dropdown-menu\";\nimport { EmptyState } from \"../empty-state/empty-state\";\nimport { PasswordField } from \"../password-field/password-field\";\nimport { Progress } from \"../progress/progress\";\nimport { SearchField } from \"../search-field/search-field\";\nimport { SlotText } from \"../slot-text/slot-text\";\nimport { Textarea } from \"../textarea/textarea\";\nimport { Tooltip } from \"../tooltip/tooltip\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useWidthAs } from \"../../hooks/use-content-width\";\nimport { useFlip } from \"../../hooks/use-flip\";\nimport { glideProps, useIndicator } from \"../../hooks/use-indicator\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport { localId } from \"../../lib/id\";\nimport type { PageText, PdfDocument } from \"./engine\";\nimport {\n  DocumentPage,\n  Thumb,\n  type AnnotationTone,\n  type DocumentImagePage,\n  type OpenDocument,\n  type PageMark,\n} from \"./parts\";\nimport { bounds, findAll, normalize, toFractions, type DocumentRect, type TextSpan } from \"./text\";\nimport styles from \"./document-viewer.module.css\";\n\nexport type { AnnotationTone, DocumentImagePage, DocumentRect };\n\nexport type DocumentZoom = \"fit-width\" | \"fit-page\" | number;\nexport type DocumentSource = string | URL | ArrayBuffer | Uint8Array | Blob;\n\nexport interface DocumentAnnotation {\n  id: string;\n  /** From 1. */\n  page: number;\n  kind: \"text\" | \"area\";\n  /** Where it sits, as fractions of the page from its top left. A text annotation may give only its quote. */\n  rects?: DocumentRect[];\n  /** The text it marks; found on its page when there are no rects. */\n  quote?: string;\n  comment?: string;\n  author?: { name: string; src?: string };\n  /** A time, or how it reads (\"2h ago\"). */\n  createdAt?: Date | number | string;\n  /** Default accent. */\n  tone?: AnnotationTone;\n  /** Settled: drawn quieter, its pin shows a check. */\n  resolved?: boolean;\n  /** A short mark for its pin instead of a number (a change's Δ); it is left out of the numbering. */\n  pin?: string;\n}\n\nexport interface DocumentAnnotationEvent {\n  type: \"create\" | \"comment\" | \"delete\";\n  id: string;\n  /** For create: Highlight, or Comment (a comment is asked for next). */\n  intent?: \"highlight\" | \"comment\";\n}\n\nexport type DocumentViewerClassNames = Partial<\n  Record<\"root\" | \"toolbar\" | \"rail\" | \"pages\" | \"page\" | \"panel\", string>\n>;\n\nexport interface DocumentViewerProps {\n  /** The PDF: an address, its bytes or a Blob. */\n  src?: DocumentSource | null;\n  /** Image pages instead of a PDF. */\n  pages?: DocumentImagePage[];\n  /** The document's name. */\n  title?: string;\n  page?: number;\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n  zoom?: DocumentZoom;\n  defaultZoom?: DocumentZoom;\n  onZoomChange?: (zoom: DocumentZoom) => void;\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  annotations?: DocumentAnnotation[];\n  defaultAnnotations?: DocumentAnnotation[];\n  onAnnotationsChange?: (annotations: DocumentAnnotation[], event: DocumentAnnotationEvent) => void;\n  selectedAnnotation?: string | null;\n  defaultSelectedAnnotation?: string | null;\n  onSelectedAnnotationChange?: (id: string | null) => void;\n  /** Offer Highlight, Comment and Mark area. Default true. */\n  annotate?: boolean;\n  /** Who new annotations belong to. Default { name: \"You\" }. */\n  author?: { name: string; src?: string };\n  thumbnails?: boolean;\n  defaultThumbnails?: boolean;\n  onThumbnailsChange?: (shown: boolean) => void;\n  panel?: boolean;\n  defaultPanel?: boolean;\n  onPanelChange?: (shown: boolean) => void;\n  /** false leaves the built-in annotations list out. Default true. */\n  annotationsPanel?: boolean;\n  /** Your controls at the toolbar's end. */\n  actions?: ReactNode;\n  /**\n   * Saves the markup as a new file; the original never changes. The app draws a marked copy of the document with these\n   * annotations and attaches it, saying each phase through `phase` (\"rendering\", then \"attaching\"), and resolves with\n   * the attached file's name, or rejects with an Error that says why (the toolbar then offers Try again). With it the\n   * toolbar offers Save marked copy whenever the markup differs from what was opened or last saved.\n   */\n  onCommit?: (\n    annotations: DocumentAnnotation[],\n    phase: (phase: \"rendering\" | \"attaching\") => void,\n  ) => Promise<{ name: string }>;\n  /** Whether there is markup not saved as a copy yet: ask before closing while it is true (the page's own unload is\n   *  guarded already). */\n  onUnsavedChange?: (unsaved: boolean) => void;\n  /** Default 640; \"fill\" takes its container's height. */\n  height?: number | \"fill\";\n  /** Skeleton pages while your app is fetching the file. */\n  loading?: boolean;\n  /** Default \"No document to show\". */\n  emptyLabel?: string;\n  /** Where your app serves pdf.js's worker. */\n  workerSrc?: string;\n  onLoad?: (info: { pages: number }) => void;\n  onError?: (error: Error) => void;\n  \"aria-label\"?: string;\n  className?: string;\n  classNames?: DocumentViewerClassNames;\n  style?: CSSProperties;\n  ref?: Ref<HTMLDivElement>;\n}\n\nconst STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];\nconst clampScale = (s: number) => Math.min(4, Math.max(0.25, s));\nconst percent = (s: number, locale = FALLBACK_LOCALE) =>\n  createFormatters(locale).number(s, { style: \"percent\", maximumFractionDigits: 0 });\nfunction when(\n  t: DocumentAnnotation[\"createdAt\"],\n  i18n: Translator<typeof englishMessages> = createTranslator(\"document-viewer\", englishMessages),\n) {\n  if (t === undefined) return \"\";\n  if (typeof t === \"string\") return t;\n  const d = new Date(t);\n  return Date.now() - d.getTime() < 60_000\n    ? i18n(\"justNow\")\n    : i18n.date(d, { day: \"numeric\", month: \"short\", hour: \"2-digit\", minute: \"2-digit\" });\n}\n/** What went wrong, in words a reader can act on. */\nfunction reason(\n  error: unknown,\n  i18n: Translator<typeof englishMessages> = createTranslator(\"document-viewer\", englishMessages),\n) {\n  const name = (error as Error)?.name ?? \"\";\n  const message = (error as Error)?.message ?? \"\";\n  if (name === \"InvalidPDFException\") return i18n(\"theFileIsNotAPDFOr\");\n  const status = /\\b(\\d{3})\\b/.exec(message)?.[1];\n  if (name === \"ResponseException\" || name === \"MissingPDFException\")\n    return status ? i18n(\"theFileCouldNotBeFetched\", { status: status }) : i18n(\"theFileCouldNotBeFetched2\");\n  return message || i18n(\"somethingWentWrongWhileOpeningIt\");\n}\n\n/**\n * A PDF or a paged document, read and marked up in place. Pages are drawn only as they come into view (hatched until\n * then) and sharpened in the background after a zoom; the search marks every hit across the document and steps\n * through them; selected text or a dragged area becomes an annotation with a comment, listed beside the pages and\n * numbered in their margin. pdf.js is fetched the first time a PDF is opened.\n */\nexport function DocumentViewer({\n  src,\n  pages: imagePages,\n  title,\n  page: pageProp,\n  defaultPage = 1,\n  onPageChange,\n  zoom: zoomProp,\n  defaultZoom = \"fit-width\",\n  onZoomChange,\n  query: queryProp,\n  defaultQuery = \"\",\n  onQueryChange,\n  annotations: annotationsProp,\n  defaultAnnotations = [],\n  onAnnotationsChange,\n  selectedAnnotation: selectedProp,\n  defaultSelectedAnnotation = null,\n  onSelectedAnnotationChange,\n  annotate = true,\n  author: authorProp,\n  thumbnails: thumbnailsProp,\n  defaultThumbnails,\n  onThumbnailsChange,\n  panel: panelProp,\n  defaultPanel,\n  onPanelChange,\n  annotationsPanel = true,\n  actions,\n  onCommit,\n  onUnsavedChange,\n  height = 640,\n  loading,\n  emptyLabel: emptyLabelProp,\n  workerSrc,\n  onLoad,\n  onError,\n  \"aria-label\": ariaLabel,\n  className,\n  classNames,\n  style,\n  ref,\n}: DocumentViewerProps) {\n  const i18n = useMessages(\"document-viewer\", englishMessages);\n  const author = authorProp ?? { name: i18n(\"you\") };\n  const emptyLabel = emptyLabelProp === undefined ? i18n(\"emptyLabel\") : emptyLabelProp;\n\n  const root = useRef<HTMLDivElement>(null);\n  const attach = useComposedRef(root, ref);\n  const scroller = useRef<HTMLDivElement>(null);\n  const uid = useId();\n  const motion = useMotionTokens(root);\n  // compact: a phone or a narrow pane (sheets, a folded toolbar); roomy: the annotations panel fits beside the pages\n  const room = useWidthAs(root, (w) => (w < 640 ? 0 : w < 896 ? 1 : 2), 2);\n  const compact = room === 0;\n\n  // ── state ──\n  const [page, setPage] = useControllable(pageProp, defaultPage, onPageChange);\n  const [zoom, setZoom] = useControllable<DocumentZoom>(zoomProp, defaultZoom, onZoomChange);\n  const [query, setQuery] = useControllable(queryProp, defaultQuery, onQueryChange);\n  // a change also says what happened to which note, so that event is reported beside the list\n  const [list, setList] = useControllable(annotationsProp, defaultAnnotations);\n  const [selected, setSelected] = useControllable<string | null>(\n    selectedProp,\n    defaultSelectedAnnotation,\n    onSelectedAnnotationChange,\n  );\n  const [thumbnailsValue, setRail] = useControllable<boolean | undefined, boolean>(\n    thumbnailsProp,\n    defaultThumbnails,\n    onThumbnailsChange,\n  );\n  const rail = thumbnailsValue ?? room > 0;\n  const [panelValue, setPanel] = useControllable<boolean | undefined, boolean>(panelProp, defaultPanel, onPanelChange);\n  const panel = annotationsPanel && (panelValue ?? (room === 2 && list.length > 0));\n  const [doc, setDoc] = useState<OpenDocument | null>(null);\n  const [status, setStatus] = useState<\"empty\" | \"loading\" | \"ready\" | \"error\" | \"locked\">(\"loading\");\n  const [progress, setProgress] = useState<number | null>(null);\n  const [failure, setFailure] = useState<string>(\"\");\n  const [locked, setLocked] = useState<{ wrong: boolean } | null>(null);\n  const [password, setPassword] = useState(\"\");\n  const [attempt, setAttempt] = useState(0);\n  const answer = useRef<((password: string | null) => void) | null>(null);\n  const [roomBox, setRoomBox] = useState<{ w: number; h: number; pad: number } | null>(null);\n  const [texts, setTexts] = useState<(PageText | null)[] | null>(null);\n  const [hitIndex, setHitIndex] = useState(0);\n  const [area, setArea] = useState(false);\n  const [sel, setSel] = useState<{ page: number; rects: DocumentRect[]; quote: string } | null>(null);\n  const [resolvedRects, setResolvedRects] = useState<Record<string, DocumentRect[]>>({});\n  const [flash, setFlash] = useState<{ id: string; key: number } | null>(null);\n  const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set());\n  const [editing, setEditing] = useState<string | null>(null);\n  const [searchOpen, setSearchOpen] = useState(false);\n  const pageEls = useRef(new Map<number, HTMLDivElement>());\n  const anchor = useRef<{ page: number; fy: number; fx: number; oy: number; ox: number } | null>(null);\n  const jumping = useRef(false);\n  const fromScroll = useRef(page);\n  const pending = useRef<\"hit\" | string | null>(null);\n  const currentRects = useRef<{ page: number; rects: DocumentRect[] } | null>(null);\n  const searchInput = useRef<HTMLInputElement>(null);\n  const pinch = useRef<{ d0: number; cx: number; cy: number; k: number } | null>(null);\n  const label = ariaLabel ?? title ?? i18n(\"document\");\n  const total = doc?.sizes.length ?? 0;\n\n  const setAnnotations = (next: DocumentAnnotation[], event: DocumentAnnotationEvent) => {\n    setList(next);\n    onAnnotationsChange?.(next, event);\n  };\n\n  // ── saving the markup as a copy ──\n  // the markup as it was opened or last saved; anything else is unsaved\n  const sig = (l: DocumentAnnotation[]) =>\n    JSON.stringify(l.map((a) => [a.id, a.page, a.rects, a.quote, a.comment, a.tone]));\n  const [savedSig, setSavedSig] = useState(() => sig(list));\n  type Commit =\n    | { state: \"idle\" }\n    | { state: \"rendering\" | \"attaching\" }\n    | { state: \"failed\"; reason: string }\n    | { state: \"attached\"; name: string };\n  const [commit, setCommit] = useState<Commit>({ state: \"idle\" });\n  const unsaved = !!onCommit && sig(list) !== savedSig;\n  const busy = commit.state === \"rendering\" || commit.state === \"attaching\";\n  const unsavedRef = useRef(onUnsavedChange);\n  unsavedRef.current = onUnsavedChange;\n  useEffect(() => unsavedRef.current?.(unsaved), [unsaved]);\n  // leaving the page with unsaved markup asks first (the browser's own prompt)\n  useEffect(() => {\n    if (!unsaved) return;\n    const ask = (e: BeforeUnloadEvent) => e.preventDefault();\n    window.addEventListener(\"beforeunload\", ask);\n    return () => window.removeEventListener(\"beforeunload\", ask);\n  }, [unsaved]);\n  const save = () => {\n    if (!onCommit || busy) return;\n    const marks = list;\n    setCommit({ state: \"rendering\" });\n    onCommit(marks, (phase) => setCommit({ state: phase })).then(\n      ({ name }) => {\n        setSavedSig(sig(marks));\n        setCommit({ state: \"attached\", name });\n      },\n      (error: unknown) =>\n        setCommit({ state: \"failed\", reason: error instanceof Error ? error.message : String(error ?? \"\") }),\n    );\n  };\n  const commitWords =\n    commit.state === \"rendering\"\n      ? i18n(\"drawingCopy\")\n      : commit.state === \"attaching\"\n        ? i18n(\"attachingCopy\")\n        : commit.state === \"failed\"\n          ? commit.reason\n            ? i18n(\"attachFailed\", { reason: commit.reason })\n            : i18n(\"attachFailedNoReason\")\n          : commit.state === \"attached\" && !unsaved\n            ? i18n(\"attachedCopy\", { name: commit.name })\n            : \"\";\n  // drawn in the toolbar, read when it renders (after the document's state below)\n  // in the toolbar's end; on a phone a slim strip of its own under the toolbar, only while there is something to say\n  const commitBar = (strip = false) =>\n    onCommit &&\n    (unsaved || commit.state !== \"idle\") && (\n      <div\n        data-slot=\"document-viewer-commit\"\n        data-state={unsaved && !busy && commit.state !== \"failed\" ? \"unsaved\" : commit.state}\n        className={cn(styles.commit, strip && styles.commitStrip)}\n      >\n        <span role=\"status\" className={cn(styles.commitWords, commit.state === \"failed\" && styles.commitFailed)}>\n          {busy && <Spinner size=\"sm\" label={commitWords} />}\n          {commit.state === \"attached\" && !unsaved && <Check aria-hidden className={styles.commitDone} />}\n          {commitWords && <span className={styles.commitText}>{commitWords}</span>}\n        </span>\n        {commit.state === \"failed\" ? (\n          <Button size=\"sm\" variant=\"secondary\" onClick={save}>\n            {i18n(\"tryAgain\")}\n          </Button>\n        ) : (\n          unsaved &&\n          !busy && (\n            <Button size=\"sm\" variant=\"secondary\" onClick={save} disabled={!ready}>\n              <FilePlus2 aria-hidden />\n              {i18n(\"saveMarkedCopy\")}\n            </Button>\n          )\n        )}\n      </div>\n    );\n\n  // ── opening the document ──\n  useEffect(() => {\n    if (imagePages?.length) {\n      setDoc({\n        kind: \"images\",\n        pages: imagePages,\n        sizes: imagePages.map((p) => ({ width: p.width, height: p.height })),\n      });\n      setStatus(\"ready\");\n      return;\n    }\n    setDoc(null);\n    setTexts(null);\n    setResolvedRects({});\n    if (!src) {\n      setStatus(\"empty\");\n      return;\n    }\n    let live = true;\n    let opened: PdfDocument | null = null;\n    setStatus(\"loading\");\n    setProgress(null);\n    setLocked(null);\n    import(\"./engine\")\n      .then(({ openPdf, LockedError }) =>\n        openPdf(src, {\n          workerSrc,\n          onProgress: (share) => live && setProgress(share),\n          onPassword: (wrong) =>\n            new Promise<string | null>((resolve) => {\n              if (!live) return resolve(null);\n              answer.current = resolve;\n              setLocked({ wrong });\n              setStatus(\"locked\");\n            }),\n        }).catch((error) => {\n          if (error instanceof LockedError) throw Object.assign(new Error(\"locked\"), { locked: true });\n          throw error;\n        }),\n      )\n      .then((pdf) => {\n        if (!live) return pdf.destroy();\n        opened = pdf;\n        answer.current = null;\n        setLocked(null);\n        setPassword(\"\");\n        setDoc({ kind: \"pdf\", pdf, sizes: pdf.sizes });\n        setStatus(\"ready\");\n        onLoad?.({ pages: pdf.pages });\n      })\n      .catch((error: Error & { locked?: boolean }) => {\n        if (!live) return;\n        if (error.locked) return;\n        setFailure(reason(error, i18n));\n        setStatus(\"error\");\n        onError?.(error);\n      });\n    return () => {\n      live = false;\n      answer.current?.(null);\n      answer.current = null;\n      opened?.destroy();\n    };\n  }, [src, imagePages, attempt, workerSrc, i18n]); // eslint-disable-line react-hooks/exhaustive-deps -- callbacks are read when they fire\n\n  // ── room and zoom ──\n  useLayoutEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const measure = () => {\n      const stack = el.firstElementChild as HTMLElement | null;\n      const css = stack && getComputedStyle(stack);\n      // the room the stack's own margins take (the pins' margin on both sides, or one on a phone)\n      const pad = css ? (parseFloat(css.paddingInlineStart) || 0) + (parseFloat(css.paddingInlineEnd) || 0) : 0;\n      setRoomBox((b) =>\n        b && b.w === el.clientWidth && b.h === el.clientHeight && b.pad === pad\n          ? b\n          : { w: el.clientWidth, h: el.clientHeight, pad },\n      );\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [status]);\n  const scale = useMemo(() => {\n    if (typeof zoom === \"number\") return clampScale(zoom);\n    if (!doc || !roomBox) return 1;\n    const widest = Math.max(...doc.sizes.map((s) => s.width));\n    const fitW = (roomBox.w - roomBox.pad) / widest;\n    if (zoom === \"fit-width\") return clampScale(Math.min(fitW, 2));\n    const s = doc.sizes[Math.min(doc.sizes.length, Math.max(1, page)) - 1];\n    return clampScale(Math.min(fitW, (roomBox.h - 32) / s.height));\n  }, [zoom, doc, roomBox, page]);\n\n  /** Remembers where the reader is (a page and a point in it), so a zoom keeps them there. */\n  const keepPlace = (ox = 0, oy = 0) => {\n    const el = scroller.current;\n    const p = pageEls.current.get(page);\n    if (!el || !p) return;\n    anchor.current = {\n      page,\n      fy: (el.scrollTop + oy - p.offsetTop) / p.offsetHeight,\n      fx: (el.scrollLeft + ox - p.offsetLeft) / p.offsetWidth,\n      oy,\n      ox,\n    };\n  };\n  useLayoutEffect(() => {\n    const a = anchor.current;\n    const el = scroller.current;\n    const p = a && pageEls.current.get(a.page);\n    if (!a || !el || !p) return;\n    el.scrollTop = p.offsetTop + a.fy * p.offsetHeight - a.oy;\n    if (el.scrollWidth > el.clientWidth) el.scrollLeft = p.offsetLeft + a.fx * p.offsetWidth - a.ox;\n    anchor.current = null;\n  }, [scale]);\n  const zoomTo = (z: DocumentZoom, ox?: number, oy?: number) => {\n    keepPlace(ox, oy);\n    setZoom(typeof z === \"number\" ? clampScale(z) : z);\n  };\n  const zoomStep = (dir: 1 | -1) => {\n    const next = dir > 0 ? STEPS.find((s) => s > scale + 0.001) : [...STEPS].reverse().find((s) => s < scale - 0.001);\n    if (next) zoomTo(next);\n  };\n  // ⌘/Ctrl + wheel (and a trackpad pinch) zooms around the pointer\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const onWheel = (e: WheelEvent) => {\n      if (!e.ctrlKey && !e.metaKey) return;\n      e.preventDefault();\n      const b = el.getBoundingClientRect();\n      keepPlace(e.clientX - b.left, e.clientY - b.top);\n      setZoom(clampScale(Math.round(scale * Math.exp(-e.deltaY * 0.01) * 100) / 100));\n    };\n    // two fingers on a touch screen: the pages scale with the pinch at once (a transform), and the zoom is set,\n    // around the pinch's centre, when the fingers lift\n    const stack = () => el.firstElementChild as HTMLElement | null;\n    const spread = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);\n    const onTouchStart = (e: TouchEvent) => {\n      if (e.touches.length !== 2) return;\n      const b = el.getBoundingClientRect();\n      pinch.current = {\n        d0: spread(e.touches),\n        cx: (e.touches[0].clientX + e.touches[1].clientX) / 2 - b.left,\n        cy: (e.touches[0].clientY + e.touches[1].clientY) / 2 - b.top,\n        k: 1,\n      };\n      const s = stack();\n      if (s) s.style.transformOrigin = `${el.scrollLeft + pinch.current.cx}px ${el.scrollTop + pinch.current.cy}px`;\n    };\n    const onTouchMove = (e: TouchEvent) => {\n      const p = pinch.current;\n      if (!p || e.touches.length !== 2) return;\n      e.preventDefault();\n      p.k = clampScale(scale * (spread(e.touches) / p.d0)) / scale;\n      const s = stack();\n      if (s) s.style.transform = `scale(${p.k})`;\n    };\n    const onTouchEnd = () => {\n      if (!pinch.current) return;\n      const { k, cx, cy } = pinch.current;\n      pinch.current = null;\n      const s = stack();\n      if (s) s.style.transform = \"\";\n      if (Math.abs(k - 1) < 0.02) return;\n      keepPlace(cx, cy);\n      setZoom(clampScale(Math.round(scale * k * 100) / 100));\n    };\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    el.addEventListener(\"touchstart\", onTouchStart, { passive: true });\n    el.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n    el.addEventListener(\"touchend\", onTouchEnd);\n    el.addEventListener(\"touchcancel\", onTouchEnd);\n    return () => {\n      el.removeEventListener(\"wheel\", onWheel);\n      el.removeEventListener(\"touchstart\", onTouchStart);\n      el.removeEventListener(\"touchmove\", onTouchMove);\n      el.removeEventListener(\"touchend\", onTouchEnd);\n      el.removeEventListener(\"touchcancel\", onTouchEnd);\n    };\n  });\n\n  // ── pages and scrolling ──\n  const register = useCallback((n: number, el: HTMLDivElement | null) => {\n    if (el) pageEls.current.set(n, el);\n    else pageEls.current.delete(n);\n  }, []);\n  const glide = (top: number, left?: number) => {\n    const el = scroller.current;\n    if (!el) return;\n    jumping.current = true;\n    const done = () => {\n      jumping.current = false;\n      onScroll();\n    };\n    if (motion.reduced) {\n      el.scrollTo({ top, left });\n      done();\n      return;\n    }\n    el.addEventListener(\"scrollend\", done, { once: true });\n    window.setTimeout(() => jumping.current && done(), 900);\n    el.scrollTo({ top, left, behavior: \"smooth\" });\n  };\n  const goTo = (n: number) => {\n    if (!total) return;\n    const k = Math.min(total, Math.max(1, n));\n    const p = pageEls.current.get(k);\n    if (!p) return;\n    fromScroll.current = k;\n    setPage(k);\n    glide(p.offsetTop - 12);\n  };\n  /** Glides so a rect of a page sits in the middle of the view. */\n  const reveal = (n: number, r: DocumentRect | null) => {\n    const el = scroller.current;\n    const p = pageEls.current.get(n);\n    if (!el || !p) return;\n    if (!r) return goTo(n);\n    const top = p.offsetTop + (r.y + r.height / 2) * p.offsetHeight - el.clientHeight / 2;\n    const left =\n      el.scrollWidth > el.clientWidth\n        ? p.offsetLeft + (r.x + r.width / 2) * p.offsetWidth - el.clientWidth / 2\n        : undefined;\n    if (page !== n) {\n      fromScroll.current = n;\n      setPage(n);\n    }\n    glide(Math.max(0, top), left);\n  };\n  const onScroll = () => {\n    const el = scroller.current;\n    if (!el || jumping.current || !total) return;\n    const line = el.scrollTop + el.clientHeight * 0.4;\n    let n = 1;\n    for (let k = 1; k <= total; k++) {\n      const p = pageEls.current.get(k);\n      if (p && p.offsetTop <= line) n = k;\n    }\n    if (n !== page) {\n      fromScroll.current = n;\n      setPage(n);\n    }\n  };\n  // a page set from outside scrolls there\n  useEffect(() => {\n    if (status !== \"ready\" || page === fromScroll.current) return;\n    goTo(page);\n  }, [page, status]); // eslint-disable-line react-hooks/exhaustive-deps -- only when the page itself changes\n  // the first page asked for is where the document opens\n  useLayoutEffect(() => {\n    if (status !== \"ready\" || !roomBox) return;\n    const p = pageEls.current.get(page);\n    if (p && page > 1 && scroller.current) scroller.current.scrollTop = p.offsetTop - 12;\n  }, [status, doc, !!roomBox]); // eslint-disable-line react-hooks/exhaustive-deps -- once per document\n\n  // ── search ──\n  const q = normalize(query);\n  useEffect(() => {\n    if (!q || doc?.kind !== \"pdf\" || texts) return;\n    let live = true;\n    const pdf = doc.pdf;\n    void Promise.all(Array.from({ length: pdf.pages }, (_, i) => pdf.text(i + 1).catch(() => null))).then(\n      (t) => live && setTexts(t),\n    );\n    return () => {\n      live = false;\n    };\n  }, [q, doc, texts]);\n  const hits = useMemo(() => {\n    const out: { page: number; span: TextSpan }[] = [];\n    if (!q || !texts) return out;\n    texts.forEach((t, i) => t && findAll(t.text, q).forEach((span) => out.push({ page: i + 1, span })));\n    return out;\n  }, [q, texts]);\n  const hitsByPage = useMemo(() => {\n    const m = new Map<number, TextSpan[]>();\n    for (const h of hits) m.set(h.page, [...(m.get(h.page) ?? []), h.span]);\n    return m;\n  }, [hits]);\n  const current = hits.length ? hits[Math.min(hitIndex, hits.length - 1)] : null;\n  const toHit = (index: number) => {\n    const h = hits[index];\n    if (!h) return;\n    setHitIndex(index);\n    pending.current = \"hit\";\n    currentRects.current = null;\n    const p = pageEls.current.get(h.page);\n    // a hit on a page already laid out reports its place at once; otherwise go to its page and wait for it\n    if (p && page !== h.page) goTo(h.page);\n  };\n  const step = (dir: 1 | -1) =>\n    hits.length && toHit((Math.min(hitIndex, hits.length - 1) + dir + hits.length) % hits.length);\n  // a new search starts at the first hit from the page being read\n  useEffect(() => {\n    if (!hits.length) return;\n    const first = hits.findIndex((h) => h.page >= page);\n    toHit(first < 0 ? 0 : first);\n  }, [q, texts]); // eslint-disable-line react-hooks/exhaustive-deps -- only when the search or the text changes\n  const onCurrentRect = useCallback(\n    (n: number, rects: DocumentRect[]) => {\n      currentRects.current = { page: n, rects };\n      if (pending.current === \"hit\") {\n        pending.current = null;\n        reveal(n, bounds(rects));\n      }\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- reveal reads the latest layout through refs\n    [page, total],\n  );\n\n  // ── annotations ──\n  const ordered = useMemo(() => {\n    const y = (a: DocumentAnnotation) => bounds(a.rects ?? resolvedRects[a.id])?.y ?? 0;\n    return [...list].sort((a, b) => a.page - b.page || y(a) - y(b));\n  }, [list, resolvedRects]);\n  // numbered in reading order; an annotation with a mark of its own (a change) keeps it and takes no number\n  const number = useMemo(() => {\n    let n = 0;\n    return new Map(ordered.map((a) => [a.id, a.pin ?? String(++n)]));\n  }, [ordered]);\n  const marksByPage = useMemo(() => {\n    const m = new Map<number, PageMark[]>();\n    for (const a of ordered) {\n      const n = number.get(a.id)!;\n      const words = a.comment ? `: ${a.comment}` : a.quote ? `: “${a.quote}”` : \"\";\n      m.set(a.page, [\n        ...(m.get(a.page) ?? []),\n        {\n          id: a.id,\n          number: n,\n          kind: a.kind,\n          tone: a.tone ?? \"accent\",\n          resolved: !!a.resolved,\n          rects: a.rects ?? resolvedRects[a.id],\n          quote: a.quote,\n          label: `${a.pin ? i18n(\"mark\") : i18n(\"annotation\")} ${n}${a.resolved ? i18n(\"resolved2\") : \"\"}${words}`,\n        },\n      ]);\n    }\n    return m;\n  }, [ordered, number, resolvedRects, i18n]);\n  const onResolve = useCallback((id: string, rects: DocumentRect[]) => {\n    setResolvedRects((r) => (r[id] ? r : { ...r, [id]: rects }));\n  }, []);\n  const lightUp = (id: string) => setFlash((f) => ({ id, key: (f?.key ?? 0) + 1 }));\n  /** Selects an annotation; from the list (or from outside) it also jumps there and lights it. */\n  const pick = (id: string | null, jump: boolean) => {\n    if (id !== selected) setSelected(id);\n    if (!id) return;\n    if (jump) {\n      const a = list.find((x) => x.id === id);\n      if (!a) return;\n      const rects = a.rects ?? resolvedRects[a.id];\n      if (rects) reveal(a.page, bounds(rects));\n      else {\n        pending.current = id;\n        goTo(a.page);\n      }\n    }\n    lightUp(id);\n  };\n  // an annotation found from its quote after a jump to its page: finish the jump\n  useEffect(() => {\n    const id = pending.current;\n    if (!id || id === \"hit\" || !resolvedRects[id]) return;\n    pending.current = null;\n    const a = list.find((x) => x.id === id);\n    if (a) reveal(a.page, bounds(resolvedRects[id]));\n  }, [resolvedRects]); // eslint-disable-line react-hooks/exhaustive-deps -- when a quote is found\n  // selected from outside (your own list): jump to it\n  const lastSelected = useRef(selected);\n  useEffect(() => {\n    if (selected === lastSelected.current) return;\n    lastSelected.current = selected;\n    if (selected && selectedProp !== undefined && status === \"ready\") pick(selected, true);\n  }, [selected, status]); // eslint-disable-line react-hooks/exhaustive-deps -- only when the selection changes\n  const select = (id: string | null, jump: boolean) => {\n    lastSelected.current = id;\n    pick(id, jump);\n  };\n  useEffect(() => {\n    if (!fresh.size) return;\n    const t = window.setTimeout(() => setFresh(new Set()), 900);\n    return () => clearTimeout(t);\n  }, [fresh]);\n\n  const create = (a: Omit<DocumentAnnotation, \"id\" | \"author\" | \"createdAt\">, intent: \"highlight\" | \"comment\") => {\n    const id = localId(`annotation-${uid.replace(/:/g, \"\")}`);\n    const made: DocumentAnnotation = { ...a, id, author, createdAt: Date.now() };\n    setAnnotations([...list, made], { type: \"create\", id, intent });\n    setFresh(new Set([id]));\n    lastSelected.current = id;\n    setSelected(id);\n    if (intent === \"comment\") {\n      setEditing(id);\n      if (annotationsPanel) setPanel(true);\n    }\n    window.getSelection()?.removeAllRanges();\n    setSel(null);\n  };\n  const comment = (id: string, text: string) => {\n    setAnnotations(\n      list.map((a) => (a.id === id ? { ...a, comment: text } : a)),\n      { type: \"comment\", id },\n    );\n    setEditing(null);\n  };\n  const remove = (id: string) => {\n    setAnnotations(\n      list.filter((a) => a.id !== id),\n      { type: \"delete\", id },\n    );\n    if (selected === id) setSelected(null);\n    if (editing === id) setEditing(null);\n  };\n  const onArea = (n: number, rect: DocumentRect) => {\n    setArea(false);\n    create({ page: n, kind: \"area\", rects: [rect] }, \"comment\");\n  };\n  const markHit = () => {\n    if (!current || !texts || currentRects.current?.page !== current.page) return;\n    const t = texts[current.page - 1];\n    create(\n      {\n        page: current.page,\n        kind: \"text\",\n        rects: currentRects.current.rects,\n        quote: t?.text.slice(current.span.start, current.span.end).trim(),\n      },\n      \"highlight\",\n    );\n  };\n\n  // selected text in one page becomes an offer: Highlight or Comment\n  const readSelection = () => {\n    if (!annotate || area) return;\n    const s = window.getSelection();\n    if (!s || s.isCollapsed || !s.rangeCount) return setSel(null);\n    const range = s.getRangeAt(0);\n    const layerOf = (node: Node | null) =>\n      (node instanceof Element ? node : node?.parentElement)?.closest<HTMLElement>(`.${styles.textLayer}`) ?? null;\n    const layer = layerOf(range.startContainer);\n    if (!layer || layer !== layerOf(range.endContainer) || !scroller.current?.contains(layer)) return setSel(null);\n    const pageEl = layer.closest<HTMLElement>(\"[data-page]\")!;\n    const quote = s.toString().replace(/\\s+/g, \" \").trim();\n    if (!quote) return setSel(null);\n    setSel({\n      page: Number(pageEl.dataset.page),\n      rects: toFractions(range.getClientRects(), pageEl.getBoundingClientRect()),\n      quote,\n    });\n  };\n  useEffect(() => {\n    if (!sel) return;\n    const off = () => {\n      if (window.getSelection()?.isCollapsed) setSel(null);\n    };\n    document.addEventListener(\"selectionchange\", off);\n    return () => document.removeEventListener(\"selectionchange\", off);\n  }, [sel]);\n\n  // ── keyboard ──\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.target !== e.currentTarget && !(e.target as HTMLElement).closest(`.${styles.textLayer}`)) return;\n    const keys: Record<string, () => void> = {\n      ArrowRight: () => goTo(page + 1),\n      ArrowLeft: () => goTo(page - 1),\n      PageDown: () => goTo(page + 1),\n      PageUp: () => goTo(page - 1),\n      Home: () => goTo(1),\n      End: () => goTo(total),\n      \"+\": () => zoomStep(1),\n      \"=\": () => zoomStep(1),\n      \"-\": () => zoomStep(-1),\n      \"0\": () => zoomTo(\"fit-width\"),\n      \"9\": () => zoomTo(\"fit-page\"),\n      Escape: () => {\n        setArea(false);\n        setSel(null);\n        window.getSelection()?.removeAllRanges();\n      },\n    };\n    const run = !e.metaKey && !e.ctrlKey && !e.altKey ? keys[e.key] : undefined;\n    if (run && status === \"ready\") {\n      e.preventDefault();\n      run();\n    }\n  };\n  const onRootKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"f\" && doc?.kind === \"pdf\") {\n      e.preventDefault();\n      setSearchOpen(true);\n      requestAnimationFrame(() => searchInput.current?.focus());\n    }\n    if (e.key === \"Escape\" && area) setArea(false);\n  };\n\n  // ── thumbnails rail ──\n  const railScroll = useRef<HTMLDivElement>(null);\n  const [plateHost, plate] = useIndicator<HTMLDivElement>(\"[aria-current=page]\", `${page}-${rail}-${total}`);\n  // the current page's thumbnail stays in the rail's view\n  useEffect(() => {\n    const t = railScroll.current?.querySelector<HTMLElement>(`[data-page=\"${page}\"]`);\n    t?.scrollIntoView({ block: \"nearest\" });\n  }, [page, rail]);\n  const onRailKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    const d = e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowUp\" ? -1 : 0;\n    if (!d) return;\n    e.preventDefault();\n    const k = Math.min(total, Math.max(1, page + d));\n    goTo(k);\n    railScroll.current?.querySelector<HTMLElement>(`[data-page=\"${k}\"]`)?.focus();\n  };\n  const markCounts = useMemo(() => {\n    const m = new Map<number, number>();\n    for (const a of list) m.set(a.page, (m.get(a.page) ?? 0) + 1);\n    return m;\n  }, [list]);\n  const thumbWidth = compact ? 112 : 96;\n  const thumbs = doc && (\n    <div ref={plateHost} className={styles.thumbs} onKeyDown={onRailKey} role=\"group\" aria-label={i18n(\"pages\")}>\n      {plate && <span aria-hidden className={cn(styles.plate, \"glide glide-2d\")} {...glideProps(plate)} />}\n      {doc.sizes.map((_, i) => (\n        <Thumb\n          key={i}\n          n={i + 1}\n          doc={doc}\n          width={thumbWidth}\n          root={railScroll}\n          current={page === i + 1}\n          hitCount={hitsByPage.get(i + 1)?.length ?? 0}\n          markCount={markCounts.get(i + 1) ?? 0}\n          focusable={page === i + 1}\n          onGo={(n) => {\n            goTo(n);\n            if (compact) setRail(false);\n          }}\n        />\n      ))}\n    </div>\n  );\n\n  // ── the annotations list ──\n  const listEl = useRef<HTMLOListElement>(null);\n  useFlip(listEl);\n  useEffect(() => {\n    if (!selected || !panel) return;\n    listEl.current\n      ?.querySelector<HTMLElement>(`[data-annotation=\"${CSS.escape(selected)}\"]`)\n      ?.scrollIntoView({ block: \"nearest\", behavior: motion.reduced ? \"auto\" : \"smooth\" });\n  }, [selected, panel]); // eslint-disable-line react-hooks/exhaustive-deps -- when the selection or the panel changes\n  const onListKey = (e: KeyboardEvent<HTMLOListElement>) => {\n    const items = [\n      ...(listEl.current?.querySelectorAll<HTMLElement>(\"[data-slot=document-viewer-annotation-go]\") ?? []),\n    ];\n    const at = items.indexOf(document.activeElement as HTMLElement);\n    if (at < 0) return;\n    const d = e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowUp\" ? -1 : 0;\n    if (d) {\n      e.preventDefault();\n      items[Math.min(items.length - 1, Math.max(0, at + d))]?.focus();\n    }\n    if (e.key === \"Delete\" || e.key === \"Backspace\") {\n      const a = ordered[at];\n      if (a && a.author?.name === author.name) {\n        e.preventDefault();\n        remove(a.id);\n        requestAnimationFrame(() => items[Math.max(0, at - 1)]?.focus());\n      }\n    }\n  };\n  const annotationList = (\n    <div className={styles.panelBody}>\n      {ordered.length === 0 ? (\n        <EmptyState\n          size=\"sm\"\n          icon={<Highlighter aria-hidden />}\n          title={i18n(\"noAnnotationsYet\")}\n          description={annotate ? i18n(\"selectTextToHighlightItOrMark\") : undefined}\n        />\n      ) : (\n        <ol ref={listEl} className={styles.list} onKeyDown={onListKey} aria-label={i18n(\"annotations\")}>\n          {ordered.map((a) => {\n            const n = number.get(a.id)!;\n            const own = a.author?.name === author.name;\n            const isEditing = editing === a.id;\n            return (\n              <li\n                key={a.id}\n                data-flip={a.id}\n                data-annotation={a.id}\n                data-slot=\"document-viewer-annotation\"\n                data-selected={selected === a.id ? \"\" : undefined}\n                data-tone={a.tone ?? \"accent\"}\n                data-resolved={a.resolved ? \"\" : undefined}\n                className={cn(styles.item, \"reveal-host\")}\n              >\n                <button\n                  type=\"button\"\n                  data-slot=\"document-viewer-annotation-go\"\n                  className={styles.itemGo}\n                  aria-current={selected === a.id ? \"true\" : undefined}\n                  onClick={() => {\n                    select(a.id, true);\n                    if (compact) setPanel(false);\n                  }}\n                >\n                  <span className={styles.itemHead}>\n                    <span aria-hidden className={styles.itemPin}>\n                      {n}\n                    </span>\n                    {a.kind === \"area\" ? (\n                      <SquareDashed aria-hidden className=\"size-3.5\" />\n                    ) : (\n                      <Highlighter aria-hidden className=\"size-3.5\" />\n                    )}\n                    <span className={styles.itemKind}>\n                      {a.kind === \"area\" ? i18n(\"area\") : i18n(\"highlight\")} {i18n(\"page2\", { page: a.page })}\n                    </span>\n                    {a.resolved && <span className={styles.itemResolved}>{i18n(\"resolved\")}</span>}\n                  </span>\n                  {a.quote && <q className={styles.quote}>{a.quote}</q>}\n                  {a.comment && !isEditing && <span className={styles.comment}>{a.comment}</span>}\n                  <span className={styles.itemFoot}>\n                    {a.author && <Avatar name={a.author.name} src={a.author.src} size=\"xs\" />}\n                    <span>{a.author?.name}</span>\n                    {a.createdAt !== undefined && <span className={styles.itemTime}>{when(a.createdAt, i18n)}</span>}\n                  </span>\n                </button>\n                {isEditing ? (\n                  <CommentEditor\n                    initial={a.comment ?? \"\"}\n                    onSave={(text) => comment(a.id, text)}\n                    onCancel={() => setEditing(null)}\n                  />\n                ) : (\n                  (own || (!a.comment && annotate)) && (\n                    <span className={cn(styles.itemTools, \"reveal\")}>\n                      {annotate && own && (\n                        <Button variant=\"ghost\" size=\"sm\" onClick={() => setEditing(a.id)}>\n                          {a.comment ? i18n(\"edit\") : i18n(\"addAComment\")}\n                        </Button>\n                      )}\n                      {own && (\n                        <Button\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          aria-label={i18n(\"deleteAnnotation\", { n: n })}\n                          onClick={() => remove(a.id)}\n                        >\n                          <Trash2 aria-hidden />\n                        </Button>\n                      )}\n                    </span>\n                  )\n                )}\n              </li>\n            );\n          })}\n        </ol>\n      )}\n    </div>\n  );\n\n  // ── the toolbar ──\n  const zoomItems: MenuItem[] = [\n    {\n      heading: i18n(\"fit\"),\n      label: i18n(\"fitWidth\"),\n      type: \"radio\",\n      checked: zoom === \"fit-width\",\n      shortcut: \"0\",\n      onSelect: () => zoomTo(\"fit-width\"),\n    },\n    {\n      label: i18n(\"fitPage\"),\n      type: \"radio\",\n      checked: zoom === \"fit-page\",\n      shortcut: \"9\",\n      onSelect: () => zoomTo(\"fit-page\"),\n    },\n    ...[0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4].map((s, i): MenuItem => ({\n      heading: i === 0 ? i18n(\"zoom2\") : undefined,\n      separatorBefore: i === 0,\n      label: percent(s, i18n.locale),\n      type: \"radio\",\n      checked: zoom === s,\n      onSelect: () => zoomTo(s),\n    })),\n  ];\n  const ready = status === \"ready\" && !loading && !!doc;\n  const canSearch = ready && doc.kind === \"pdf\";\n  const searchBox = canSearch && (\n    <div className={styles.search} data-open={searchOpen ? \"\" : undefined}>\n      <SearchField\n        ref={searchInput}\n        label={i18n(\"search\", { label: label })}\n        size=\"sm\"\n        shortcut={compact ? undefined : \"/\"}\n        placeholder={i18n(\"search2\")}\n        value={query}\n        onValueChange={(v) => {\n          setQuery(v);\n          setHitIndex(0);\n        }}\n        loading={!!q && !texts}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            step(e.shiftKey ? -1 : 1);\n          }\n          if (e.key === \"Escape\" && !query && compact) setSearchOpen(false);\n        }}\n      />\n      {q && texts && (\n        <span className={styles.hits} aria-live=\"polite\" data-slot=\"document-viewer-hits\">\n          {hits.length\n            ? i18n(\"of\", { value: Math.min(hitIndex, hits.length - 1) + 1, length: hits.length })\n            : i18n(\"noHits\")}\n        </span>\n      )}\n      {hits.length > 0 && (\n        <span className={styles.stepper}>\n          <Button variant=\"ghost\" size=\"sm\" aria-label={i18n(\"previousHit\")} onClick={() => step(-1)}>\n            <ChevronUp aria-hidden />\n          </Button>\n          <Button variant=\"ghost\" size=\"sm\" aria-label={i18n(\"nextHit\")} onClick={() => step(1)}>\n            <ChevronDown aria-hidden />\n          </Button>\n          {annotate && (\n            <Tooltip content={i18n(\"highlightThisHit\")}>\n              <Button variant=\"ghost\" size=\"sm\" aria-label={i18n(\"highlightThisHit\")} onClick={markHit}>\n                <Highlighter aria-hidden />\n              </Button>\n            </Tooltip>\n          )}\n        </span>\n      )}\n      {compact && (\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          aria-label={i18n(\"closeSearch\")}\n          onClick={() => {\n            setQuery(\"\");\n            setSearchOpen(false);\n          }}\n        >\n          <X aria-hidden />\n        </Button>\n      )}\n    </div>\n  );\n\n  const toolbar = (\n    <div data-slot=\"document-viewer-toolbar\" className={cn(styles.toolbar, classNames?.toolbar)}>\n      {compact && searchOpen ? (\n        searchBox\n      ) : (\n        <>\n          {compact ? (\n            // a phone: the page number is the way to the pages (a sheet of thumbnails)\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              disabled={!ready}\n              aria-label={i18n(\"pagesPageOf\", { page: page, total: total })}\n              aria-haspopup=\"dialog\"\n              className={styles.pageButton}\n              onClick={() => setRail(true)}\n            >\n              <PanelLeft aria-hidden />\n              <span className={styles.pageCount}>\n                {total ? page : \"–\"}\n                <span className={styles.pageOf}>/ {total || \"–\"}</span>\n              </span>\n            </Button>\n          ) : (\n            <>\n              <Tooltip content={rail ? i18n(\"hidePages\") : i18n(\"showPages\")}>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  aria-label={i18n(\"pages\")}\n                  aria-pressed={rail}\n                  disabled={!ready}\n                  onClick={() => setRail(!rail)}\n                >\n                  <PanelLeft aria-hidden />\n                </Button>\n              </Tooltip>\n              <div className={styles.pager}>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  aria-label={i18n(\"previousPage\")}\n                  disabled={!ready || page <= 1}\n                  onClick={() => goTo(page - 1)}\n                >\n                  <ChevronLeft aria-hidden />\n                </Button>\n                <PageField page={page} total={total} disabled={!ready} onGo={goTo} />\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  aria-label={i18n(\"nextPage\")}\n                  disabled={!ready || page >= total}\n                  onClick={() => goTo(page + 1)}\n                >\n                  <ChevronRight aria-hidden />\n                </Button>\n              </div>\n            </>\n          )}\n          <div className={styles.zoom}>\n            {!compact && (\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                aria-label={i18n(\"zoomOut\")}\n                disabled={!ready || scale <= 0.25}\n                onClick={() => zoomStep(-1)}\n              >\n                <Minus aria-hidden />\n              </Button>\n            )}\n            <DropdownMenu\n              label={i18n(\"zoom\", { value: percent(scale, i18n.locale) })}\n              items={zoomItems}\n              trigger={\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  disabled={!ready}\n                  aria-label={ready ? i18n(\"zoom\", { value: percent(scale, i18n.locale) }) : i18n(\"zoom2\")}\n                  iconOnly={false}\n                  className={styles.zoomTrigger}\n                >\n                  {ready ? <SlotText value={Math.round(scale * 100)} format={(v) => `${v} %`} /> : <span>–</span>}\n                  <ChevronDown aria-hidden className=\"size-3.5 opacity-70\" />\n                </Button>\n              }\n            />\n            {!compact && (\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                aria-label={i18n(\"zoomIn\")}\n                disabled={!ready || scale >= 4}\n                onClick={() => zoomStep(1)}\n              >\n                <Plus aria-hidden />\n              </Button>\n            )}\n          </div>\n          {compact\n            ? canSearch && (\n                <Button variant=\"ghost\" size=\"sm\" aria-label={i18n(\"search2\")} onClick={() => setSearchOpen(true)}>\n                  <Search aria-hidden />\n                </Button>\n              )\n            : searchBox}\n          <div className={styles.end}>\n            {annotate && (\n              <Tooltip content={area ? i18n(\"stopMarkingEsc\") : i18n(\"markAnArea\")}>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  aria-label={i18n(\"markAnArea\")}\n                  aria-pressed={area}\n                  disabled={!ready}\n                  className={styles.toggle}\n                  onClick={() => {\n                    setArea(!area);\n                    setSel(null);\n                  }}\n                >\n                  <SquareDashed aria-hidden />\n                </Button>\n              </Tooltip>\n            )}\n            {annotationsPanel && (annotate || list.length > 0) && (\n              <Tooltip content={panel ? i18n(\"hideAnnotations\") : i18n(\"showAnnotations\")}>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  aria-label={i18n(\"annotations2\", { length: list.length })}\n                  aria-pressed={panel}\n                  iconOnly={list.length === 0}\n                  disabled={!ready}\n                  className={styles.toggle}\n                  onClick={() => setPanel(!panel)}\n                >\n                  <MessageSquareText aria-hidden />\n                  {list.length > 0 && <SlotText value={list.length} className={styles.count} />}\n                </Button>\n              </Tooltip>\n            )}\n            {!compact && commitBar()}\n            {actions}\n          </div>\n        </>\n      )}\n    </div>\n  );\n\n  // ── states over the pages ──\n  let over: ReactNode = null;\n  if (loading || status === \"loading\")\n    over = (\n      <div className={styles.skeleton} aria-busy data-slot=\"document-viewer-loading\">\n        <span className=\"sr-only\">{i18n(\"opening\", { label: label })}</span>\n        {progress !== null && (\n          <Progress\n            value={Math.round(progress * 100)}\n            size=\"sm\"\n            label={i18n(\"opening2\")}\n            showValue\n            className={styles.progress}\n          />\n        )}\n        {[0, 1, 2].map((i) => (\n          <span key={i} aria-hidden className={cn(styles.skeletonPage, \"sheen\")} />\n        ))}\n      </div>\n    );\n  else if (status === \"empty\")\n    over = <EmptyState icon={<FileText aria-hidden />} title={emptyLabel} className={styles.state} />;\n  else if (status === \"error\")\n    over = (\n      <EmptyState\n        icon={<FileWarning aria-hidden />}\n        title={i18n(\"couldNotOpen\", { value: title ?? i18n(\"theDocument\") })}\n        description={failure}\n        action={\n          <Button variant=\"secondary\" onClick={() => setAttempt((a) => a + 1)}>\n            {i18n(\"tryAgain\")}\n          </Button>\n        }\n        className={styles.state}\n      />\n    );\n  else if (status === \"locked\" && locked)\n    over = (\n      <form\n        className={cn(styles.locked, \"m-surface\")}\n        data-slot=\"document-viewer-locked\"\n        onSubmit={(e) => {\n          e.preventDefault();\n          if (!password) return;\n          const send = answer.current;\n          answer.current = null;\n          setStatus(\"loading\");\n          send?.(password);\n        }}\n      >\n        <span aria-hidden className={styles.lockIcon}>\n          <Lock className=\"size-5\" />\n        </span>\n        <h3 className={styles.lockTitle}>\n          {title ? i18n(\"isLocked\", { title: title }) : i18n(\"thisDocumentIsLocked\")}\n        </h3>\n        <p className={styles.lockText}>{i18n(\"enterItsPasswordToOpenIt\")}</p>\n        <PasswordField\n          label={i18n(\"password\")}\n          value={password}\n          onChange={(e) => setPassword(e.currentTarget.value)}\n          autoComplete=\"off\"\n          error={locked.wrong ? i18n(\"thatPasswordDidNotOpenIt\") : undefined}\n        />\n        <Button type=\"submit\" variant=\"primary\" disabled={!password}>\n          {i18n(\"open\")}\n        </Button>\n      </form>\n    );\n\n  const fill = height === \"fill\";\n  const railBody = (\n    <div ref={railScroll} className={styles.railScroll}>\n      {thumbs}\n    </div>\n  );\n  return (\n    <div\n      ref={attach}\n      data-slot=\"document-viewer\"\n      data-compact={compact ? \"\" : undefined}\n      data-area={area ? \"\" : undefined}\n      aria-label={label}\n      role=\"region\"\n      aria-busy={status === \"loading\" || loading || undefined}\n      className={cn(styles.root, \"m-surface\", fill && styles.fill, classNames?.root, className)}\n      style={{ ...style, ...(fill ? null : { \"--_height\": `${height}px` }) } as CSSProperties}\n      onKeyDown={onRootKeyDown}\n    >\n      {toolbar}\n      {compact && commitBar(true)}\n      <div className={styles.body}>\n        {ready && rail && !compact && (\n          <aside\n            data-slot=\"document-viewer-rail\"\n            className={cn(styles.rail, classNames?.rail)}\n            aria-label={i18n(\"pages\")}\n          >\n            {railBody}\n          </aside>\n        )}\n        <div className={styles.frame}>\n          <div\n            ref={scroller}\n            data-slot=\"document-viewer-pages\"\n            className={cn(styles.pages, classNames?.pages)}\n            tabIndex={0}\n            role=\"document\"\n            aria-label={i18n(\"pageOf2\", { label: label, page: page, value: total || \"…\" })}\n            onScroll={onScroll}\n            onKeyDown={onKeyDown}\n            onPointerDown={(e) => {\n              const layer = (e.target as HTMLElement).closest(`.${styles.textLayer}`);\n              if (!layer || area) return;\n              layer.setAttribute(\"data-selecting\", \"\");\n              const done = () => {\n                layer.removeAttribute(\"data-selecting\");\n                readSelection();\n              };\n              document.addEventListener(\"pointerup\", done, { once: true });\n            }}\n            onKeyUp={(e) => e.shiftKey && readSelection()}\n          >\n            <div className={styles.stack}>\n              {over ??\n                (doc &&\n                  doc.sizes.map((_, i) => {\n                    const n = i + 1;\n                    const pageHits = hitsByPage.get(n) ?? [];\n                    return (\n                      <DocumentPage\n                        key={n}\n                        className={classNames?.page}\n                        n={n}\n                        doc={doc}\n                        scale={scale}\n                        root={scroller}\n                        hits={pageHits}\n                        current={current && current.page === n ? pageHits.indexOf(current.span) : -1}\n                        marks={marksByPage.get(n) ?? EMPTY}\n                        selected={selected}\n                        flash={flash}\n                        fresh={fresh}\n                        area={area}\n                        total={total}\n                        onArea={onArea}\n                        onPick={(id) => select(id, false)}\n                        onResolve={onResolve}\n                        onCurrentRect={onCurrentRect}\n                        register={register}\n                      >\n                        {sel?.page === n && (\n                          <SelectionBar\n                            rect={bounds(sel.rects)!}\n                            onHighlight={() =>\n                              create({ page: n, kind: \"text\", rects: sel.rects, quote: sel.quote }, \"highlight\")\n                            }\n                            onComment={() =>\n                              create({ page: n, kind: \"text\", rects: sel.rects, quote: sel.quote }, \"comment\")\n                            }\n                          />\n                        )}\n                      </DocumentPage>\n                    );\n                  }))}\n            </div>\n          </div>\n          {area && ready && (\n            <p className={cn(styles.areaHint, \"m-overlay\")} role=\"status\">\n              {i18n(\"dragAcrossAPageToMarkAn\")}\n            </p>\n          )}\n        </div>\n        {ready && panel && !compact && (\n          <aside\n            data-slot=\"document-viewer-panel\"\n            className={cn(styles.panel, classNames?.panel)}\n            aria-label={i18n(\"annotations\")}\n          >\n            <div className={styles.panelHead}>\n              <span className={styles.panelTitle}>{i18n(\"annotations\")}</span>\n              <span className={styles.panelCount}>{list.length}</span>\n            </div>\n            {annotationList}\n          </aside>\n        )}\n      </div>\n      {compact && ready && (\n        <>\n          <BottomSheet title={i18n(\"pages\")} open={rail} onOpenChange={setRail} detents={[0.5, 0.92]}>\n            {railBody}\n          </BottomSheet>\n          {annotationsPanel && (\n            <BottomSheet\n              title={i18n(\"annotations3\", { length: list.length })}\n              open={panel}\n              onOpenChange={setPanel}\n              detents={[0.55, 0.92]}\n            >\n              {annotationList}\n            </BottomSheet>\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n\nconst EMPTY: PageMark[] = [];\n\n/** The page number field: type a page and press Enter. */\nfunction PageField({\n  page,\n  total,\n  disabled,\n  onGo,\n}: {\n  page: number;\n  total: number;\n  disabled: boolean;\n  onGo: (n: number) => void;\n}) {\n  const i18n = useMessages(\"document-viewer\", englishMessages);\n\n  const [draft, setDraft] = useState<string | null>(null);\n  return (\n    <label className={styles.pageField}>\n      <span className=\"sr-only\">{i18n(\"page3\")}</span>\n      <input\n        className={cn(styles.pageInput, \"m-field\")}\n        inputMode=\"numeric\"\n        disabled={disabled}\n        value={draft ?? (total ? String(page) : \"–\")}\n        size={Math.max(2, String(total).length)}\n        onFocus={(e) => e.currentTarget.select()}\n        onChange={(e) => setDraft(e.currentTarget.value.replace(/\\D/g, \"\"))}\n        onBlur={() => setDraft(null)}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" && draft) {\n            onGo(Number(draft));\n            setDraft(null);\n          }\n          if (e.key === \"Escape\") setDraft(null);\n        }}\n      />\n      <span className={styles.pageTotal}>\n        {i18n(\"of2\")} {total || \"–\"}\n      </span>\n    </label>\n  );\n}\n\n/** Over selected text: Highlight it, or highlight it and write a comment. Pressing keeps the selection. */\nfunction SelectionBar({\n  rect,\n  onHighlight,\n  onComment,\n}: {\n  rect: DocumentRect;\n  onHighlight: () => void;\n  onComment: () => void;\n}) {\n  const i18n = useMessages(\"document-viewer\", englishMessages);\n\n  return (\n    <div\n      data-slot=\"document-viewer-selection-bar\"\n      className={cn(styles.selectionBar, \"m-overlay\")}\n      style={{ left: `${(rect.x + rect.width / 2) * 100}%`, top: `${rect.y * 100}%` } as CSSProperties}\n      onPointerDown={(e) => {\n        e.preventDefault();\n        e.stopPropagation();\n      }}\n      onPointerUp={(e) => e.stopPropagation()}\n    >\n      <Button variant=\"ghost\" size=\"sm\" onClick={onHighlight}>\n        <Highlighter aria-hidden />\n        {i18n(\"highlight\")}\n      </Button>\n      <Button variant=\"ghost\" size=\"sm\" onClick={onComment}>\n        <MessageSquarePlus aria-hidden />\n        {i18n(\"comment\")}\n      </Button>\n    </div>\n  );\n}\n\n/** A comment being written: ⌘Enter saves, Escape cancels. */\nfunction CommentEditor({\n  initial,\n  onSave,\n  onCancel,\n}: {\n  initial: string;\n  onSave: (text: string) => void;\n  onCancel: () => void;\n}) {\n  const i18n = useMessages(\"document-viewer\", englishMessages);\n\n  const [text, setText] = useState(initial);\n  return (\n    <div className={styles.editor} data-slot=\"document-viewer-editor\">\n      <Textarea\n        label={i18n(\"comment\")}\n        hideLabel\n        autoGrow\n        rows={2}\n        maxRows={6}\n        autoFocus\n        placeholder={i18n(\"writeAComment\")}\n        value={text}\n        onChange={(e) => setText(e.currentTarget.value)}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey) && text.trim()) {\n            e.preventDefault();\n            onSave(text.trim());\n          }\n          if (e.key === \"Escape\") {\n            e.preventDefault();\n            e.stopPropagation();\n            onCancel();\n          }\n        }}\n      />\n      <span className={styles.editorActions}>\n        <Button variant=\"ghost\" size=\"sm\" onClick={onCancel}>\n          {i18n(\"cancel\")}\n        </Button>\n        <Button variant=\"primary\" size=\"sm\" disabled={!text.trim()} onClick={() => onSave(text.trim())}>\n          {i18n(\"save\")}\n        </Button>\n      </span>\n    </div>\n  );\n}\n\nexport default DocumentViewer;\n"},{"path":"beamline/components/document-viewer/engine.ts","type":"registry:file","target":"src/ui/beamline/components/document-viewer/engine.ts","content":"// The PDF engine behind DocumentViewer. Only this file imports pdf.js, and the viewer loads it with import() the first\n// time a PDF is opened, so a page without a viewer (or a viewer showing image pages) never downloads it.\nimport { getDocument, GlobalWorkerOptions, PasswordResponses, TextLayer } from \"pdfjs-dist\";\nimport type { PDFDocumentProxy, PDFPageProxy, RenderTask } from \"pdfjs-dist\";\n\n/** pdf.js's 100 % is 96 CSS px to the inch, a PDF point is 1/72 inch. */\nconst CSS_PER_POINT = 96 / 72;\n/** The largest canvas a page is drawn into (pixels); past it a deep zoom draws softer instead of failing. */\nconst MAX_CANVAS = 2 ** 24;\n\nexport interface PageText {\n  /** The text of each item, in the order pdf.js lays them out (one per text-layer span). */\n  items: string[];\n  /** Where each item starts in `text`. */\n  starts: number[];\n  /** The page's text for searching: items joined, a space where a line ends. */\n  text: string;\n}\n\nexport interface PdfDocument {\n  pages: number;\n  /** Each page's size at 100 %, in CSS px. */\n  sizes: { width: number; height: number }[];\n  /** Draws a page into the canvas at the given zoom (1 = 100 %); the canvas is sized for the screen's pixel ratio. */\n  render(page: number, canvas: HTMLCanvasElement, scale: number): { done: Promise<void>; cancel(): void };\n  text(page: number): Promise<PageText>;\n  /** Lays the page's selectable text into the container (sized by --total-scale-factor on an ancestor). */\n  textLayer(page: number, container: HTMLElement): { done: Promise<HTMLElement[]>; cancel(): void };\n  destroy(): void;\n}\n\nexport interface OpenOptions {\n  workerSrc?: string;\n  onProgress?: (share: number | null) => void;\n  /** Asked when the file is locked; `wrong` after a password that did not open it. Resolve null to give up. */\n  onPassword?: (wrong: boolean) => Promise<string | null>;\n}\n\nexport class LockedError extends Error {\n  constructor() {\n    super(\"The document is locked with a password\");\n    this.name = \"LockedError\";\n  }\n}\n\nlet ready: Promise<void> | null = null;\n/** Where pdf.js reads the file. Given the address of its worker (workerSrc), in that worker, one for every viewer on\n *  the page, so a long document never holds up the page. Otherwise on the page itself, through a plain import() that\n *  any bundler (and the system's own package build) handles; a worker the browser cannot start falls back to that. */\nfunction worker(workerSrc?: string) {\n  return (ready ??= (async () => {\n    if (GlobalWorkerOptions.workerPort || GlobalWorkerOptions.workerSrc) return;\n    if (workerSrc) {\n      try {\n        const port = new Worker(new URL(workerSrc, document.baseURI), { type: \"module\" });\n        // the worker says \"ready\" as soon as it runs; an error first means it never will\n        const started = await new Promise<boolean>((resolve) => {\n          port.addEventListener(\"message\", () => resolve(true), { once: true });\n          port.addEventListener(\"error\", () => resolve(false), { once: true });\n        });\n        if (started) {\n          GlobalWorkerOptions.workerPort = port;\n          return;\n        }\n        port.terminate();\n      } catch {\n        // no module workers here: the page itself\n      }\n    }\n    // @ts-expect-error -- pdf.js ships no types for its worker bundle; importing it is only for its side effect\n    await import(\"pdfjs-dist/build/pdf.worker.min.mjs\");\n  })());\n}\n\ntype Source = string | URL | ArrayBuffer | Uint8Array | Blob;\n\nasync function bytesOf(src: Source): Promise<{ url?: string; data?: Uint8Array }> {\n  if (src instanceof Blob) return { data: new Uint8Array(await src.arrayBuffer()) };\n  if (src instanceof ArrayBuffer) return { data: new Uint8Array(src.slice(0)) };\n  // pdf.js takes ownership of the bytes it is given; a copy keeps the caller's own\n  if (src instanceof Uint8Array) return { data: src.slice() };\n  return { url: new URL(String(src), document.baseURI).href };\n}\n\nexport async function openPdf(src: Source, options: OpenOptions = {}): Promise<PdfDocument> {\n  await worker(options.workerSrc);\n  // errors only: pdf.js's warnings (a font it substitutes, an object it skips) are not the reader's business\n  const task = getDocument({ ...(await bytesOf(src)), verbosity: 0 });\n  let locked = false;\n  task.onPassword = (answer: (password: string) => void, reason: number) => {\n    locked = true;\n    const wrong = reason === PasswordResponses.INCORRECT_PASSWORD;\n    if (!options.onPassword) {\n      void task.destroy();\n      return;\n    }\n    void options.onPassword(wrong).then((password) => {\n      if (password === null) void task.destroy();\n      else answer(password);\n    });\n  };\n  task.onProgress = ({ loaded, total }: { loaded: number; total: number }) =>\n    options.onProgress?.(total > 0 ? Math.min(1, loaded / total) : null);\n  let doc: PDFDocumentProxy;\n  try {\n    doc = await task.promise;\n  } catch (error) {\n    if (locked) throw new LockedError();\n    throw error;\n  }\n  const proxies = await Promise.all(Array.from({ length: doc.numPages }, (_, i) => doc.getPage(i + 1)));\n  const sizes = proxies.map((p) => {\n    const v = p.getViewport({ scale: CSS_PER_POINT });\n    return { width: v.width, height: v.height };\n  });\n  const texts = new Map<number, Promise<PageText>>();\n  const page = (n: number): PDFPageProxy => proxies[n - 1];\n\n  return {\n    pages: doc.numPages,\n    sizes,\n    render(n, canvas, scale) {\n      const viewport = page(n).getViewport({ scale: scale * CSS_PER_POINT });\n      const ratio = Math.min(window.devicePixelRatio || 1, Math.sqrt(MAX_CANVAS / (viewport.width * viewport.height)));\n      canvas.width = Math.floor(viewport.width * ratio);\n      canvas.height = Math.floor(viewport.height * ratio);\n      let job: RenderTask | null = page(n).render({\n        canvas,\n        viewport,\n        transform: ratio === 1 ? undefined : [ratio, 0, 0, ratio, 0, 0],\n      });\n      return {\n        done: job.promise.finally(() => (job = null)),\n        cancel: () => job?.cancel(),\n      };\n    },\n    text(n) {\n      let known = texts.get(n);\n      if (!known) {\n        known = page(n)\n          .getTextContent()\n          .then((content) => {\n            const items: string[] = [];\n            const starts: number[] = [];\n            let text = \"\";\n            for (const item of content.items) {\n              if (!(\"str\" in item)) continue;\n              starts.push(text.length);\n              items.push(item.str);\n              text += item.str;\n              if (item.hasEOL && !text.endsWith(\" \")) text += \" \";\n            }\n            return { items, starts, text };\n          });\n        texts.set(n, known);\n      }\n      return known;\n    },\n    textLayer(n, container) {\n      const layer = new TextLayer({\n        textContentSource: page(n).streamTextContent(),\n        container,\n        viewport: page(n).getViewport({ scale: CSS_PER_POINT }),\n      });\n      return { done: layer.render().then(() => layer.textDivs), cancel: () => layer.cancel() };\n    },\n    destroy() {\n      void task.destroy();\n    },\n  };\n}\n"},{"path":"beamline/components/document-viewer/messages.ts","type":"registry:file","target":"src/ui/beamline/components/document-viewer/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English defaults; instance labels take precedence. */\nexport const english = {\n  saveMarkedCopy: \"Save marked copy\",\n  drawingCopy: \"Drawing the marked copy…\",\n  attachingCopy: \"Attaching…\",\n  attachFailed: \"Couldn't attach: {reason}\",\n  attachFailedNoReason: \"Couldn't attach the marked copy.\",\n  attachedCopy: \"Attached {name}; the original is unchanged\",\n  pageOf: \"Page {n} of {total}\",\n  page: \"Page {n}\",\n  emptyLabel: \"No document to show\",\n  pages: \"Pages\",\n  noAnnotationsYet: \"No annotations yet\",\n  selectTextToHighlightItOrMark: \"Select text to highlight it, or mark an area of a page.\",\n  annotations: \"Annotations\",\n  page2: \"· page {page}\",\n  area: \"Area\",\n  highlight: \"Highlight\",\n  resolved: \"Resolved\",\n  edit: \"Edit\",\n  addAComment: \"Add a comment\",\n  deleteAnnotation: \"Delete annotation {n}\",\n  fitWidth: \"Fit width\",\n  fitPage: \"Fit page\",\n  search: \"Search {label}\",\n  search2: \"Search\",\n  of: \"{value} of {length}\",\n  noHits: \"No hits\",\n  previousHit: \"Previous hit\",\n  nextHit: \"Next hit\",\n  highlightThisHit: \"Highlight this hit\",\n  closeSearch: \"Close search\",\n  pagesPageOf: \"Pages, page {page} of {total}\",\n  hidePages: \"Hide pages\",\n  showPages: \"Show pages\",\n  previousPage: \"Previous page\",\n  nextPage: \"Next page\",\n  zoomOut: \"Zoom out\",\n  zoom: \"Zoom, {value}\",\n  zoom2: \"Zoom\",\n  zoomIn: \"Zoom in\",\n  stopMarkingEsc: \"Stop marking (Esc)\",\n  markAnArea: \"Mark an area\",\n  hideAnnotations: \"Hide annotations\",\n  showAnnotations: \"Show annotations\",\n  annotations2: \"Annotations, {length}\",\n  opening: \"Opening {label}\",\n  opening2: \"Opening\",\n  couldNotOpen: \"Could not open {value}\",\n  tryAgain: \"Try again\",\n  isLocked: \"{title} is locked\",\n  thisDocumentIsLocked: \"This document is locked\",\n  enterItsPasswordToOpenIt: \"Enter its password to open it.\",\n  password: \"Password\",\n  thatPasswordDidNotOpenIt: \"That password did not open it.\",\n  open: \"Open\",\n  pageOf2: \"{label}, page {page} of {value}\",\n  dragAcrossAPageToMarkAn: \"Drag across a page to mark an area · Esc to stop\",\n  annotations3: \"Annotations · {length}\",\n  page3: \"Page\",\n  of2: \"of\",\n  comment: \"Comment\",\n  writeAComment: \"Write a comment\",\n  cancel: \"Cancel\",\n  save: \"Save\",\n  annotations4: {\n    one: \"{count} annotation\",\n    other: \"{count} annotations\",\n  },\n  hits: {\n    one: \"{count} hit\",\n    other: \"{count} hits\",\n  },\n  document: \"Document\",\n  mark: \"Mark\",\n  annotation: \"Annotation\",\n  fit: \"Fit\",\n  justNow: \"Just now\",\n  theFileIsNotAPDFOr: \"The file is not a PDF, or it is damaged.\",\n  theFileCouldNotBeFetched: \"The file could not be fetched ({status}).\",\n  theFileCouldNotBeFetched2: \"The file could not be fetched.\",\n  somethingWentWrongWhileOpeningIt: \"Something went wrong while opening it.\",\n  resolved2: \", resolved\",\n  theDocument: \"the document\",\n  you: \"You\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/document-viewer/parts.tsx","type":"registry:file","target":"src/ui/beamline/components/document-viewer/parts.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport { Check, Search } from \"lucide-react\";\nimport { useDrag, type DragPoint } from \"../../hooks/use-drag\";\nimport { cn } from \"../../lib/utils\";\nimport type { PageText, PdfDocument } from \"./engine\";\nimport { bounds, findAll, spanRects, type DocumentRect, type TextSpan } from \"./text\";\nimport styles from \"./document-viewer.module.css\";\n\n/** An area being marked, in shares of the page: where the press began and where the hand is. */\ntype Draft = { x0: number; y0: number; x1: number; y1: number };\n\nexport type AnnotationTone = \"accent\" | \"info\" | \"success\" | \"warning\" | \"danger\" | \"neutral\";\n\nexport interface DocumentImagePage {\n  src: string;\n  /** Size at 100 %, in CSS px. */\n  width: number;\n  height: number;\n  alt?: string;\n}\n\n/** What the viewer has open: a PDF through the engine, or image pages. */\nexport type OpenDocument =\n  | { kind: \"pdf\"; pdf: PdfDocument; sizes: { width: number; height: number }[] }\n  | { kind: \"images\"; pages: DocumentImagePage[]; sizes: { width: number; height: number }[] };\n\n/** An annotation as a page draws it: its number in the list and where it sits (found from its quote if need be). */\nexport interface PageMark {\n  id: string;\n  /** The pin's text: the annotation's number, or its own mark. */\n  number: string;\n  kind: \"text\" | \"area\";\n  tone: AnnotationTone;\n  resolved: boolean;\n  rects?: DocumentRect[];\n  quote?: string;\n  label: string;\n}\n\nconst pct = (v: number) => `${(v * 100).toFixed(3)}%`;\nconst place = (r: DocumentRect): CSSProperties => ({\n  left: pct(r.x),\n  top: pct(r.y),\n  width: pct(r.width),\n  height: pct(r.height),\n});\n\n/** Becomes true while `el` is within a screen of the scroller's view. */\nfunction useNear(el: RefObject<HTMLElement | null>, root: RefObject<HTMLElement | null>, margin = \"100% 0px\") {\n  const [near, setNear] = useState(false);\n  useEffect(() => {\n    const target = el.current;\n    if (!target) return;\n    const io = new IntersectionObserver(([entry]) => setNear(entry.isIntersecting), {\n      root: root.current,\n      rootMargin: margin,\n    });\n    io.observe(target);\n    return () => io.disconnect();\n  }, [el, root, margin]);\n  return near;\n}\n\n/** Draws a page (or its thumbnail) into a canvas while it is near the view; a new zoom draws a sharp copy in the\n *  background and swaps it in, the old one stretched until then. `forget` clears a page that has gone far away. */\nfunction useDrawn(\n  host: RefObject<HTMLDivElement | null>,\n  doc: OpenDocument,\n  n: number,\n  scale: number,\n  near: boolean,\n  forget: boolean,\n) {\n  const [drawn, setDrawn] = useState(false);\n  const once = useRef(false);\n  useEffect(() => {\n    // image pages are the browser's to load: once near, an image stays\n    if (doc.kind === \"images\") {\n      if (near) setDrawn(true);\n      return;\n    }\n    if (!host.current) return;\n    if (!near) {\n      if (!forget || !once.current) return;\n      const t = window.setTimeout(() => {\n        host.current?.replaceChildren();\n        once.current = false;\n        setDrawn(false);\n      }, 1500);\n      return () => clearTimeout(t);\n    }\n    const canvas = document.createElement(\"canvas\");\n    canvas.setAttribute(\"aria-hidden\", \"true\");\n    let job: ReturnType<PdfDocument[\"render\"]> | null = null;\n    let live = true;\n    // a zoom that is still changing (a held key, a pinch) is drawn once it settles\n    const t = window.setTimeout(\n      () => {\n        job = doc.pdf.render(n, canvas, scale);\n        job.done\n          .then(() => {\n            if (!live || !host.current) return;\n            host.current.replaceChildren(canvas);\n            once.current = true;\n            setDrawn(true);\n          })\n          .catch(() => {\n            /* cancelled, or the document was closed */\n          });\n      },\n      once.current ? 140 : 0,\n    );\n    return () => {\n      live = false;\n      clearTimeout(t);\n      job?.cancel();\n    };\n  }, [doc, n, scale, near, forget, host]);\n  return drawn;\n}\n\ninterface PageProps {\n  className?: string;\n  n: number;\n  doc: OpenDocument;\n  scale: number;\n  root: RefObject<HTMLDivElement | null>;\n  hits: TextSpan[];\n  /** Index into hits of the current hit, or -1. */\n  current: number;\n  marks: PageMark[];\n  selected: string | null;\n  /** The annotation that lights once (its id and a counter, so a second jump to it lights again). */\n  flash: { id: string; key: number } | null;\n  fresh: ReadonlySet<string>;\n  area: boolean;\n  total: number;\n  onArea: (n: number, rect: DocumentRect) => void;\n  onPick: (id: string) => void;\n  onResolve: (id: string, rects: DocumentRect[]) => void;\n  onCurrentRect: (n: number, rects: DocumentRect[]) => void;\n  /** The page's drawn element, or null when it leaves. */\n  register: (n: number, el: HTMLDivElement | null) => void;\n  children?: ReactNode;\n}\n\nexport function DocumentPage({\n  className,\n  n,\n  doc,\n  scale,\n  root,\n  hits,\n  current,\n  marks,\n  selected,\n  flash,\n  fresh,\n  area,\n  total,\n  onArea,\n  onPick,\n  onResolve,\n  onCurrentRect,\n  register,\n  children,\n}: PageProps) {\n  const i18n = useMessages(\"document-viewer\", englishMessages);\n\n  const el = useRef<HTMLDivElement>(null);\n  const host = useRef<HTMLDivElement>(null);\n  const layer = useRef<HTMLDivElement>(null);\n  const near = useNear(el, root);\n  const drawn = useDrawn(host, doc, n, scale, near, doc.sizes.length > 24);\n  const [divs, setDivs] = useState<HTMLElement[] | null>(null);\n  const [text, setText] = useState<PageText | null>(null);\n  const [hitRects, setHitRects] = useState<DocumentRect[][]>([]);\n  const [draft, setDraft] = useState<Draft | null>(null);\n  const size = doc.sizes[n - 1];\n\n  useLayoutEffect(() => {\n    register(n, el.current);\n    return () => register(n, null);\n  }, [n, register]);\n\n  // the selectable text: laid out once, it follows the zoom through --total-scale-factor\n  useEffect(() => {\n    if (!near || doc.kind !== \"pdf\" || divs || !layer.current) return;\n    layer.current.replaceChildren();\n    const job = doc.pdf.textLayer(n, layer.current);\n    let live = true;\n    job.done\n      .then((d) => {\n        if (!live || !layer.current) return;\n        // a cover under the text that spreads over the page while a selection is dragged, so crossing the gaps\n        // between lines extends the selection instead of jumping it (the arrangement pdf.js's own viewer uses)\n        const end = document.createElement(\"div\");\n        end.className = styles.textEnd;\n        layer.current.append(end);\n        setDivs(d);\n      })\n      .catch(() => {});\n    void doc.pdf.text(n).then((t) => live && setText(t));\n    return () => {\n      live = false;\n      job.cancel();\n    };\n  }, [near, doc, n, divs]);\n\n  // hits and quotes become rects (fractions of the page) once the text is laid out\n  useLayoutEffect(() => {\n    const box = el.current?.getBoundingClientRect();\n    if (!divs || !text || !box) return setHitRects([]);\n    setHitRects(hits.map((h) => spanRects(h, text, divs, box)));\n  }, [divs, text, hits]);\n  useLayoutEffect(() => {\n    const box = el.current?.getBoundingClientRect();\n    if (!divs || !text || !box) return;\n    for (const m of marks) {\n      if (m.rects || !m.quote) continue;\n      const span = findAll(text.text, m.quote)[0];\n      if (span) onResolve(m.id, spanRects(span, text, divs, box));\n    }\n  }, [divs, text, marks, onResolve]);\n  useEffect(() => {\n    const rects = current >= 0 ? hitRects[current] : undefined;\n    if (rects?.length) onCurrentRect(n, rects);\n  }, [current, hitRects, n, onCurrentRect]);\n\n  const at = (p: DragPoint) => {\n    const b = el.current!.getBoundingClientRect();\n    return {\n      x: Math.max(0, Math.min(1, (p.x - b.left) / b.width)),\n      y: Math.max(0, Math.min(1, (p.y - b.top) / b.height)),\n    };\n  };\n  const draftRect = draft && {\n    x: Math.min(draft.x0, draft.x1),\n    y: Math.min(draft.y0, draft.y1),\n    width: Math.abs(draft.x1 - draft.x0),\n    height: Math.abs(draft.y1 - draft.y0),\n  };\n  // marking an area on the shared drag: the rectangle follows the hand; Escape or a lost pointer takes it back (the\n  // next Escape leaves area mode); the drawn value lives in a ref, so a release reads the last move, not the last render\n  const drawing = useRef<Draft | null>(null);\n  const draw = (d: Draft | null) => {\n    drawing.current = d;\n    setDraft(d);\n  };\n  useDrag(\n    el,\n    {\n      onPress: (p, e) => {\n        e.preventDefault();\n        const q = at(p);\n        draw({ x0: q.x, y0: q.y, x1: q.x, y1: q.y });\n      },\n      onMove: (p) => {\n        const d = drawing.current;\n        if (!d) return;\n        const q = at(p);\n        draw({ ...d, x1: q.x, y1: q.y });\n      },\n      onEnd: () => {\n        const d = drawing.current;\n        draw(null);\n        if (!d) return;\n        const r = {\n          x: Math.min(d.x0, d.x1),\n          y: Math.min(d.y0, d.y1),\n          width: Math.abs(d.x1 - d.x0),\n          height: Math.abs(d.y1 - d.y0),\n        };\n        if (r.width > 0.02 && r.height > 0.01) onArea(n, r);\n      },\n      onTap: () => draw(null),\n      onCancel: () => draw(null),\n      onAbandon: () => draw(null),\n    },\n    { disabled: !area },\n  );\n  // a click on a mark (through the text above it) picks its annotation\n  const onClick = (e: ReactMouseEvent<HTMLDivElement>) => {\n    if (area || (e.target as HTMLElement).closest(\"button\")) return;\n    if (!window.getSelection()?.isCollapsed) return;\n    const b = el.current!.getBoundingClientRect();\n    const x = (e.clientX - b.left) / b.width;\n    const y = (e.clientY - b.top) / b.height;\n    const hit = marks.find((m) =>\n      m.rects?.some((r) => x >= r.x && x <= r.x + r.width && y >= r.y - 0.002 && y <= r.y + r.height + 0.002),\n    );\n    if (hit) onPick(hit.id);\n  };\n\n  // pins in the margin, nudged apart when two annotations sit at the same height\n  const pinH = 22 / (size.height * scale);\n  let floor = -1;\n  const pins = marks\n    .map((m) => ({ m, y: bounds(m.rects)?.y ?? null }))\n    .filter((p): p is { m: PageMark; y: number } => p.y !== null)\n    .sort((a, b) => a.y - b.y)\n    .map((p) => {\n      const y = Math.max(p.y, floor + pinH);\n      floor = y;\n      return { ...p, y };\n    });\n\n  return (\n    <div\n      ref={el}\n      data-slot=\"document-viewer-page\"\n      data-page={n}\n      data-drawn={drawn ? \"\" : undefined}\n      role=\"group\"\n      aria-label={i18n(\"pageOf\", { n: n, total: total })}\n      className={cn(styles.page, area && styles.areaMode, className)}\n      style={\n        {\n          width: size.width * scale,\n          height: size.height * scale,\n          \"--total-scale-factor\": scale * (96 / 72),\n        } as CSSProperties\n      }\n      onClick={onClick}\n    >\n      <div aria-hidden className={cn(styles.placeholder, \"unmeasured\")}>\n        <span>{n}</span>\n      </div>\n      {doc.kind === \"pdf\" ? (\n        <div ref={host} className={styles.canvas} />\n      ) : (\n        drawn && (\n          <img\n            className={styles.canvas}\n            src={doc.pages[n - 1].src}\n            alt={doc.pages[n - 1].alt ?? i18n(\"page\", { n: n })}\n            draggable={false}\n            onLoad={(e) => e.currentTarget.setAttribute(\"data-loaded\", \"\")}\n          />\n        )\n      )}\n      <div aria-hidden className={styles.marks}>\n        {hitRects.map((rects, i) =>\n          rects.map((r, j) => (\n            <span\n              key={`h${i}-${j}`}\n              className={styles.hit}\n              data-current={i === current ? \"\" : undefined}\n              style={place(r)}\n            />\n          )),\n        )}\n        {marks.map((m) =>\n          (m.rects ?? []).map((r, j) => (\n            <span\n              key={`${m.id}-${j}-${flash?.id === m.id ? flash.key : 0}`}\n              className={styles.mark}\n              data-kind={m.kind}\n              data-tone={m.tone}\n              data-resolved={m.resolved ? \"\" : undefined}\n              data-selected={selected === m.id ? \"\" : undefined}\n              data-flash={flash?.id === m.id ? \"\" : undefined}\n              data-fresh={fresh.has(m.id) ? \"\" : undefined}\n              style={{ ...place(r), \"--i\": j } as CSSProperties}\n            />\n          )),\n        )}\n        {draftRect && <span className={styles.draft} style={place(draftRect)} />}\n      </div>\n      {doc.kind === \"pdf\" && <div ref={layer} data-slot=\"document-viewer-documentText\" className={styles.textLayer} />}\n      {pins.map(({ m, y }) => (\n        <button\n          key={m.id}\n          type=\"button\"\n          data-slot=\"document-viewer-pin\"\n          className={cn(styles.pin, \"ix\")}\n          data-tone={m.tone}\n          data-selected={selected === m.id ? \"\" : undefined}\n          data-resolved={m.resolved ? \"\" : undefined}\n          style={{ top: pct(y) }}\n          aria-label={m.label}\n          aria-pressed={selected === m.id}\n          onClick={() => onPick(m.id)}\n        >\n          {m.resolved ? <Check aria-hidden className=\"size-3\" /> : m.number}\n        </button>\n      ))}\n      {children}\n    </div>\n  );\n}\n\ninterface ThumbProps {\n  n: number;\n  doc: OpenDocument;\n  width: number;\n  root: RefObject<HTMLDivElement | null>;\n  current: boolean;\n  hitCount: number;\n  markCount: number;\n  focusable: boolean;\n  onGo: (n: number) => void;\n}\n\n/** One page in the rail: drawn small when it scrolls into the rail's view, hatched until then. */\nexport function Thumb({ n, doc, width, root, current, hitCount, markCount, focusable, onGo }: ThumbProps) {\n  const i18n = useMessages(\"document-viewer\", englishMessages);\n\n  const el = useRef<HTMLButtonElement>(null);\n  const host = useRef<HTMLDivElement>(null);\n  const near = useNear(el, root, \"50% 0px\");\n  const size = doc.sizes[n - 1];\n  const scale = width / size.width;\n  const drawn = useDrawn(host, doc, n, scale, near, false);\n  const parts = [\n    i18n(\"page\", { n: n }),\n    markCount ? i18n(\"annotations4\", { count: markCount }) : \"\",\n    hitCount ? i18n(\"hits\", { count: hitCount }) : \"\",\n  ];\n  return (\n    <button\n      ref={el}\n      type=\"button\"\n      data-slot=\"document-viewer-thumb\"\n      data-page={n}\n      aria-current={current ? \"page\" : undefined}\n      tabIndex={focusable ? 0 : -1}\n      aria-label={parts.filter(Boolean).join(\", \")}\n      className={styles.thumb}\n      onClick={() => onGo(n)}\n    >\n      <span\n        className={styles.thumbPaper}\n        data-drawn={drawn ? \"\" : undefined}\n        style={{ width, height: size.height * scale }}\n      >\n        <span aria-hidden className={cn(styles.placeholder, \"unmeasured\")} />\n        {doc.kind === \"pdf\" ? (\n          <div ref={host} className={styles.canvas} />\n        ) : (\n          drawn && <img className={styles.canvas} src={doc.pages[n - 1].src} alt=\"\" draggable={false} />\n        )}\n        {(hitCount > 0 || markCount > 0) && (\n          <span aria-hidden className={styles.thumbMarks}>\n            {hitCount > 0 && (\n              <span className={styles.thumbHits}>\n                <Search className=\"size-2.5\" />\n                {hitCount}\n              </span>\n            )}\n            {markCount > 0 && <span className={styles.thumbNotes}>{markCount}</span>}\n          </span>\n        )}\n      </span>\n      <span aria-hidden className={styles.thumbNumber}>\n        {n}\n      </span>\n    </button>\n  );\n}\n"},{"path":"beamline/components/document-viewer/text.ts","type":"registry:file","target":"src/ui/beamline/components/document-viewer/text.ts","content":"// Text on a page: finding a query or a quote in it, and turning a stretch of it (or a selection) into rects that are\n// fractions of the page, so they hold at every zoom.\nimport type { PageText } from \"./engine\";\n\nexport interface DocumentRect {\n  /** Fractions of the page (0–1) from its top left. */\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\n/** A stretch of a page's text: [start, end) in PageText.text. */\nexport interface TextSpan {\n  start: number;\n  end: number;\n}\n\n/** The text with runs of space folded to one and case dropped, and where each folded character came from. */\nfunction fold(text: string) {\n  let out = \"\";\n  const from: number[] = [];\n  let space = false;\n  for (let i = 0; i < text.length; i++) {\n    const c = text[i];\n    if (/\\s/.test(c)) {\n      if (space || out === \"\") continue;\n      space = true;\n      out += \" \";\n    } else {\n      space = false;\n      out += c.toLowerCase();\n    }\n    from.push(i);\n  }\n  return { out, from };\n}\n\nexport const normalize = (q: string) => q.trim().replace(/\\s+/g, \" \").toLowerCase();\n\n/** Every place `query` occurs in the page's text, case and spacing ignored. */\nexport function findAll(text: string, query: string): TextSpan[] {\n  const q = normalize(query);\n  if (!q) return [];\n  const { out, from } = fold(text);\n  const spans: TextSpan[] = [];\n  for (let i = out.indexOf(q); i >= 0; i = out.indexOf(q, i + q.length))\n    spans.push({ start: from[i], end: from[i + q.length - 1] + 1 });\n  return spans;\n}\n\n/** Rects of a span of the page's text, read from the text layer the engine laid out (one span element per item). */\nexport function spanRects(span: TextSpan, text: PageText, divs: HTMLElement[], page: DOMRect): DocumentRect[] {\n  const range = document.createRange();\n  const rects: DOMRect[] = [];\n  for (let i = 0; i < text.items.length; i++) {\n    const from = text.starts[i];\n    const to = from + text.items[i].length;\n    if (to <= span.start || from >= span.end) continue;\n    const node = divs[i]?.firstChild;\n    if (!node) continue;\n    range.setStart(node, Math.max(span.start, from) - from);\n    range.setEnd(node, Math.min(span.end, to) - from);\n    rects.push(...range.getClientRects());\n  }\n  return toFractions(rects, page);\n}\n\n/** Client rects → page fractions, one rect per line (pieces on the same line are joined). */\nexport function toFractions(rects: Iterable<DOMRect>, page: DOMRect): DocumentRect[] {\n  const lines: { top: number; bottom: number; left: number; right: number }[] = [];\n  for (const r of rects) {\n    if (r.width < 0.5 || r.height < 0.5) continue;\n    const line = lines.find(\n      (l) => Math.abs(l.top - r.top) < r.height * 0.5 && Math.abs(l.bottom - r.bottom) < r.height * 0.5,\n    );\n    if (line) {\n      line.left = Math.min(line.left, r.left);\n      line.right = Math.max(line.right, r.right);\n      line.top = Math.min(line.top, r.top);\n      line.bottom = Math.max(line.bottom, r.bottom);\n    } else lines.push({ top: r.top, bottom: r.bottom, left: r.left, right: r.right });\n  }\n  return lines.map((l) => ({\n    x: (l.left - page.left) / page.width,\n    y: (l.top - page.top) / page.height,\n    width: (l.right - l.left) / page.width,\n    height: (l.bottom - l.top) / page.height,\n  }));\n}\n\n/** The union box of a set of rects, or null. */\nexport function bounds(rects: DocumentRect[] | undefined): DocumentRect | null {\n  if (!rects?.length) return null;\n  const x = Math.min(...rects.map((r) => r.x));\n  const y = Math.min(...rects.map((r) => r.y));\n  const right = Math.max(...rects.map((r) => r.x + r.width));\n  const bottom = Math.max(...rects.map((r) => r.y + r.height));\n  return { x, y, width: right - x, height: bottom - y };\n}\n"},{"path":"beamline/components/dropdown-menu/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/dropdown-menu/defaults.ts","content":"/** Pure allowed presentation defaults for DropdownMenu; no component or runtime imports. */\nexport interface DropdownMenuDefaults {\n  /** Shared variant setting; omit to inherit the surrounding scope or the component default. */\n  variant?: \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"danger\" | \"accent\";\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\" | \"touch\";\n  /** Shared align setting; omit to inherit the surrounding scope or the component default. */\n  align?: \"start\" | \"center\" | \"end\";\n}\n"},{"path":"beamline/components/dropdown-menu/dropdown-menu.tsx","type":"registry:file","target":"src/ui/beamline/components/dropdown-menu/dropdown-menu.tsx","content":"\"use client\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { DropdownMenuDefaults } from \"./defaults\";\nexport type { DropdownMenuDefaults } from \"./defaults\";\n\nimport type { CSSProperties, Ref, ReactElement, ReactNode } from \"react\";\nimport * as RadixMenu from \"@radix-ui/react-dropdown-menu\";\nimport { Button, type ButtonSize, type ButtonVariant } from \"../button/button\";\nimport {\n  MenuItems,\n  MenuSurface,\n  useMenuDirection,\n  type MenuItem,\n  type MenuClassNames,\n} from \"../_shared/menu\";\nimport { cn } from \"../../lib/utils\";\nimport { usePortalContainer } from \"../../runtime/appearance\";\n\nexport type { MenuItem, MenuClassNames };\n\nexport interface DropdownMenuProps {\n  /** The menu's name: the trigger's text, or its accessible name when the trigger is an icon. */\n  label: string;\n  /** Items rendered in the component. Required. */\n  items: MenuItem[];\n  /** An icon for the built-in trigger (MoreHorizontal for row actions); with it the trigger shows only the icon, and\n   *  label becomes its accessible name, unless showLabel is set. */\n  icon?: ReactNode;\n  /** Shows the label beside the icon. Defaults to false. */\n  showLabel?: boolean;\n  /** Your own trigger (a Button, an avatar); it receives the open state and keyboard handling. */\n  trigger?: ReactElement;\n  /** Visual weight of the control. Defaults to \"ghost\". */\n  variant?: ButtonVariant;\n  /** Control size within the supported size steps. Defaults to \"sm\". */\n  size?: ButtonSize;\n  /** Alignment within the available space. Defaults to \"end\". */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Edge from which the panel or anchored content opens. Defaults to \"bottom\". */\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  /** Controlled open state; omit to use defaultOpen. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Defaults to false. */\n  defaultOpen?: boolean;\n  /** Reports open state after a change; no callback by default. */\n  onOpenChange?: (open: boolean) => void;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: MenuClassNames;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n  /** Reading direction; inherits the surrounding direction when omitted. Defaults to the surrounding reading direction. */\n  dir?: \"ltr\" | \"rtl\";\n}\n\n/**\n * A list of actions behind a trigger: row actions, \"More\", an account menu. One highlight plate glides between rows\n * under the pointer and jumps with the keyboard; type to jump to an item; → opens a submenu.\n */\nexport function DropdownMenu({\n  label,\n  items,\n  icon,\n  showLabel = false,\n  trigger,\n  variant: variantProp,\n  size: sizeProp,\n  align: alignProp,\n  side = \"bottom\",\n  open,\n  defaultOpen,\n  onOpenChange,\n  className,\n  classNames,\n  style,\n  ref,\n  dir: direction,\n}: DropdownMenuProps) {\n  const scopeDefaults = useComponentDefaults<DropdownMenuDefaults>(\"dropdown-menu\", [\"variant\", \"size\", \"align\"]);\n  const variant = variantProp ?? scopeDefaults.variant ?? \"ghost\";\n  const size = sizeProp ?? scopeDefaults.size ?? \"sm\";\n  const align = alignProp ?? scopeDefaults.align ?? \"end\";\n\n  const container = usePortalContainer();\n  const iconOnly = !!icon && !showLabel;\n  const { dir, triggerProps } = useMenuDirection(direction);\n  return (\n    <RadixMenu.Root dir={dir} open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>\n      <RadixMenu.Trigger asChild data-slot=\"dropdown-menu-trigger\" className={classNames?.trigger} {...triggerProps}>\n        {trigger ?? (\n          <Button\n            data-slot=\"dropdown-menu-trigger\"\n            variant={variant}\n            size={size}\n            aria-label={iconOnly ? label : undefined}\n          >\n            {icon}\n            {!iconOnly && label}\n          </Button>\n        )}\n      </RadixMenu.Trigger>\n      {container !== null && (\n        <RadixMenu.Portal container={container}>\n          <MenuSurface\n            kit={RadixMenu}\n            label={label}\n            align={align}\n            side={side}\n            sideOffset={6}\n            className={cn(classNames?.root, className)}\n            classNames={classNames}\n            style={style}\n            ref={ref}\n          >\n            <MenuItems kit={RadixMenu} items={items} container={container} classNames={classNames} />\n          </MenuSurface>\n        </RadixMenu.Portal>\n      )}\n    </RadixMenu.Root>\n  );\n}\n\nexport default DropdownMenu;\n"},{"path":"beamline/components/empty-state/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/empty-state/defaults.ts","content":"/** Pure allowed presentation defaults for EmptyState; no component or runtime imports. */\nexport interface EmptyStateDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\";\n}\n"},{"path":"beamline/components/empty-state/empty-state.module.css","type":"registry:file","target":"src/ui/beamline/components/empty-state/empty-state.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* An empty state answers to its room (data-room, read from its own width once per change: it is often centred in a\n     panel, where a size container would have no width of its own). Its air, icon ring and actions step with that room;\n     a caller's own padding or width classes still win (they sit in the utilities layer above this one). It arrives with\n     a short fade, as it usually replaces a list that just emptied. */\n  .root {\n    display: grid;\n    justify-items: center;\n    text-align: center;\n    animation: arrive var(--pui-fade-in) var(--pui-ease-default) both;\n  }\n  @keyframes arrive {\n    from {\n      opacity: 0;\n    }\n  }\n  .root[data-size=\"md\"] {\n    gap: var(--pui-space-3);\n    padding: var(--pui-space-10) var(--pui-space-6);\n  }\n  .root[data-size=\"sm\"] {\n    gap: var(--pui-space-2);\n    padding: var(--pui-space-6) var(--pui-space-4);\n  }\n  /* a big room (a whole page or panel body) gets more air above and below */\n  .root[data-size=\"md\"][data-room=\"wide\"] {\n    padding-block: var(--pui-space-16);\n  }\n  /* a narrow room (a sidebar, a phone column, a small tile): less air, the dashed ring folds away, and the actions\n     stack full width so each is an easy target */\n  .root[data-room=\"narrow\"] {\n    gap: var(--pui-space-2);\n    padding: var(--pui-space-5) var(--pui-space-3);\n  }\n  .root[data-room=\"narrow\"] .ring {\n    display: none;\n  }\n  .root[data-room=\"narrow\"] > .actions {\n    flex-direction: column;\n    justify-self: stretch;\n  }\n  .root[data-room=\"narrow\"] > .actions > * {\n    width: 100%;\n  }\n\n  /* layout row: a strip for a list body or a table section — icon, words and actions in one line from 32rem, the\n     stack below it */\n  .root[data-row] {\n    grid-template-columns: auto minmax(0, 1fr) auto;\n    align-items: center;\n    justify-items: start;\n    column-gap: var(--pui-space-4);\n    text-align: start;\n  }\n  .root[data-row] > .icon {\n    margin: 0 var(--pui-space-2);\n  }\n  .root[data-row] > .actions {\n    margin-top: 0;\n    justify-content: flex-end;\n  }\n\n  .icon {\n    position: relative;\n    display: grid;\n    place-items: center;\n    border-radius: var(--pui-radius-panel);\n    color: var(--pui-ink-2);\n  }\n  .root[data-size=\"md\"] > .icon {\n    width: var(--pui-space-12);\n    height: var(--pui-space-12);\n    margin-bottom: var(--pui-space-1);\n  }\n  .root[data-size=\"sm\"] > .icon {\n    width: calc(var(--pui-space-1) * 9);\n    height: calc(var(--pui-space-1) * 9);\n  }\n  .icon > svg {\n    width: var(--pui-icon-lg);\n    height: var(--pui-icon-lg);\n  }\n  .root[data-size=\"sm\"] > .icon > svg {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n  }\n  /* the dashed ring around the icon says \"a place waiting to be filled\"; it firms up while the next step is pointed at\n     or focused, tying the picture to the action */\n  .ring {\n    position: absolute;\n    inset: calc(var(--pui-space-2) * -1);\n    border: 1px dashed var(--pui-line);\n    border-radius: var(--pui-radius-surface);\n    transition: border-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .root:has(> .actions :is(:hover, :focus-visible)) .ring {\n    border-color: var(--pui-line-strong);\n    transition-duration: 0ms;\n  }\n  .body {\n    display: grid;\n    gap: var(--pui-space-1);\n    max-width: 44ch;\n  }\n  .actions {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    justify-content: center;\n    gap: var(--pui-space-2);\n    margin-top: var(--pui-space-1);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.icon {\n  composes: m-control from global;\n}\n"},{"path":"beamline/components/empty-state/empty-state.tsx","type":"registry:file","target":"src/ui/beamline/components/empty-state/empty-state.tsx","content":"\"use client\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { EmptyStateDefaults } from \"./defaults\";\nexport type { EmptyStateDefaults } from \"./defaults\";\n\nimport { useRef, type CSSProperties, type Ref, type ReactNode } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useWidthAs } from \"../../hooks/use-content-width\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./empty-state.module.css\";\n\nexport interface EmptyStateClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the icon part; no override by default. */\n  icon?: string;\n  /** Classes for the ring part; no override by default. */\n  ring?: string;\n  /** Classes for the body part; no override by default. */\n  body?: string;\n  /** Classes for the title part; no override by default. */\n  title?: string;\n  /** Classes for the description part; no override by default. */\n  description?: string;\n  /** Classes for the actions part; no override by default. */\n  actions?: string;\n}\n\nexport interface EmptyStateProps {\n  /** Say what will be here: \"No reports yet\". */\n  title: ReactNode;\n  /** Why it is empty and what happens next. */\n  description?: ReactNode;\n  /** The next step: create, import, invite. */\n  action?: ReactNode;\n  /** A quieter second step: docs, a sample. */\n  secondary?: ReactNode;\n  /** Icon element shown beside the label. Defaults to no leading icon. */\n  icon?: ReactNode;\n  /** compact for panels and table bodies. */\n  size?: \"md\" | \"sm\";\n  /** stack (default): centred. row: a strip for a list body or table section — icon, words and actions in one line\n   *  from 32rem, the stack below. */\n  layout?: \"stack\" | \"row\";\n  /** The region's name, when the title is not text. */\n  label?: string;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: EmptyStateClassNames;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLElement>;\n}\n\n/** What a place will hold and the first step to fill it, instead of a blank. For filters with no results, say which\n *  filter to loosen. */\nexport function EmptyState({\n  title,\n  description,\n  action,\n  secondary,\n  icon,\n  size: sizeProp,\n  layout = \"stack\",\n  label,\n  className,\n  classNames,\n  style,\n  ref,\n}: EmptyStateProps) {\n  const scopeDefaults = useComponentDefaults<EmptyStateDefaults>(\"empty-state\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n\n  const host = useRef<HTMLElement>(null);\n  const attach = useComposedRef(host, ref);\n  // the room, read once per change of step: narrow below 16rem, wide from 42rem, a row (for layout row) from 32rem of\n  // content at the default padding. Read on the outer width: the padding steps with the room, so the content width\n  // would flip it back and forth every frame when it sits near a step.\n  const pad = size === \"sm\" ? 32 : 48;\n  const room = useWidthAs(\n    host,\n    (w) => {\n      const s = host.current ? getComputedStyle(host.current) : null;\n      const outer = w + (s ? parseFloat(s.paddingLeft) + parseFloat(s.paddingRight) : pad) - pad;\n      return outer < 256 ? \"narrow\" : outer >= 672 ? \"wide\" : outer >= 512 ? \"row\" : \"default\";\n    },\n    \"default\",\n  );\n\n  return (\n    <section\n      ref={attach}\n      style={style}\n      data-slot=\"empty-state\"\n      data-size={size}\n      data-room={room === \"row\" ? \"default\" : room}\n      data-row={layout === \"row\" && (room === \"row\" || room === \"wide\") ? \"\" : undefined}\n      aria-label={label ?? (typeof title === \"string\" ? title : undefined)}\n      className={cn(styles.root, classNames?.root, className)}\n    >\n      {icon && (\n        <span data-slot=\"empty-state-icon\" aria-hidden className={cn(styles.icon, classNames?.icon)}>\n          <span data-slot=\"empty-state-ring\" className={cn(styles.ring, classNames?.ring)} />\n          {icon}\n        </span>\n      )}\n      <div data-slot=\"empty-state-body\" className={cn(styles.body, classNames?.body)}>\n        <h3\n          data-slot=\"empty-state-title\"\n          className={cn(\"text-ink\", size === \"md\" ? \"text-subheading\" : \"text-label\", classNames?.title)}\n        >\n          {title}\n        </h3>\n        {description && (\n          <p\n            data-slot=\"empty-state-description\"\n            className={cn(\"leading-relaxed text-ink-3\", size === \"md\" ? \"text-sm\" : \"text-xs\", classNames?.description)}\n          >\n            {description}\n          </p>\n        )}\n      </div>\n      {(action || secondary) && (\n        <div data-slot=\"empty-state-actions\" className={cn(styles.actions, classNames?.actions)}>\n          {action}\n          {secondary}\n        </div>\n      )}\n    </section>\n  );\n}\n\nexport default EmptyState;\n"},{"path":"beamline/components/glow/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/glow/defaults.ts","content":"/** Pure allowed presentation defaults for Glow; no component or runtime imports. */\nexport interface GlowDefaults {\n  /** Shared effect setting; omit to inherit the component default. Scoped defaults retain the legacy key variant. */\n  variant?: \"aurora\" | \"ring\";\n}\n"},{"path":"beamline/components/glow/glow.module.css","type":"registry:file","target":"src/ui/beamline/components/glow/glow.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Light that says \"working\". Aurora: colour pools under and behind the element and drifts. Ring: a band of colour\n   travels around the edge. Both are blurred layers that only translate or rotate (compositor work), fade in and out\n   with `active`, stop entirely when inactive, and hold still off screen. Colours come from --pui-glow-1/2/3; every\n   period is a multiple of --pui-duration-considered, so the scope's animation speed reaches the light too. */\n  .root {\n    --pui-r: var(--pui-radius-card);\n    --pui-i: 1;\n    position: relative;\n    isolation: isolate;\n    border-radius: var(--pui-r);\n  }\n  .root > .field {\n    position: absolute;\n    z-index: -1;\n    pointer-events: none;\n    opacity: 0;\n    transition: opacity calc(var(--pui-duration-considered) * 2) var(--pui-ease-standard);\n  }\n  .root[data-active] > .field {\n    opacity: calc(0.9 * var(--pui-i));\n  }\n\n  /* aurora: three soft blobs along the lower edge, each drifting on its own period */\n  .aurora {\n    inset: 30% -6% -34%;\n    filter: blur(22px);\n  }\n  .blob {\n    position: absolute;\n    bottom: 0;\n    width: 46%;\n    height: 70%;\n    border-radius: 50%;\n    will-change: transform;\n  }\n  .blob:nth-child(1) {\n    left: 2%;\n    background: var(--pui-glow-1);\n    animation: glow-drift-a calc(var(--pui-duration-considered) * 19.5) var(--pui-ease-in-out) infinite alternate;\n  }\n  .blob:nth-child(2) {\n    left: 30%;\n    background: var(--pui-glow-2);\n    animation: glow-drift-b calc(var(--pui-duration-considered) * 25) var(--pui-ease-in-out) infinite alternate;\n  }\n  .blob:nth-child(3) {\n    right: 2%;\n    background: var(--pui-glow-3);\n    animation: glow-drift-c calc(var(--pui-duration-considered) * 22) var(--pui-ease-in-out) infinite alternate;\n  }\n\n  /* ring: a conic band (a square as wide as the element's diagonal, set from its size) rotating inside a mask that leaves only a thin frame plus its soft bloom */\n  .ring {\n    inset: -1px;\n    border-radius: calc(var(--pui-r) + 1px);\n    padding: 1.5px;\n    overflow: hidden;\n    -webkit-mask:\n      linear-gradient(#000 0 0) content-box exclude,\n      linear-gradient(#000 0 0);\n    mask:\n      linear-gradient(#000 0 0) content-box exclude,\n      linear-gradient(#000 0 0);\n  }\n  .bloom {\n    inset: -10px;\n    border-radius: calc(var(--pui-r) + 10px);\n    overflow: hidden;\n    filter: blur(12px);\n  }\n  .band {\n    position: absolute;\n    left: 50%;\n    top: 50%;\n    width: var(--pui-d, 150%);\n    aspect-ratio: 1;\n    translate: -50% -50%;\n    will-change: transform;\n    background: conic-gradient(\n      from 0deg,\n      transparent 0 58%,\n      var(--pui-glow-1) 70%,\n      var(--pui-glow-2) 80%,\n      var(--pui-glow-3) 88%,\n      transparent 96%\n    );\n    animation: glow-turn calc(var(--pui-duration-considered) * 10) linear infinite;\n  }\n  .bloom .band {\n    opacity: 0.55;\n  }\n\n  .root:not([data-active]) .blob,\n  .root:not([data-active]) .band {\n    animation-play-state: paused;\n  }\n  .root[data-paused] .blob,\n  .root[data-paused] .band {\n    animation-play-state: paused;\n  }\n  .root[data-reduced] .blob,\n  .root[data-reduced] .band {\n    animation: none;\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .blob,\n    .band {\n      animation: none !important;\n    }\n  }\n}\n@keyframes glow-drift-a {\n  to {\n    transform: translate(18%, -12%) scale(1.15);\n  }\n}\n@keyframes glow-drift-b {\n  to {\n    transform: translate(-14%, 8%) scale(0.9);\n  }\n}\n@keyframes glow-drift-c {\n  to {\n    transform: translate(-20%, -10%) scale(1.1);\n  }\n}\n@keyframes glow-turn {\n  to {\n    transform: rotate(1turn);\n  }\n}\n"},{"path":"beamline/components/glow/glow.tsx","type":"registry:file","target":"src/ui/beamline/components/glow/glow.tsx","content":"\"use client\";\n\nimport { useDeprecatedProp } from \"../../hooks/use-deprecated-prop\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { GlowDefaults } from \"./defaults\";\nexport type { GlowDefaults } from \"./defaults\";\n\nimport { useEffect, useRef, type CSSProperties, type ReactNode, type Ref } from \"react\";\nimport { useActive } from \"../../hooks/use-active\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./glow.module.css\";\n\nexport interface GlowClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the field part; no override by default. */\n  field?: string;\n  /** Classes for the blob part; no override by default. */\n  blob?: string;\n  /** Classes for the bloom part; no override by default. */\n  bloom?: string;\n  /** Classes for the ring part; no override by default. */\n  ring?: string;\n  /** Classes for the band part; no override by default. */\n  band?: string;\n}\n\nexport interface GlowProps {\n  /** Content rendered inside the component. Defaults to no children. */\n  children?: ReactNode;\n  /** While true the glow fades in and moves; false fades it out and stops all motion. */\n  active?: boolean;\n  /** Aurora light behind the content or a ring around it; defaults to aurora. */\n  effect?: \"aurora\" | \"ring\";\n  /** Legacy effect setting; omitted by default. @deprecated Use effect instead. */\n  variant?: \"aurora\" | \"ring\";\n  /** 0–1, how strong the light is. */\n  intensity?: number;\n  /** Corner radius of the wrapped element, so the ring follows it. Defaults to the card radius. */\n  radius?: string;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: GlowClassNames;\n}\n\n/**\n * Ambient light around one element that says \"something is happening here\": the composer while an agent works, the\n * live card, the call in progress. One running per screen (bind `active` to a state that ends). Compositor-only motion; nothing runs while inactive or off screen.\n */\nexport function Glow({\n  children,\n  active = true,\n  effect: effectProp,\n  variant: variantProp,\n  intensity = 1,\n  radius,\n  className,\n  classNames,\n  style,\n  ref,\n}: GlowProps) {\n  const scopeDefaults = useComponentDefaults<GlowDefaults>(\"glow\", [\"variant\"]);\n  useDeprecatedProp(\n    \"glow\",\n    \"variant\",\n    \"effect\",\n    variantProp !== undefined || (effectProp === undefined && scopeDefaults.variant !== undefined),\n  );\n  const variant = effectProp ?? variantProp ?? scopeDefaults.variant ?? \"aurora\";\n\n  const own = useRef<HTMLDivElement>(null);\n  const attach = useComposedRef(own, ref);\n  const tokens = useMotionTokens(own);\n  const managedReduced = useReducedMotionSafe();\n  const onScreen = useActive(own);\n  // The ring's rotating band must cover the element's diagonal and no more (a larger layer only costs GPU memory).\n  useEffect(() => {\n    const el = own.current;\n    if (!el || variant !== \"ring\" || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([e]) => {\n      const { width, height } = e.contentRect;\n      el.style.setProperty(\"--pui-d\", `${Math.ceil(Math.hypot(width, height) + 24)}px`);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [variant]);\n  return (\n    <div\n      ref={attach}\n      data-slot=\"glow\"\n      data-variant={variant}\n      data-active={active || undefined}\n      data-paused={onScreen ? undefined : \"\"}\n      data-reduced={tokens.reduced || managedReduced ? \"\" : undefined}\n      className={cn(styles.root, classNames?.root, className)}\n      style={{ ...(radius ? { \"--pui-r\": radius } : {}), \"--pui-i\": intensity, ...style } as CSSProperties}\n    >\n      {variant === \"aurora\" ? (\n        <span data-slot=\"glow-field\" aria-hidden=\"true\" className={cn(styles.field, styles.aurora, classNames?.field)}>\n          <span data-slot=\"glow-blob\" className={cn(styles.blob, classNames?.blob)} />\n          <span data-slot=\"glow-blob\" className={cn(styles.blob, classNames?.blob)} />\n          <span data-slot=\"glow-blob\" className={cn(styles.blob, classNames?.blob)} />\n        </span>\n      ) : (\n        <>\n          <span\n            data-slot=\"glow-bloom\"\n            aria-hidden=\"true\"\n            className={cn(styles.field, styles.bloom, classNames?.field, classNames?.bloom)}\n          >\n            <span data-slot=\"glow-band\" className={cn(styles.band, classNames?.band)} />\n          </span>\n          <span\n            data-slot=\"glow-ring\"\n            aria-hidden=\"true\"\n            className={cn(styles.field, styles.ring, classNames?.field, classNames?.ring)}\n          >\n            <span data-slot=\"glow-band\" className={cn(styles.band, classNames?.band)} />\n          </span>\n        </>\n      )}\n      {children}\n    </div>\n  );\n}\n\nexport default Glow;\n"},{"path":"beamline/components/kbd/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/kbd/defaults.ts","content":"/** Pure allowed presentation defaults for Kbd; no component or runtime imports. */\nexport interface KbdDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/kbd/kbd.module.css","type":"registry:file","target":"src/ui/beamline/components/kbd/kbd.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Keycaps: each key is a small raised control (the control role), so it reads as a key in every look. */\n  .keys {\n    display: inline-flex;\n    align-items: center;\n    gap: var(--pui-space-0-5);\n    vertical-align: middle;\n    /* a key combination is a fixed sequence: it reads Ctrl K in any page direction, isolated as one unit while the\n       page keeps its own direction around it */\n    direction: ltr;\n    unicode-bidi: isolate;\n  }\n  .cap {\n    display: inline-flex;\n    height: 22px;\n    min-width: 22px;\n    align-items: center;\n    justify-content: center;\n    padding-inline: var(--pui-space-1-5);\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-kbd-radius wins over both. */\n    --_kbd-nest: clamp(\n      calc(var(--pui-radius-chip) / 2),\n      calc(var(--pui-nest-radius) - var(--pui-nest-inset, 0px)),\n      var(--pui-nest-ceiling, var(--pui-radius-chip))\n    );\n    border-radius: var(--pui-kbd-radius, var(--_kbd-nest, calc(var(--pui-radius-chip) - 1px)));\n    color: var(--pui-ink-2);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-medium);\n    transition:\n      translate var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      var(--m-transition);\n  }\n  /* live: a held key presses its cap down at once (the look's own press light) and lets it up over the leave\n     duration; with every key of the combination held, the caps ink in the accent and take an accent ring */\n  .cap[data-pressed] {\n    --pui-m-press: 1;\n    translate: 0 1px;\n    color: var(--pui-ink);\n    transition-duration: 0ms;\n  }\n  .keys[data-complete] .cap {\n    color: var(--pui-accent-ink);\n    box-shadow:\n      var(--m-control-edge),\n      0 0 0 1px var(--pui-accent);\n  }\n  /* a finished step of a sequence keeps its caps inked while the next is awaited */\n  .cap[data-done] {\n    color: var(--pui-accent-ink);\n  }\n  /* \"then\" between the steps of a sequence: a word, seen and spoken, so G then A never reads as G and A held together.\n     While the next step is awaited a line under it runs out over the time it has (SEQUENCE_MS in kbd.tsx): a timer,\n     so reduced motion keeps it, as HoldToConfirm keeps its fill. */\n  .then {\n    position: relative;\n    padding-inline: var(--pui-space-0-5);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-normal);\n    transition: color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .then[data-waiting] {\n    color: var(--pui-ink-2);\n    transition-duration: 0ms;\n  }\n  .then[data-waiting]::after {\n    content: \"\";\n    position: absolute;\n    inset-inline: var(--pui-space-0-5);\n    bottom: -2px;\n    height: 1px;\n    background: var(--pui-accent);\n    transform-origin: left;\n    /* the same wait the sequence listener keeps (SEQUENCE_MS), passed in so the line and the timeout never drift */\n    animation: kbd-wait var(--_wait) linear forwards;\n  }\n  @keyframes kbd-wait {\n    from {\n      scale: 1 1;\n    }\n    to {\n      scale: 0 1;\n    }\n  }\n  .sm {\n    height: 18px;\n    min-width: 18px;\n    padding-inline: var(--pui-space-1);\n    font-size: var(--pui-text-2xs);\n  }\n  .lg {\n    height: 28px;\n    min-width: 28px;\n    padding-inline: var(--pui-space-2);\n    font-size: var(--pui-text-sm);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.cap {\n  composes: m-control from global;\n}\n"},{"path":"beamline/components/kbd/kbd.tsx","type":"registry:file","target":"src/ui/beamline/components/kbd/kbd.tsx","content":"\"use client\";\n\nimport { useMessages, type Translator } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { KbdDefaults } from \"./defaults\";\nexport type { KbdDefaults } from \"./defaults\";\n\nimport { Fragment, useEffect, useReducer } from \"react\";\nimport type { CSSProperties, HTMLAttributes, ReactNode, Ref } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { useApplePlatform } from \"../../hooks/use-apple-platform\";\nimport styles from \"./kbd.module.css\";\n\nconst MAP_MAC = (i18n: Translator<typeof englishMessages>): Record<string, string> => ({\n  mod: \"⌘\",\n  cmd: \"⌘\",\n  ctrl: \"⌃\",\n  alt: \"⌥\",\n  option: \"⌥\",\n  shift: \"⇧\",\n  enter: \"↵\",\n  backspace: \"⌫\",\n  esc: i18n(\"escapeShort\"),\n  tab: \"⇥\",\n  up: \"↑\",\n  down: \"↓\",\n  left: \"←\",\n  right: \"→\",\n  space: i18n(\"space\"),\n});\nconst MAP_PC = (i18n: Translator<typeof englishMessages>): Record<string, string> => ({\n  mod: i18n(\"controlShort\"),\n  cmd: i18n(\"controlShort\"),\n  ctrl: i18n(\"controlShort\"),\n  alt: i18n(\"alt\"),\n  option: i18n(\"alt\"),\n  shift: i18n(\"shift\"),\n  enter: i18n(\"enter\"),\n  backspace: i18n(\"backspace\"),\n  esc: i18n(\"escapeShort\"),\n  tab: i18n(\"tab\"),\n  up: \"↑\",\n  down: \"↓\",\n  left: \"←\",\n  right: \"→\",\n  space: i18n(\"space\"),\n});\nconst SPOKEN = (i18n: Translator<typeof englishMessages>): Record<string, string> => ({\n  \"⌘\": i18n(\"command\"),\n  \"⌃\": i18n(\"control\"),\n  \"⌥\": i18n(\"option\"),\n  \"⇧\": i18n(\"shift\"),\n  \"↵\": i18n(\"enter\"),\n  \"⌫\": i18n(\"backspace\"),\n  \"⇥\": i18n(\"tab\"),\n  \"↑\": i18n(\"up\"),\n  \"↓\": i18n(\"down\"),\n  \"←\": i18n(\"left\"),\n  \"→\": i18n(\"right\"),\n});\n\n/** The KeyboardEvent.key a cap stands for, compared in lower case. */\nconst EVENT_KEY: Record<string, string> = {\n  ctrl: \"control\",\n  alt: \"alt\",\n  option: \"alt\",\n  shift: \"shift\",\n  enter: \"enter\",\n  backspace: \"backspace\",\n  esc: \"escape\",\n  tab: \"tab\",\n  up: \"arrowup\",\n  down: \"arrowdown\",\n  left: \"arrowleft\",\n  right: \"arrowright\",\n  space: \" \",\n};\nconst eventKey = (k: string, mac: boolean) => {\n  const name = k.toLowerCase();\n  if (name === \"mod\" || name === \"cmd\") return mac ? \"meta\" : \"control\";\n  return EVENT_KEY[name] ?? name;\n};\nconst NONE: ReadonlySet<string> = new Set();\n/** The key between the steps of a sequence: [\"g\", \"then\", \"a\"] is G, then A. */\nconst THEN = \"then\";\n/** How long a sequence waits for its next step, as apps that bind them do. */\nconst SEQUENCE_MS = 1500;\n\n/** live: the keys of the shortcut held now, the step being waited for, whether the whole shortcut is done, and how\n *  many steps have been done (a step done again, G pressed twice, gives the next one its full time again). */\ninterface Live {\n  held: ReadonlySet<string>;\n  step: number;\n  complete: boolean;\n  done: number;\n}\ntype LiveAction =\n  { type: \"down\"; key: string; steps: string[][] } | { type: \"up\"; key: string; steps: string[][] } | { type: \"reset\" };\nconst REST: Live = { held: NONE, step: 0, complete: false, done: 0 };\n\n/** A step is done by the press that completes it: every key of the step held, the last one just pressed. */\nconst advance = (live: Live, key: string, steps: string[][]): Live => {\n  const want = steps[live.step];\n  if (!want.includes(key) || !want.every((k) => live.held.has(k))) return live;\n  return live.step === steps.length - 1\n    ? { ...live, complete: true }\n    : { ...live, step: live.step + 1, done: live.done + 1 };\n};\nfunction track(live: Live, action: LiveAction): Live {\n  if (action.type === \"reset\") return live === REST ? live : REST;\n  const { key, steps } = action;\n  if (action.type === \"down\") {\n    // a key outside the shortcut breaks a sequence half-way; a combination simply waits for its own keys\n    if (!steps.some((s) => s.includes(key))) return live.step || live.complete ? { ...REST, held: live.held } : live;\n    const held = live.held.has(key) ? live.held : new Set(live.held).add(key);\n    const next = advance({ ...live, held }, key, steps);\n    if (next.step === live.step && !next.complete && live.step > 0 && !steps[live.step].includes(key))\n      // a wrong key mid-sequence starts it over, and may itself begin it\n      return advance({ ...live, held, step: 0, complete: false }, key, steps);\n    return next;\n  }\n  // with ⌘ held a Mac sends no keyup for the other keys: letting go of ⌘ lets go of all of them\n  let held = live.held;\n  if (key === \"meta\") held = NONE;\n  else if (held.has(key)) {\n    const next = new Set(held);\n    next.delete(key);\n    held = next;\n  }\n  // letting go of the last step ends the shortcut; an earlier step stays done while the next is awaited\n  if (live.complete && steps[live.step].some((k) => !held.has(k))) return { ...REST, held };\n  return held === live.held ? live : { ...live, held };\n}\n\n/** Typing a letter into a field is typing, not a shortcut. */\nconst typing = (e: KeyboardEvent) => {\n  const t = e.target as HTMLElement | null;\n  return (\n    e.key.length === 1 &&\n    !e.metaKey &&\n    !e.ctrlKey &&\n    !e.altKey &&\n    !!t &&\n    (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))\n  );\n};\n\nexport interface KbdClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the cap part; no override by default. */\n  cap?: string;\n  /** Classes for the symbol part; no override by default. */\n  symbol?: string;\n  /** Classes for the spoken part; no override by default. */\n  spoken?: string;\n  /** The word between the steps of a sequence. */\n  then?: string;\n}\n\nexport interface KbdProps extends HTMLAttributes<HTMLElement> {\n  /** Keys in order; held together they make a combination: `[\"mod\", \"K\"]` renders ⌘K on Mac and Ctrl K elsewhere.\n   *  `\"then\"` between keys makes a sequence: `[\"g\", \"then\", \"a\"]` is G, then A. */\n  keys?: string[];\n  /** The caps answer the keyboard: each presses down while the viewer holds its key, and the combination lights when\n   *  all are held; in a sequence each finished step stays lit while the next is awaited (1.5 s). For teaching a\n   *  shortcut where it is shown; it listens only while mounted with `live`. */\n  live?: boolean;\n  /** Platform used to name modifier keys; defaults to the viewer's detected platform. */\n  platform?: \"mac\" | \"other\";\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLElement>;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: KbdClassNames;\n}\n\n/**\n * Keyboard keys as keycaps, for combinations (⌘K) and sequences (G then A). `mod` resolves to ⌘ or Ctrl for the\n * viewer's platform; symbols are spoken as words for screen readers. Pass `children` for a single literal key.\n */\nexport function Kbd({\n  keys,\n  live = false,\n  platform,\n  size: sizeProp,\n  className,\n  classNames,\n  children,\n  ref,\n  ...props\n}: KbdProps) {\n  const i18n = useMessages(\"kbd\", englishMessages);\n\n  const scopeDefaults = useComponentDefaults<KbdDefaults>(\"kbd\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n\n  const detectedMac = useApplePlatform();\n  const mac = platform === undefined ? detectedMac : platform === \"mac\";\n  const map = mac ? MAP_MAC(i18n) : MAP_PC(i18n);\n  const spoken = SPOKEN(i18n);\n  // the steps: one for a combination, one per \"then\" for a sequence (a stray \"then\" at either end is no step)\n  const parts: string[][] = [[]];\n  for (const k of keys ?? []) {\n    if (k.toLowerCase() === THEN) parts.push([]);\n    else parts[parts.length - 1].push(k);\n  }\n  const steps = parts.filter((s) => s.length > 0);\n  // live: which of the caps' keys the viewer holds now, and how far through the sequence they are\n  const [state, dispatch] = useReducer(track, REST);\n  const wanted = live && keys ? steps.map((s) => s.map((k) => eventKey(k, mac)).join(\"\\u0000\")).join(\"\\u0001\") : \"\";\n  useEffect(() => {\n    if (!wanted) return;\n    const events = wanted.split(\"\\u0001\").map((s) => s.split(\"\\u0000\"));\n    const press = (e: KeyboardEvent) => {\n      if (e.repeat || typing(e)) return;\n      dispatch({ type: \"down\", key: e.key.toLowerCase(), steps: events });\n    };\n    const release = (e: KeyboardEvent) => dispatch({ type: \"up\", key: e.key.toLowerCase(), steps: events });\n    const clear = () => dispatch({ type: \"reset\" });\n    window.addEventListener(\"keydown\", press);\n    window.addEventListener(\"keyup\", release);\n    window.addEventListener(\"blur\", clear);\n    return () => {\n      window.removeEventListener(\"keydown\", press);\n      window.removeEventListener(\"keyup\", release);\n      window.removeEventListener(\"blur\", clear);\n      dispatch({ type: \"reset\" });\n    };\n  }, [wanted]);\n  // a sequence waits for its next step a moment, then starts over\n  const waiting = state.step > 0 && !state.complete;\n  useEffect(() => {\n    if (!waiting) return;\n    const t = window.setTimeout(() => dispatch({ type: \"reset\" }), SEQUENCE_MS);\n    return () => window.clearTimeout(t);\n  }, [waiting, state.done]);\n  const label = (k: string) => map[k.toLowerCase()] ?? (k.length === 1 ? k.toUpperCase() : k);\n  const cap = cn(styles.cap, \"font-sans\", size !== \"md\" && styles[size], classNames?.cap);\n  const face = (k: ReactNode) =>\n    // a label on a plain span is not read; the symbol is hidden and its word is spoken instead\n    typeof k === \"string\" && spoken[k] ? (\n      <>\n        <span data-slot=\"kbd-symbol\" className={classNames?.symbol} aria-hidden>\n          {k}\n        </span>\n        <span data-slot=\"kbd-spoken\" className={cn(\"sr-only\", classNames?.spoken)}>\n          {spoken[k]}\n        </span>\n      </>\n    ) : (\n      k\n    );\n  return (\n    <kbd\n      ref={ref}\n      data-slot=\"kbd\"\n      data-complete={state.complete || undefined}\n      className={cn(styles.keys, classNames?.root, className)}\n      {...props}\n    >\n      {keys ? (\n        steps.map((step, s) => (\n          <Fragment key={s}>\n            {s > 0 && (\n              <span\n                // a new wait restarts the line under it\n                key={waiting && s === state.step ? `then-${state.done}` : \"then\"}\n                data-slot=\"kbd-then\"\n                data-waiting={(waiting && s === state.step) || undefined}\n                className={cn(styles.then, classNames?.then)}\n                style={{ \"--_wait\": `${SEQUENCE_MS}ms` } as CSSProperties}\n              >\n                {i18n(\"then\")}\n              </span>\n            )}\n            {step.map((k, i) => {\n              const event = eventKey(k, mac);\n              return (\n                <span\n                  data-slot=\"kbd-cap\"\n                  key={i}\n                  data-pressed={(live && s === state.step && state.held.has(event)) || undefined}\n                  data-done={(live && s < state.step) || undefined}\n                  className={cap}\n                >\n                  {face(label(k))}\n                </span>\n              );\n            })}\n          </Fragment>\n        ))\n      ) : (\n        <span data-slot=\"kbd-cap\" className={cap}>\n          {face(children)}\n        </span>\n      )}\n    </kbd>\n  );\n}\n\nexport default Kbd;\n"},{"path":"beamline/components/kbd/messages.ts","type":"registry:file","target":"src/ui/beamline/components/kbd/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  then: \"then\",\n  escapeShort: \"Esc\",\n  space: \"Space\",\n  controlShort: \"Ctrl\",\n  alt: \"Alt\",\n  shift: \"Shift\",\n  enter: \"Enter\",\n  backspace: \"Backspace\",\n  tab: \"Tab\",\n  command: \"Command\",\n  control: \"Control\",\n  option: \"Option\",\n  up: \"Up\",\n  down: \"Down\",\n  left: \"Left\",\n  right: \"Right\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/label/label.tsx","type":"registry:file","target":"src/ui/beamline/components/label/label.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport type { ComponentProps } from \"react\";\nimport * as RadixLabel from \"@radix-ui/react-label\";\nimport { cn } from \"../../lib/utils\";\n\nexport interface LabelProps extends ComponentProps<typeof RadixLabel.Root> {\n  /** Shows the marker and announces \"required\". */\n  required?: boolean;\n  /** The control says \"required\" itself (a native required input, a radiogroup with aria-required): the label shows\n   * the marker only, so the control's name stays the label's words and the state is announced once. */\n  requiredOnControl?: boolean;\n}\n\n/** The text label tied to a form control. It dims with a disabled control next to it (peer) or inside it (group). */\nexport function Label({ required = false, requiredOnControl = false, className, children, ...props }: LabelProps) {\n  const i18n = useMessages(\"label\", englishMessages);\n\n  return (\n    <RadixLabel.Root\n      data-slot=\"label\"\n      {...props}\n      className={cn(\n        \"inline-flex items-center gap-[var(--pui-space-1)] text-sm leading-tight font-medium text-ink select-none group-data-[disabled]:opacity-[var(--pui-opacity-disabled)] peer-disabled:opacity-[var(--pui-opacity-disabled)]\",\n        className,\n      )}\n    >\n      {children}\n      {required && (\n        <>\n          <span aria-hidden className=\"text-red\">\n            *\n          </span>\n          {!requiredOnControl && <span className=\"sr-only\">{i18n(\"required\")}</span>}\n        </>\n      )}\n    </RadixLabel.Root>\n  );\n}\n\nexport default Label;\n"},{"path":"beamline/components/label/messages.ts","type":"registry:file","target":"src/ui/beamline/components/label/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  required: \"required\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/password-field/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/password-field/defaults.ts","content":"/** Pure allowed presentation defaults for PasswordField; no component or runtime imports. */\nexport interface PasswordFieldDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/password-field/messages.ts","type":"registry:file","target":"src/ui/beamline/components/password-field/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  hidePassword: \"Hide password\",\n  showPassword: \"Show password\",\n  capsLock: \"Caps Lock\",\n  capsLockIsOn: \"Caps Lock is on\",\n  ofRulesMet: \"{metCount} of {length} rules met\",\n  passwordRules: \"Password rules\",\n  met: \", met\",\n  notMetYet: \", not met yet\",\n  weak: \"Weak\",\n  fair: \"Fair\",\n  good: \"Good\",\n  strong: \"Strong\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/password-field/password-field.module.css","type":"registry:file","target":"src/ui/beamline/components/password-field/password-field.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Password field: the reveal toggle and the Caps Lock notice at the end of the frame; for a new password, a strength\n     meter whose segments fill from the start and drain back, and the rules it must meet, each ticked as it is met. */\n  .reveal {\n    display: grid;\n    width: var(--pui-space-7);\n    height: var(--pui-space-7);\n    margin-inline-end: calc(var(--pui-space-1) * -1);\n    place-items: center;\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink-3);\n    transition:\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .reveal:hover:not(:disabled) {\n    background: var(--pui-hover);\n    color: var(--pui-ink);\n    transition-duration: 0ms;\n  }\n  .reveal:active:not(:disabled) {\n    scale: var(--pui-press-scale);\n  }\n\n  /* said in words beside the cause, in the warning colour, never colour alone (the arrow and the word) */\n  .caps {\n    display: inline-flex;\n    align-items: center;\n    gap: var(--pui-space-1);\n    padding-inline: var(--pui-space-1-5);\n    height: var(--pui-space-5);\n    border-radius: var(--pui-radius-chip);\n    background: color-mix(in oklab, var(--pui-orange) 14%, transparent);\n    color: var(--pui-orange);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-medium);\n    white-space: nowrap;\n    animation: caps-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .caps :where(svg) {\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n  }\n  @keyframes caps-in {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.15 * var(--pui-motion-travel, 1));\n    }\n  }\n\n  .strength {\n    position: relative;\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-2);\n  }\n  .strength[data-score=\"1\"] {\n    --_tone: var(--pui-red);\n  }\n  .strength[data-score=\"2\"] {\n    --_tone: var(--pui-orange);\n  }\n  .strength[data-score=\"3\"] {\n    --_tone: var(--pui-blue);\n  }\n  .strength[data-score=\"4\"] {\n    --_tone: var(--pui-green);\n  }\n  .meter {\n    display: grid;\n    flex: 1;\n    grid-template-columns: repeat(4, 1fr);\n    gap: var(--pui-space-1);\n  }\n  .segment {\n    height: var(--pui-space-1);\n    overflow: hidden;\n    border-radius: var(--pui-radius-pill);\n    background: var(--pui-hover-2);\n  }\n  /* each segment fills from its start edge, one after another as the score rises, and drains together as it falls */\n  .segment::after {\n    content: \"\";\n    display: block;\n    height: 100%;\n    background: var(--_tone, transparent);\n    transform: scaleX(0);\n    transform-origin: left;\n    transition:\n      transform var(--pui-duration-standard) var(--pui-ease-default),\n      background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .segment:dir(rtl)::after {\n    transform-origin: right;\n  }\n  .segment[data-on]::after {\n    transform: none;\n    transition-delay: calc(var(--i, 0) * var(--pui-stagger-item)), 0s;\n  }\n  .word {\n    min-inline-size: 7ch;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    text-align: end;\n  }\n  .wordIn {\n    display: inline-block;\n    animation: word-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  @keyframes word-in {\n    from {\n      opacity: 0;\n      translate: 0 calc(3px * var(--pui-motion-travel, 1));\n    }\n  }\n\n  /* the rules carry their own gap inside the fold, so a closed list takes no room under the field */\n  .rules {\n    margin-top: calc(var(--pui-field-gap, var(--pui-space-1-5)) * -1);\n  }\n  .ruleList {\n    display: grid;\n    gap: var(--pui-space-1);\n    padding-top: var(--pui-field-gap, var(--pui-space-1-5));\n  }\n  .rule {\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-1-5);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    transition: color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .rule[data-met] {\n    color: var(--pui-ink-2);\n    transition-duration: var(--pui-motion-in);\n  }\n  .tick {\n    flex-shrink: 0;\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    fill: none;\n    stroke: currentColor;\n    stroke-width: 2;\n    stroke-linecap: round;\n    stroke-linejoin: round;\n  }\n  /* unmet: a quiet dot; met: the dot gives way to a green check that draws itself */\n  .dot {\n    fill: currentColor;\n    stroke: none;\n    transition: opacity var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .check {\n    color: var(--pui-green);\n    stroke: var(--pui-green);\n    stroke-dasharray: 14;\n    stroke-dashoffset: 14;\n    transition: stroke-dashoffset var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .rule[data-met] .dot {\n    opacity: 0;\n  }\n  .rule[data-met] .check {\n    stroke-dashoffset: 0;\n  }\n}\n"},{"path":"beamline/components/password-field/password-field.tsx","type":"registry:file","target":"src/ui/beamline/components/password-field/password-field.tsx","content":"\"use client\";\n\nimport { type Translator } from \"../../runtime/messages\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useDeprecatedProp } from \"../../hooks/use-deprecated-prop\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { PasswordFieldDefaults } from \"./defaults\";\nimport { cssEase } from \"../../lib/motion-tokens\";\nexport type { PasswordFieldDefaults } from \"./defaults\";\n\nimport {\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type InputHTMLAttributes,\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { ArrowBigUp, Eye, EyeOff } from \"lucide-react\";\nimport {\n  Field,\n  FieldFrame,\n  fieldControlClass,\n  useFieldIds,\n  type FieldClassNames,\n  type FieldSize,\n} from \"../_shared/field\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport styles from \"./password-field.module.css\";\n\nexport type PasswordStrength = 0 | 1 | 2 | 3 | 4;\n\n/** One rule a new password must meet, checked as it is typed (\"At least 12 characters\"). */\nexport interface PasswordRequirement {\n  /** Text displayed for this item. Required. */\n  label: string;\n  /** Returns true when the password meets this rule. Required. */\n  test: (value: string) => boolean;\n}\n\nexport interface PasswordFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, \"type\" | \"size\"> {\n  /** Visible label or accessible name for the control. Required. */\n  label: ReactNode;\n  /** Reports the text after an edit; no callback by default, and native onChange still receives its event. */\n  onValueChange?: (value: string) => void;\n  /** Shows the label visually while keeping its accessible name; defaults to true. Takes precedence over hideLabel. */\n  showLabel?: boolean;\n  /** Hides the label; defaults to false. @deprecated Use showLabel={false} instead. */\n  hideLabel?: boolean;\n  /** Supporting text beneath the label or heading. Defaults to no supporting text. */\n  description?: ReactNode;\n  /** Error message shown in place of the usual supporting text. Defaults to no error message. */\n  error?: ReactNode;\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: FieldSize;\n  /** Show a strength meter (for creating a password, not for signing in). */\n  showStrength?: boolean;\n  /** Your own scorer (0 = empty … 4 = strong); the built-in one weighs length, variety and obvious patterns. */\n  scoreStrength?: (value: string) => PasswordStrength;\n  /** Rules for a new password, ticked off as they are met. The list opens while the field has attention and stays\n   *  open while one is unmet; when every rule is met it folds away. */\n  requirements?: PasswordRequirement[];\n  /** At the end of the label's row: \"Forgot password?\". */\n  aside?: ReactNode;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for documented field parts; root style does not replace native behavior. */\n  classNames?: FieldClassNames &\n    Partial<Record<\"input\" | \"reveal\" | \"strength\" | \"requirements\" | \"requirement\" | \"caps\", string>>;\n  /** Receives the native input; no ref by default. */\n  ref?: Ref<HTMLInputElement>;\n}\n\nconst COMMON = /^(password|passwort|qwerty|letmein|welcome|admin|iloveyou|123456|abc123)/i;\n/** Length first, then variety; repeated runs, sequences and well-known passwords are marked down. */\nexport function scorePassword(v: string): PasswordStrength {\n  if (!v) return 0;\n  let s = v.length >= 16 ? 3 : v.length >= 12 ? 2 : v.length >= 8 ? 1 : 0;\n  const kinds = [/[a-z]/, /[A-Z]/, /\\d/, /[^\\w\\s]/].filter((r) => r.test(v)).length;\n  if (kinds >= 3) s += 1;\n  if (/(.)\\1{2,}/.test(v) || /(0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef)/i.test(v)) s -= 1;\n  if (COMMON.test(v)) s = Math.min(s, 1);\n  return Math.max(1, Math.min(4, s)) as PasswordStrength;\n}\nfunction localizedWORDS(i18n: Translator<typeof englishMessages>) {\n  return [\"\", i18n(\"weak\"), i18n(\"fair\"), i18n(\"good\"), i18n(\"strong\")] as const;\n}\n\n/**\n * A password field with a reveal control and a Caps Lock notice; for a new password, a strength meter and the rules it\n * must meet, ticked off as it is typed.\n */\nexport function PasswordField({\n  label,\n  hideLabel: hideLabelProp,\n  showLabel,\n  description,\n  error,\n  size: sizeProp,\n  showStrength = false,\n  scoreStrength = scorePassword,\n  requirements,\n  aside,\n  id,\n  className,\n  classNames,\n  style,\n  required,\n  disabled,\n  value,\n  defaultValue,\n  onChange,\n  onValueChange,\n  onKeyDown,\n  onKeyUp,\n  onFocus,\n  onBlur,\n  ref,\n  ...props\n}: PasswordFieldProps) {\n  const i18n = useMessages(\"password-field\", englishMessages);\n\n  useDeprecatedProp(\"PasswordField\", \"hideLabel\", \"showLabel={false}\", hideLabelProp !== undefined);\n  const hideLabel = showLabel === undefined ? hideLabelProp : !showLabel;\n  const scopeDefaults = useComponentDefaults<PasswordFieldDefaults>(\"password-field\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n\n  const { controlId, descriptionId, errorId, aria } = useFieldIds(id, { description, error });\n  const [visible, setVisible] = useState(false);\n  const [caps, setCaps] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const field = useRef<HTMLInputElement>(null);\n  const attach = useComposedRef(field, ref);\n  const motion = useMotionTokens(field);\n  // the native change event is passed on as it is, so the hook only holds the text\n  const [text, setText] = useControllable(value !== undefined ? String(value) : undefined, String(defaultValue ?? \"\"));\n  const score = showStrength ? scoreStrength(text) : 0;\n  const met = requirements?.map((r) => r.test(text)) ?? [];\n  const metCount = met.filter(Boolean).length;\n  // the rules wait for attention, stay while one is unmet, and fold away once every one is met\n  const rulesOpen = !!requirements?.length && (focused || (text !== \"\" && metCount < met.length));\n  const readCaps = (e: KeyboardEvent<HTMLInputElement>) => setCaps(e.getModifierState?.(\"CapsLock\") ?? false);\n  // a caller that already says it (an auth form's own hint under the field) is not repeated at the end of the frame\n  const [saidByCaller, setSaidByCaller] = useState(false);\n  useLayoutEffect(() => {\n    const said = !!field.current?.closest(\"[data-slot=field]\")?.querySelector(\"[data-slot=caps-lock-hint]\");\n    setSaidByCaller((s) => (s === said ? s : said));\n  }, [caps, focused]);\n  const showCaps = caps && focused && !saidByCaller;\n  const reveal = (\n    <button\n      type=\"button\"\n      data-slot=\"password-reveal\"\n      disabled={disabled}\n      aria-label={visible ? i18n(\"hidePassword\") : i18n(\"showPassword\")}\n      aria-pressed={visible}\n      onClick={() => {\n        setVisible((v) => !v);\n        // dots and letters swap through a short blur, so the change reads as one text turning, not a jump\n        if (!motion.reduced) {\n          const ease = motion.ease.standard;\n          field.current?.animate(\n            [\n              { filter: \"blur(3px)\", opacity: 0.4 },\n              { filter: \"none\", opacity: 1 },\n            ],\n            {\n              duration: motion.duration.standard * 1000,\n              easing: cssEase(ease, undefined),\n            },\n          );\n        }\n      }}\n      className={cn(styles.reveal, classNames?.reveal)}\n    >\n      {visible ? (\n        <EyeOff key=\"off\" className=\"size-4 animate-enter-scale\" />\n      ) : (\n        <Eye key=\"on\" className=\"size-4 animate-enter-scale\" />\n      )}\n    </button>\n  );\n  return (\n    <Field\n      label={label}\n      hideLabel={hideLabel}\n      description={description}\n      error={error}\n      aside={aside}\n      required={required}\n      requiredOnControl\n      controlId={controlId}\n      descriptionId={descriptionId}\n      errorId={errorId}\n      className={className}\n      classNames={classNames}\n      style={style}\n    >\n      <FieldFrame\n        classNames={classNames}\n        size={size}\n        invalid={!!error}\n        disabled={disabled}\n        trailing={\n          <>\n            {showCaps && (\n              // said where the typing happens, the moment it matters: a password typed in capitals fails silently\n              <span data-slot=\"password-caps\" className={cn(styles.caps, classNames?.caps)}>\n                <ArrowBigUp aria-hidden />\n                <span className={styles.capsWord}>{i18n(\"capsLock\")}</span>\n              </span>\n            )}\n            {reveal}\n          </>\n        }\n      >\n        <input\n          ref={attach}\n          data-slot=\"password-field\"\n          {...aria}\n          type={visible ? \"text\" : \"password\"}\n          required={required}\n          disabled={disabled}\n          value={value}\n          defaultValue={defaultValue}\n          autoCapitalize=\"none\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          className={cn(fieldControlClass, classNames?.input)}\n          onChange={(e) => {\n            setText(e.target.value);\n            onChange?.(e);\n            onValueChange?.(e.target.value);\n          }}\n          onKeyDown={(e) => {\n            readCaps(e);\n            onKeyDown?.(e);\n          }}\n          onKeyUp={(e) => {\n            readCaps(e);\n            onKeyUp?.(e);\n          }}\n          onFocus={(e) => {\n            setFocused(true);\n            onFocus?.(e);\n          }}\n          onBlur={(e) => {\n            setFocused(false);\n            onBlur?.(e);\n          }}\n          {...props}\n        />\n        {/* live words stay inside the frame (it is positioned), heard when they change, never at every key */}\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {showCaps ? i18n(\"capsLockIsOn\") : \"\"}\n        </span>\n        {!!requirements?.length && (\n          <span className=\"sr-only\" aria-live=\"polite\">\n            {text ? i18n(\"ofRulesMet\", { metCount: metCount, length: met.length }) : \"\"}\n          </span>\n        )}\n      </FieldFrame>\n      {showStrength && (\n        <div data-slot=\"password-strength\" data-score={score} className={cn(styles.strength, classNames?.strength)}>\n          <div className={styles.meter} aria-hidden>\n            {[1, 2, 3, 4].map((i) => (\n              <span\n                key={i}\n                data-on={score >= i ? \"\" : undefined}\n                className={styles.segment}\n                style={{ \"--i\": i - 1 } as CSSProperties}\n              />\n            ))}\n          </div>\n          {/* a new word rises in where the old one was; it is also what assistive tech hears, once per change */}\n          <span className={styles.word} aria-live=\"polite\">\n            {text ? (\n              <span key={score} className={styles.wordIn}>\n                {localizedWORDS(i18n)[score]}\n              </span>\n            ) : (\n              \"\"\n            )}\n          </span>\n        </div>\n      )}\n      {!!requirements?.length && (\n        <div\n          data-slot=\"password-requirements\"\n          data-open={rulesOpen ? \"\" : undefined}\n          className={cn(\"disclosure-panel\", styles.rules, classNames?.requirements)}\n        >\n          <div>\n            <ul className={styles.ruleList} aria-label={i18n(\"passwordRules\")}>\n              {requirements.map((r, i) => (\n                <li\n                  key={r.label}\n                  data-slot=\"password-requirement\"\n                  data-met={met[i] ? \"\" : undefined}\n                  className={cn(styles.rule, classNames?.requirement)}\n                >\n                  <svg viewBox=\"0 0 12 12\" aria-hidden className={styles.tick}>\n                    <circle className={styles.dot} cx=\"6\" cy=\"6\" r=\"1.5\" />\n                    <path className={styles.check} d=\"M2.5 6.2 5 8.6l4.6-5\" />\n                  </svg>\n                  {r.label}\n                  <span className=\"sr-only\">{met[i] ? i18n(\"met\") : i18n(\"notMetYet\")}</span>\n                </li>\n              ))}\n            </ul>\n          </div>\n        </div>\n      )}\n    </Field>\n  );\n}\n\nexport default PasswordField;\n"},{"path":"beamline/components/progress/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/progress/defaults.ts","content":"/** Pure allowed presentation defaults for Progress; no component or runtime imports. */\nexport interface ProgressDefaults {\n  /** Shared tone setting; omit to inherit the surrounding scope or the component default. */\n  tone?: \"danger\" | \"accent\" | \"success\" | \"warning\";\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\";\n}\n"},{"path":"beamline/components/progress/messages.ts","type":"registry:file","target":"src/ui/beamline/components/progress/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  remaining: \"about {duration} left\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/progress/progress.module.css","type":"registry:file","target":"src/ui/beamline/components/progress/progress.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* The number rolls to each new value with the fill. Done, a check arrives beside it (a shape as well as the 100%). */\n  .value {\n    display: inline-flex;\n    align-items: center;\n    gap: var(--pui-space-1);\n  }\n  /* the estimate: secondary, after the label, the first part to go when the header is tight */\n  .remaining {\n    flex: 0 1 auto;\n    min-width: 0;\n    margin-inline-end: auto;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    animation: done var(--pui-duration-standard) var(--pui-ease-default) both;\n  }\n  .done {\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    color: var(--pui-progress-fill);\n    animation: done var(--pui-duration-standard) var(--pui-ease-default) both;\n  }\n  @keyframes done {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.5 * var(--pui-motion-travel, 1));\n    }\n  }\n  /* Progress: a well (the inset role) and a solid fill in the tone's colour; the indeterminate fill is a short bar\n     sweeping the track. */\n  .track {\n    --m-inset-fill: var(--pui-control-track);\n    position: relative;\n    overflow: hidden;\n    border-radius: var(--pui-radius-pill);\n  }\n  .sm {\n    height: 4px;\n  }\n  .md {\n    height: 6px;\n  }\n  .fill {\n    --m-color: var(--pui-progress-fill);\n    position: absolute;\n    inset-block: 0;\n    inset-inline-start: 0;\n    width: 100%;\n    border-radius: inherit;\n    transform-origin: left center;\n    transition: transform var(--pui-duration-standard) var(--pui-ease-default);\n  }\n  .fill:dir(rtl) {\n    transform-origin: right center;\n  }\n  .indeterminate {\n    width: 38%;\n    animation: sweep calc(1.3s * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1)) var(--pui-ease-in-out) infinite;\n  }\n  /* in RTL the fill starts at the right, so the sweep runs right to left */\n  .indeterminate:dir(rtl) {\n    animation-name: sweep-rtl;\n  }\n  .indeterminate[data-reduced] {\n    animation: none;\n    width: 100%;\n    opacity: 0.45;\n  }\n  @keyframes sweep-rtl {\n    from {\n      transform: translateX(110%);\n    }\n    to {\n      transform: translateX(-280%);\n    }\n  }\n  @keyframes sweep {\n    from {\n      transform: translateX(-110%);\n    }\n    to {\n      transform: translateX(280%);\n    }\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .indeterminate {\n      animation: none;\n      width: 100%;\n      opacity: 0.45;\n    }\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.track {\n  composes: m-inset meter from global;\n}\n.fill {\n  composes: m-solid from global;\n}\n"},{"path":"beamline/components/progress/progress.tsx","type":"registry:file","target":"src/ui/beamline/components/progress/progress.tsx","content":"\"use client\";\n\nimport { useMessages, type Translator } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { ProgressDefaults } from \"./defaults\";\nexport type { ProgressDefaults } from \"./defaults\";\n\nimport { useEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type ReactNode, 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 * as RadixProgress from \"@radix-ui/react-progress\";\nimport { Check } from \"lucide-react\";\nimport { SlotText } from \"../slot-text/slot-text\";\nimport { cn } from \"../../lib/utils\";\nimport { DeltaGhost, useDeltaGhost } from \"../_shared/delta-ghost\";\nimport styles from \"./progress.module.css\";\n\nexport interface ProgressClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the header part; no override by default. */\n  header?: string;\n  /** Classes for the label part; no override by default. */\n  label?: string;\n  /** Classes for the value part; no override by default. */\n  value?: string;\n  /** Classes for the track part; no override by default. */\n  track?: string;\n  /** Classes for the fill part; no override by default. */\n  fill?: string;\n}\n\nexport interface ProgressProps extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Done so far; null when the length is unknown (the bar sweeps). */\n  value?: number | null;\n  /** Maximum permitted value. Defaults to 100. */\n  max?: number;\n  /** Shown above the bar and used as its accessible name. */\n  label?: ReactNode;\n  /** Shows the percentage, or `format`'s text, beside the label. */\n  showValue?: boolean;\n  /** Custom value text: (value, max) => \"12 of 40 files\". */\n  format?: (value: number, max: number) => string;\n  /** Estimates the time left from the pace so far and shows it beside the label once the task has run for 2 s. */\n  showRemaining?: boolean;\n  /** Words for the estimate (ms left). Default \"about 20 s left\" / \"about 3 min left\". */\n  formatRemaining?: (ms: number) => string;\n  /** Semantic colour of the displayed content. Defaults to \"accent\". */\n  tone?: \"accent\" | \"success\" | \"warning\" | \"danger\";\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: \"sm\" | \"md\";\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: ProgressClassNames;\n}\n\nconst FILL = {\n  accent: \"var(--pui-accent)\",\n  success: \"var(--pui-green)\",\n  warning: \"var(--pui-orange)\",\n  danger: \"var(--pui-red)\",\n} as const;\n\n/** How much of a known task is done. The fill eases to each new value and the step it took stays lit for a moment (a\n *  step back drains); an unknown length sweeps until a value arrives. */\nexport function Progress({\n  value = 0,\n  max = 100,\n  label,\n  showValue = false,\n  format,\n  showRemaining = false,\n  formatRemaining: formatRemainingProp,\n  tone: toneProp,\n  size: sizeProp,\n  className,\n  classNames,\n  style,\n  ref,\n  \"aria-label\": ariaLabel,\n  ...props\n}: ProgressProps) {\n  const i18n = useMessages(\"progress\", englishMessages);\n  const formatRemaining = formatRemainingProp ?? ((ms: number) => remainingWords(ms, i18n));\n  const scopeDefaults = useComponentDefaults<ProgressDefaults>(\"progress\", [\"tone\", \"size\"]);\n  const tone = toneProp ?? scopeDefaults.tone ?? \"accent\";\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n  const host = useRef<HTMLDivElement>(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 known = value !== null && Number.isFinite(value);\n  const clamped = known ? Math.min(max, Math.max(0, value as number)) : 0;\n  const text = known\n    ? format\n      ? format(clamped, max)\n      : i18n.number(clamped / max, { style: \"percent\", maximumFractionDigits: 0 })\n    : \"\";\n  const named = typeof label === \"string\" ? label : undefined;\n  const [ghost, clearGhost] = useDeltaGhost(known ? clamped / max : null);\n  // the pace so far, from the first value seen (a step back starts again): time left only once there is a pace to trust\n  const since = useRef<{ t: number; v: number } | null>(null);\n  const [left, setLeft] = useState<number | null>(null);\n  useEffect(() => {\n    if (!showRemaining || !known) return;\n    const now = performance.now();\n    const from = since.current;\n    if (!from || clamped < from.v) {\n      since.current = { t: now, v: clamped };\n      setLeft(null);\n      return;\n    }\n    const spent = now - from.t;\n    const done = clamped - from.v;\n    setLeft(spent >= 2000 && done > 0 && clamped < max ? ((max - clamped) / done) * spent : null);\n  }, [clamped, known, showRemaining, max]);\n  return (\n    <div\n      ref={attach}\n      data-slot=\"progress\"\n      data-state={!known ? \"indeterminate\" : clamped >= max ? \"complete\" : \"loading\"}\n      className={cn(\"grid gap-1.5\", classNames?.root, className)}\n      style={{ \"--pui-progress-fill\": FILL[tone], ...style } as CSSProperties}\n      {...props}\n    >\n      {(label || showValue) && (\n        <div\n          data-slot=\"progress-header\"\n          className={cn(\"flex items-baseline justify-between gap-3 text-xs\", classNames?.header)}\n        >\n          {label && (\n            <span data-slot=\"progress-label\" className={cn(\"truncate text-ink-2\", classNames?.label)}>\n              {label}\n            </span>\n          )}\n          {left !== null && (\n            <span data-slot=\"progress-remaining\" className={cn(styles.remaining, \"text-ink-3\")}>\n              {formatRemaining(left)}\n            </span>\n          )}\n          {showValue && known && (\n            <span data-slot=\"progress-value\" className={cn(styles.value, \"text-ink-3 tabular-nums\", classNames?.value)}>\n              {clamped >= max && <Check aria-hidden className={styles.done} />}\n              <SlotText value={text} stagger={0} duration={tokens.duration.standard * 1000} />\n            </span>\n          )}\n        </div>\n      )}\n      <RadixProgress.Root\n        data-slot=\"progress-track\"\n        value={known ? clamped : null}\n        max={max}\n        getValueLabel={() => text}\n        aria-label={ariaLabel ?? named}\n        className={cn(styles.track, styles[size], classNames?.track)}\n      >\n        <DeltaGhost ghost={ghost} onDone={clearGhost} color={FILL[tone]} />\n        <RadixProgress.Indicator\n          data-slot=\"progress-fill\"\n          data-reduced={reduced ? \"\" : undefined}\n          className={cn(styles.fill, !known && styles.indeterminate, classNames?.fill)}\n          style={\n            known ? { transform: `scaleX(${clamped / max})` } : { animationPlayState: active ? \"running\" : \"paused\" }\n          }\n        />\n      </RadixProgress.Root>\n    </div>\n  );\n}\n\nfunction remainingWords(ms: number, i18n: Translator<typeof englishMessages>) {\n  const s = Math.max(1, Math.round(ms / 1000));\n  return i18n(\"remaining\", {\n    duration: i18n.number(s < 60 ? s : Math.round(s / 60), {\n      style: \"unit\",\n      unit: s < 60 ? \"second\" : \"minute\",\n      unitDisplay: \"short\",\n    }),\n  });\n}\n\nexport default Progress;\n"},{"path":"beamline/components/search-field/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/search-field/defaults.ts","content":"/** Pure allowed presentation defaults for SearchField; no component or runtime imports. */\nexport interface SearchFieldDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/search-field/messages.ts","type":"registry:file","target":"src/ui/beamline/components/search-field/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  clearSearch: \"Clear search\",\n  searching: \"Searching\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/search-field/search-field.tsx","type":"registry:file","target":"src/ui/beamline/components/search-field/search-field.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useWidthAs } from \"../../hooks/use-content-width\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SearchFieldDefaults } from \"./defaults\";\nexport type { SearchFieldDefaults } from \"./defaults\";\n\nimport { useEffect, useRef, useState, type InputHTMLAttributes, type ReactNode, type Ref } from \"react\";\nimport { Search, X } from \"lucide-react\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport { IconAction } from \"../_shared/icon-action\";\nimport {\n  Field,\n  FieldFrame,\n  fieldControlClass,\n  useFieldIds,\n  type FieldClassNames,\n  type FieldSize,\n} from \"../_shared/field\";\nimport { Glow } from \"../glow/glow\";\nimport { Kbd } from \"../kbd/kbd\";\nimport { Spinner } from \"../spinner/spinner\";\n\nexport interface SearchFieldProps extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"type\" | \"size\" | \"value\" | \"defaultValue\"\n> {\n  /** Accessible name; hidden by default (the magnifier and placeholder say what the field is). */\n  label: ReactNode;\n  /** Show the label above the field. */\n  showLabel?: boolean;\n  /** Controlled search text; omit to use defaultValue. */\n  value?: string;\n  /** Initial search text when uncontrolled. Defaults to an empty string. */\n  defaultValue?: string;\n  /** Reports search text after an edit; no callback by default. */\n  onValueChange?: (value: string) => void;\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: FieldSize;\n  /** A shortcut hint shown while the field is empty, e.g. \"/\" or \"⌘K\". A one-key shortcut (\"/\") is the field's own: pressed\n   *  anywhere but in a text field, it focuses the search. A chord (\"⌘K\") is shown only; the app binds it. */\n  shortcut?: string;\n  /** A band of light rides the border: \"focus\" while focused, true while results are on their way. */\n  beam?: boolean | \"focus\";\n  /** Results are on their way: the magnifier turns into a spinner (aria-busy); typing goes on. */\n  loading?: boolean;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for documented field parts; root style does not replace native behavior. */\n  classNames?: FieldClassNames & Partial<Record<\"input\" | \"clear\", string>>;\n  /** Receives the native input; no ref by default. */\n  ref?: Ref<HTMLInputElement>;\n}\n\n/** The search entry point: magnifier, clear button, Escape to clear, an optional shortcut hint and a live beam. */\nexport function SearchField({\n  label,\n  showLabel = false,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onChange,\n  size: sizeProp,\n  shortcut,\n  beam,\n  loading,\n  id,\n  className,\n  classNames,\n  style,\n  disabled,\n  onKeyDown,\n  onFocus,\n  onBlur,\n  ref,\n  ...props\n}: SearchFieldProps) {\n  const i18n = useMessages(\"search-field\", englishMessages);\n\n  const scopeDefaults = useComponentDefaults<SearchFieldDefaults>(\"search-field\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n\n  const { controlId, aria } = useFieldIds(id);\n  const [text, set] = useControllable(value, defaultValue, onValueChange);\n  const [focused, setFocused] = useState(false);\n  const input = useRef<HTMLInputElement | null>(null);\n  const attach = useComposedRef(input, ref);\n  const frameRef = useRef<HTMLDivElement>(null);\n  // the shortcut hint is the first thing to go when the field is narrow: the words typed matter more\n  const roomy = useWidthAs(frameRef, (w) => w >= 200, true);\n  // a one-key shortcut works where it is shown: pressed outside a text field, it brings the person here\n  useEffect(() => {\n    if (!shortcut || [...shortcut].length !== 1 || disabled) return;\n    const down = (e: KeyboardEvent) => {\n      if (e.key !== shortcut || e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return;\n      const at = e.target instanceof HTMLElement ? e.target : null;\n      if (at?.isContentEditable || at?.closest(\"input, textarea, select\")) return;\n      e.preventDefault();\n      input.current?.focus();\n    };\n    window.addEventListener(\"keydown\", down);\n    return () => window.removeEventListener(\"keydown\", down);\n  }, [shortcut, disabled]);\n  // the magnifier comes back scaled in after a search, never on the first paint\n  const [searched, setSearched] = useState(false);\n  if (loading && !searched) setSearched(true);\n  const trailing = text ? (\n    <IconAction\n      data-slot=\"search-clear\"\n      aria-label={i18n(\"clearSearch\")}\n      disabled={disabled}\n      onClick={() => {\n        set(\"\");\n        input.current?.focus();\n      }}\n      className={cn(\"-me-1 animate-enter-scale\", classNames?.clear)}\n    >\n      <X className=\"size-3.5\" />\n    </IconAction>\n  ) : shortcut && !focused && roomy ? (\n    <Kbd className=\"animate-enter-fade\">{shortcut}</Kbd>\n  ) : null;\n  const frame = (\n    <FieldFrame\n      ref={frameRef}\n      classNames={classNames}\n      size={size}\n      disabled={disabled}\n      leading={\n        // one place for what the field is doing: the magnifier, or the ring while results are on their way\n        loading ? (\n          <Spinner key=\"busy\" size=\"sm\" label={i18n(\"searching\")} className=\"animate-enter-scale\" />\n        ) : (\n          <Search key=\"rest\" className={searched ? \"animate-enter-scale\" : undefined} />\n        )\n      }\n      trailing={trailing}\n      // nesting input (O1 §14.1): the shortcut keycap (22px) rounds concentric with the field — a pill in a pill field\n      className=\"[--pui-nest-inset:calc((var(--_field-height)-1.375rem)/2)] [--pui-nest-radius:var(--pui-field-radius,var(--pui-radius-control))]\"\n    >\n      {\" \"}\n      <input\n        ref={attach}\n        data-slot=\"search-field\"\n        {...aria}\n        type=\"search\"\n        role=\"searchbox\"\n        aria-busy={loading || undefined}\n        value={text}\n        disabled={disabled}\n        autoComplete=\"off\"\n        spellCheck={false}\n        className={cn(fieldControlClass, \"[&::-webkit-search-cancel-button]:hidden\", classNames?.input)}\n        onChange={(e) => {\n          set(e.target.value);\n          onChange?.(e);\n        }}\n        onKeyDown={(e) => {\n          // Escape clears; on an empty field it lets go of the field\n          if (e.key === \"Escape\" && text) {\n            e.preventDefault();\n            set(\"\");\n          } else if (e.key === \"Escape\") e.currentTarget.blur();\n          onKeyDown?.(e);\n        }}\n        onFocus={(e) => {\n          setFocused(true);\n          onFocus?.(e);\n        }}\n        onBlur={(e) => {\n          setFocused(false);\n          onBlur?.(e);\n        }}\n        {...props}\n      />\n    </FieldFrame>\n  );\n  const active = beam === \"focus\" ? focused : !!beam;\n  return (\n    <Field\n      label={label}\n      hideLabel={!showLabel}\n      controlId={controlId}\n      className={className}\n      classNames={classNames}\n      style={style}\n    >\n      {/* the wrapper stays while a beam is wanted at all, even off: swapping it in and out would remount the input and drop focus */}\n      {beam !== undefined ? (\n        <Glow effect=\"ring\" active={active} radius=\"var(--pui-radius-control)\" intensity={0.9}>\n          {frame}\n        </Glow>\n      ) : (\n        frame\n      )}\n    </Field>\n  );\n}\n\nexport default SearchField;\n"},{"path":"beamline/components/slot-text/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/slot-text/defaults.ts","content":"/** Pure allowed presentation defaults for SlotText; no component or runtime imports. */\nexport interface SlotTextDefaults {\n  /** Shared align setting; omit to inherit the surrounding scope or the component default. */\n  align?: \"start\" | \"end\";\n}\n"},{"path":"beamline/components/slot-text/slot-text.module.css","type":"registry:file","target":"src/ui/beamline/components/slot-text/slot-text.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A reel stands on the run of digits it is rolling to; once it lands it is quietly reset to the same digit on its\n     middle run (no motion), so the next roll can turn either way without running out of reel. A roll is an animation\n     from the numeral it showed to the one it lands on, in whole numerals (the staircase timing in slot-text.tsx), so it\n     always starts from a whole digit, even when a new value arrives mid-roll; the two names take turns so each change\n     starts afresh. */\n  .reel {\n    position: absolute;\n    inset-inline: 0;\n    top: 0;\n    display: flex;\n    flex-direction: column;\n    animation-fill-mode: both;\n  }\n  .rollA {\n    animation-name: slot-text-roll-a;\n  }\n  .rollB {\n    animation-name: slot-text-roll-b;\n  }\n  /* the inline transform names the target; a landed reel is reset over it */\n  .reel[data-settled] {\n    transform: translateY(calc(var(--_rest) * -1lh)) !important;\n    animation: none !important;\n  }\n\n  /* a new place or a changed sign waits, unseen, for its turn in the ripple (its delay is the animation's length), then\n     is there whole: never half faded over its neighbour */\n  .arrive {\n    animation-name: slot-text-wait;\n    animation-timing-function: step-end;\n  }\n  @keyframes slot-text-roll-a {\n    from {\n      transform: translateY(calc(var(--_from) * -1lh));\n    }\n    to {\n      transform: translateY(calc(var(--_to) * -1lh));\n    }\n  }\n  @keyframes slot-text-roll-b {\n    from {\n      transform: translateY(calc(var(--_from) * -1lh));\n    }\n    to {\n      transform: translateY(calc(var(--_to) * -1lh));\n    }\n  }\n  @keyframes slot-text-wait {\n    from,\n    to {\n      visibility: hidden;\n    }\n  }\n}\n"},{"path":"beamline/components/slot-text/slot-text.tsx","type":"registry:file","target":"src/ui/beamline/components/slot-text/slot-text.tsx","content":"\"use client\";\n\nimport { type AnimationEvent, type Ref } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\n\nimport { useLocale, numberFormatter } from \"../../runtime/localization\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SlotTextDefaults } from \"./defaults\";\nexport type { SlotTextDefaults } from \"./defaults\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./slot-text.module.css\";\n\nexport interface SlotTextClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the spoken part; no override by default. */\n  spoken?: string;\n  /** Classes for the visual part; no override by default. */\n  visual?: string;\n  /** Classes for the digit part; no override by default. */\n  digit?: string;\n  /** Classes for the measure part; no override by default. */\n  measure?: string;\n  /** Classes for the reel part; no override by default. */\n  reel?: string;\n  /** Classes for the numeral part; no override by default. */\n  numeral?: string;\n  /** Classes for the character part; no override by default. */\n  character?: string;\n}\n\nexport interface SlotTextProps {\n  /** Text or number shown on the animated reels. Required. */\n  value: string | number;\n  /** Turns a number into its text: currency, thousands. */\n  format?: (value: number) => string;\n  /** ms per reel. Default: twice the considered duration (about 0.7 s), following the motion scale. */\n  duration?: number;\n  /** ms between reels, right to left. Default: the word stagger (about 30 ms), following the motion scale. */\n  stagger?: number;\n  /** Extra full turns a changing digit makes. Default 0. */\n  spins?: number;\n  /** Which side stays put when the length changes. Default end (numbers). */\n  align?: \"start\" | \"end\";\n  /** Read the new value aloud when it changes. Default false (most counters are glanceable). */\n  announce?: boolean;\n  /** Content direction. Numbers stay LTR; other text resolves its own direction instead of inheriting page layout. */\n  dir?: \"ltr\" | \"rtl\" | \"auto\";\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: SlotTextClassNames;\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}\n\nconst DIGITS = \"0123456789\";\n\n/** A roll of `n` numerals as a staircase on the system's ease-out: each numeral is shown whole and held until the eased\n *  travel reaches the next, so the reel slows into place like an odometer and no frame ever shows two digits split by\n *  the window (the owner rejected digits drawn over each other mid-roll, 27 Oct). */\nconst stairs = new Map<number, string>();\nfunction staircase(n: number) {\n  let s = stairs.get(n);\n  if (!s) {\n    // ease-out cubic: travel p at time t = 1 - (1 - p)^(1/3); numeral k shows once the travel passes the middle of k\n    const at = (k: number) => `${((1 - Math.cbrt(1 - (k - 0.5) / n)) * 100).toFixed(2)}%`;\n    const points = [\"0 0%\"];\n    for (let k = 1; k <= n; k++) points.push(`${(k - 1) / n} ${at(k)}`, `${k / n} ${at(k)}`);\n    points.push(\"1 100%\");\n    s = `linear(${points.join(\", \")})`;\n    stairs.set(n, s);\n  }\n  return s;\n}\n\n/** A caller's ms, or the token default, on the system's clock. */\nconst clock = (ms: number | undefined, token: string) =>\n  ms === undefined ? token : `calc(${ms}ms * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1))`;\n\n/**\n * Text and numbers that roll into their new value on reels, digit by digit from the right. A number that goes up rolls\n * every reel up and one that goes down rolls them down (9 → 0 on the way up passes through the wrap, like an odometer),\n * so the direction of the change reads before the digits do. A reel ticks through whole numerals, slowing into place, so\n * any frame taken mid-roll reads clean digits; a place the new value no longer has goes at once, and a new place or a\n * changed sign appears whole when its turn in the ripple comes. Reduced motion swaps the value at once. The reels are\n * hidden from assistive tech, which reads the value as text.\n */\nexport function SlotText({\n  value,\n  format,\n  duration,\n  stagger,\n  spins = 0,\n  align: alignProp,\n  announce = false,\n  dir,\n  className,\n  classNames,\n  ref,\n  style,\n}: SlotTextProps) {\n  const locale = useLocale();\n  const scopeDefaults = useComponentDefaults<SlotTextDefaults>(\"slot-text\", [\"align\"]);\n  const align = alignProp ?? scopeDefaults.align ?? \"end\";\n\n  const text =\n    typeof value === \"number\"\n      ? format\n        ? format(value)\n        : numberFormatter(locale, { maximumSignificantDigits: 21 }).format(value)\n      : value;\n  const direction = dir ?? (typeof value === \"number\" || !/\\p{L}/u.test(text) ? \"ltr\" : \"auto\");\n  const host = useRef<HTMLSpanElement>(null);\n  const attach = useComposedRef(host, ref);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = tokens.reduced || managedReduced;\n  // the change is known in the same render it arrives in (no effect-delayed second pass): what it was, which way a\n  // number went, and a run counter that restarts the character fades\n  const [seen, setSeen] = useState({ text, value });\n  const [from, setFrom] = useState(text);\n  const [trend, setTrend] = useState(0);\n  const [run, setRun] = useState(0);\n  if (text !== seen.text) {\n    setSeen({ text, value });\n    setFrom(seen.text);\n    setTrend(typeof value === \"number\" && typeof seen.value === \"number\" ? Math.sign(value - seen.value) : 0);\n    setRun((r) => r + 1);\n  }\n  // when the roll has landed, the columns the old value needed and the new one does not are let go\n  const settle = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  useEffect(() => {\n    if (from === text) return;\n    const s = tokens.duration.considered * 2 + tokens.stagger.word * Math.max(text.length, from.length);\n    const ms = Math.max((duration ?? s * 1000) + (stagger ?? 0) * Math.max(text.length, from.length), 0) + 50;\n    settle.current = setTimeout(() => setFrom(text), reduced ? 0 : ms);\n    return () => clearTimeout(settle.current);\n  }, [from, text, run, duration, stagger, reduced, tokens.duration.considered, tokens.stagger.word]);\n  // a new roll starts from where each reel rests: the resting mark from the last roll is lifted before paint\n  useLayoutEffect(() => {\n    host.current?.querySelectorAll(\"[data-settled]\").forEach((r) => r.removeAttribute(\"data-settled\"));\n  }, [run]);\n  const len = Math.max(text.length, from.length);\n  const pad = (s: string) => (align === \"end\" ? s.padStart(len, \" \") : s.padEnd(len, \" \"));\n  const now = pad(text),\n    was = pad(from);\n  // a reel holds the digits 2·spins + 3 times; it rests on the middle run, and a roll that passes the wrap lands on\n  // the run beside it, from which the reel is quietly reset to the middle once it stops\n  const base = 10 * (spins + 1);\n  const reel = Array.from({ length: 10 * (2 * spins + 3) }, (_, k) => DIGITS[k % 10]);\n  const durationCss = clock(duration, \"calc(var(--pui-duration-considered) * 2)\");\n  const staggerCss = clock(stagger, \"var(--pui-stagger-word)\");\n  const landed = (e: AnimationEvent<HTMLSpanElement>) => {\n    const target = e.target as HTMLElement;\n    if (target.dataset.slot === \"slot-text-reel\") target.setAttribute(\"data-settled\", \"\");\n  };\n  return (\n    <span\n      ref={attach}\n      data-slot=\"slot-text\"\n      data-trend={trend > 0 ? \"up\" : trend < 0 ? \"down\" : undefined}\n      dir={direction}\n      className={cn(\"relative inline-flex tabular-nums [unicode-bidi:isolate]\", classNames?.root, className)}\n      style={style}\n    >\n      <span\n        data-slot=\"slot-text-spoken\"\n        className={cn(\"sr-only\", classNames?.spoken)}\n        aria-live={announce ? \"polite\" : undefined}\n      >\n        {text}\n      </span>\n      <span\n        aria-hidden\n        data-slot=\"slot-text-visual\"\n        className={cn(\"inline-flex overflow-hidden\", classNames?.visual)}\n        onAnimationEnd={landed}\n      >\n        {[...now].map((ch, i) => {\n          const order = align === \"end\" ? len - 1 - i : i;\n          // a column keeps its element while it stays the same distance from the aligned side, so it can roll\n          const key = align === \"end\" ? `e${order}` : `s${order}`;\n          const delay = `calc(${order} * ${staggerCss})`;\n          const old = was[i];\n          // a place the new value no longer has (1,284 → 488: the \"1\" and the \",\"): gone at once, so the number takes\n          // its new width with the change itself and no half-faded digit sits over its neighbour while the reels turn\n          const padding = align === \"end\" ? i < len - text.length : i >= text.length;\n          if (padding) return null;\n          if (DIGITS.includes(ch)) {\n            const d = Number(ch);\n            const before = Number(old);\n            const changed = old !== ch && DIGITS.includes(old) && run > 0;\n            const turns = changed ? spins * 10 : 0;\n            // the way the number went decides the way every reel turns; a text value turns each the short way\n            const to =\n              !changed || reduced\n                ? base + d\n                : trend > 0\n                  ? base + d + (d > before ? 0 : 10) + turns\n                  : trend < 0\n                    ? base + d - (d < before ? 0 : 10) - turns\n                    : base + d + turns;\n            const fresh = !DIGITS.includes(old) && run > 0 && !reduced;\n            const start = changed && !reduced ? base + before : to;\n            const steps = Math.abs(to - start);\n            return (\n              <span\n                key={key}\n                data-slot=\"slot-text-digit\"\n                className={cn(\n                  \"relative inline-block h-[1lh] overflow-hidden\",\n                  fresh && styles.arrive,\n                  classNames?.digit,\n                )}\n                style={fresh ? { animationDuration: delay } : undefined}\n              >\n                <span data-slot=\"slot-text-measure\" className={cn(\"invisible\", classNames?.measure)}>\n                  0\n                </span>\n                <span\n                  data-slot=\"slot-text-reel\"\n                  className={cn(styles.reel, steps > 0 && (run % 2 ? styles.rollA : styles.rollB), classNames?.reel)}\n                  style={\n                    {\n                      \"--_from\": start,\n                      \"--_to\": to,\n                      \"--_rest\": base + d,\n                      transform: \"translateY(calc(var(--_to) * -1lh))\",\n                      ...(run > 0 &&\n                        !reduced && {\n                          animationDuration: durationCss,\n                          animationDelay: delay,\n                          animationTimingFunction: staircase(Math.max(steps, 1)),\n                        }),\n                    } as CSSProperties\n                  }\n                >\n                  {reel.map((n, k) => (\n                    <span key={k} data-slot=\"slot-text-numeral\" className={cn(\"h-[1lh]\", classNames?.numeral)}>\n                      {n}\n                    </span>\n                  ))}\n                </span>\n              </span>\n            );\n          }\n          return (\n            <span\n              key={`${key}-${ch}-${run}`}\n              data-slot=\"slot-text-character\"\n              className={cn(\n                \"inline-block whitespace-pre\",\n                !reduced && old !== ch && run > 0 && styles.arrive,\n                classNames?.character,\n              )}\n              style={{ animationDuration: delay }}\n            >\n              {ch}\n            </span>\n          );\n        })}\n      </span>\n    </span>\n  );\n}\n\nexport default SlotText;\n"},{"path":"beamline/components/spinner/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/defaults.ts","content":"/** Pure allowed presentation defaults for Spinner; no component or runtime imports. */\nexport interface SpinnerDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/spinner/messages.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  label: \"Loading\",\n  done: \"Done\",\n  error: \"Failed\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/spinner/spinner.module.css","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* With a delay the ring waits unseen and then fades in, so quick work never flashes it; the result (a check or a\n     cross) always shows at once. */\n  .root[data-delay] {\n    animation: appear var(--pui-fade-in) var(--pui-ease-default) var(--_delay) both;\n  }\n  @keyframes appear {\n    from {\n      opacity: 0;\n    }\n  }\n  /* The turn runs on the compositor (a CSS animation on the svg box) and costs no JS per frame. When the work ends the\n     arc closes into a full ring over the standard duration, so stopping the turn is invisible, and the check or cross\n     draws itself a beat later. All timing comes from the motion tokens; the loop period follows --pui-motion-scale. */\n  .svg[data-state=\"loading\"] {\n    animation: turn calc(0.8s * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1)) linear infinite;\n  }\n  .track {\n    stroke-opacity: 0.18;\n  }\n  .arc {\n    stroke-dasharray: 0.3 1;\n    transition: stroke-dasharray var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .svg:not([data-state=\"loading\"]) .arc {\n    stroke-dasharray: 1 1;\n  }\n  .mark {\n    stroke-dasharray: 1 1;\n    animation: draw var(--pui-duration-standard) var(--pui-ease-enter) var(--pui-duration-fast) both;\n  }\n  @keyframes turn {\n    to {\n      transform: rotate(1turn);\n    }\n  }\n  @keyframes draw {\n    from {\n      stroke-dashoffset: 1;\n    }\n    to {\n      stroke-dashoffset: 0;\n    }\n  }\n  .svg[data-paused] {\n    animation-play-state: paused;\n  }\n  .svg[data-reduced],\n  .svg[data-reduced] .mark {\n    animation: none;\n  }\n  /* Reduction retains the loading/result shape without a decorative loop. */\n  @media (prefers-reduced-motion: reduce) {\n    .svg[data-state=\"loading\"],\n    .mark {\n      animation: none;\n    }\n  }\n}\n"},{"path":"beamline/components/spinner/spinner.tsx","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SpinnerDefaults } from \"./defaults\";\nexport type { SpinnerDefaults } from \"./defaults\";\n\nimport { useRef, type CSSProperties, type Ref } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useActive } from \"../../hooks/use-active\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./spinner.module.css\";\n\nexport interface SpinnerClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the svg part; no override by default. */\n  svg?: string;\n  /** Classes for the track part; no override by default. */\n  track?: string;\n  /** Classes for the arc part; no override by default. */\n  arc?: string;\n  /** Classes for the mark part; no override by default. */\n  mark?: string;\n}\n\nexport interface SpinnerProps {\n  /** `success` draws a check out of the ring, `error` a cross; both stop spinning. */\n  state?: \"loading\" | \"success\" | \"error\";\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Accessible name while loading. */\n  label?: string;\n  /** ms to wait before it shows: work that ends sooner never flashes a spinner (about 300 is a good start). Default 0. */\n  delay?: number;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLSpanElement>;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: SpinnerClassNames;\n}\n\n/** Icon box from the foundation icon steps (§14.2): sm 14, md 16, lg 20. */\nconst PX = { sm: \"var(--pui-icon-sm)\", md: \"var(--pui-icon-md)\", lg: \"var(--pui-icon-lg)\" } as const;\n\n/**\n * A ring that spins while work runs and closes into a drawn check (or cross) when it ends — the result is a shape as\n * well as a colour. With reduced motion the loading ring holds still.\n */\nexport function Spinner({\n  state = \"loading\",\n  size: sizeProp,\n  label: labelProp,\n  delay = 0,\n  className,\n  classNames,\n  style,\n  ref,\n}: SpinnerProps) {\n  const i18n = useMessages(\"spinner\", englishMessages);\n  const label = labelProp === undefined ? i18n(\"label\") : labelProp;\n\n  const scopeDefaults = useComponentDefaults<SpinnerDefaults>(\"spinner\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n  const host = useRef<HTMLSpanElement>(null);\n  const attach = useComposedRef(host, ref);\n  const active = useActive(host);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = tokens.reduced || managedReduced;\n\n  const box = PX[size];\n  const done = state !== \"loading\";\n  const tone = state === \"success\" ? \"text-green\" : state === \"error\" ? \"text-red\" : \"text-ink-2\";\n  return (\n    <span\n      ref={attach}\n      data-slot=\"spinner\"\n      data-state={state}\n      data-delay={delay > 0 && state === \"loading\" ? \"\" : undefined}\n      role={done ? \"img\" : \"status\"}\n      aria-label={state === \"success\" ? i18n(\"done\") : state === \"error\" ? i18n(\"error\") : label}\n      className={cn(styles.root, \"relative inline-grid shrink-0 place-items-center\", tone, classNames?.root, className)}\n      style={{ width: box, height: box, \"--_delay\": `${delay}ms`, ...style } as CSSProperties}\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        width=\"100%\"\n        height=\"100%\"\n        fill=\"none\"\n        aria-hidden=\"true\"\n        data-state={state}\n        data-slot=\"spinner-svg\"\n        data-reduced={reduced ? \"\" : undefined}\n        data-paused={active ? undefined : \"\"}\n        className={cn(styles.svg, classNames?.svg)}\n      >\n        <circle\n          data-slot=\"spinner-track\"\n          className={cn(styles.track, classNames?.track)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n        />\n        <circle\n          data-slot=\"spinner-arc\"\n          className={cn(styles.arc, classNames?.arc)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          pathLength={1}\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n          strokeLinecap=\"round\"\n          transform=\"rotate(-90 12 12)\"\n        />\n        {state === \"success\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M7.5 12.5l3 3 6-6.5\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        )}\n        {state === \"error\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M8.5 8.5l7 7M15.5 8.5l-7 7\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n          />\n        )}\n      </svg>\n    </span>\n  );\n}\n\nexport default Spinner;\n"},{"path":"beamline/components/textarea/textarea.tsx","type":"registry:file","target":"src/ui/beamline/components/textarea/textarea.tsx","content":"\"use client\";\n\nimport { useResolvedAppearance } from \"../../hooks/use-resolved-appearance\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\n\nimport { useLayoutEffect, useRef, useState, type ReactNode, type Ref, type TextareaHTMLAttributes } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n  Field,\n  FieldCount,\n  FieldFrame,\n  fieldControlClass,\n  useFieldIds,\n  type FieldClassNames,\n} from \"../_shared/field\";\n\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n  label: ReactNode;\n  /** Reports the text after an edit; no callback by default, and native onChange still receives its event. */\n  onValueChange?: (value: string) => void;\n  hideLabel?: boolean;\n  description?: ReactNode;\n  error?: ReactNode;\n  /** Grow with the content between `rows` and `maxRows`, then scroll. Default true. */\n  autoGrow?: boolean;\n  maxRows?: number;\n  /** With `maxLength`: the count at the end of the label's row, quiet (\"58/140\") while the field has attention and in\n   *  words as the limit nears (\"12 left\"). */\n  showCount?: boolean;\n  className?: string;\n  /** Classes for documented field parts; root style does not replace native behavior. */\n  classNames?: FieldClassNames & Partial<Record<\"input\" | \"count\", string>>;\n  ref?: Ref<HTMLTextAreaElement>;\n}\n\n/** Multi-line plain text that grows with what is typed, up to a limit, then scrolls. */\nexport function Textarea({\n  label,\n  hideLabel,\n  description,\n  error,\n  autoGrow = true,\n  rows = 3,\n  maxRows = 12,\n  showCount = false,\n  id,\n  className,\n  classNames,\n  style,\n  required,\n  disabled,\n  readOnly,\n  maxLength,\n  value,\n  defaultValue,\n  onChange,\n  onValueChange,\n  ref,\n  ...props\n}: TextareaProps) {\n  const { controlId, descriptionId, errorId, aria } = useFieldIds(id, { description, error });\n  const own = useRef<HTMLTextAreaElement | null>(null);\n  const attach = useComposedRef(own, ref);\n  // the count follows the text, typed or set by the caller\n  const [typed, setTyped] = useState(String(defaultValue ?? \"\").length);\n  const length = value !== undefined ? String(value).length : typed;\n  // Refit after managed appearance, explicit refresh and relevant font changes.\n  useResolvedAppearance(own);\n  // Browsers without field-sizing grow the box by measuring its scroll height.\n  const fit = () => {\n    const el = own.current;\n    if (!el || !autoGrow || el.ownerDocument.defaultView?.CSS?.supports?.(\"field-sizing\", \"content\")) return;\n    el.style.height = \"auto\";\n    el.style.height = `${Math.min(el.scrollHeight, Number.parseFloat(getComputedStyle(el).maxHeight))}px`;\n  };\n  useLayoutEffect(fit);\n  return (\n    <Field\n      label={label}\n      hideLabel={hideLabel}\n      description={description}\n      error={error}\n      aside={\n        showCount && maxLength != null ? (\n          <FieldCount length={length} max={maxLength} slot=\"textarea-count\" className={classNames?.count} />\n        ) : undefined\n      }\n      required={required}\n      requiredOnControl\n      controlId={controlId}\n      descriptionId={descriptionId}\n      errorId={errorId}\n      className={className}\n      classNames={classNames}\n      style={style}\n    >\n      <FieldFrame classNames={classNames} multiline invalid={!!error} disabled={disabled} readOnly={readOnly}>\n        <textarea\n          ref={attach}\n          data-slot=\"textarea\"\n          {...aria}\n          rows={rows}\n          required={required}\n          disabled={disabled}\n          readOnly={readOnly}\n          maxLength={maxLength}\n          value={value}\n          defaultValue={defaultValue}\n          className={cn(fieldControlClass, classNames?.input)}\n          style={{\n            minHeight: `calc(${rows} * 1lh)`,\n            maxHeight: autoGrow ? `calc(${maxRows} * 1lh)` : undefined,\n            overflowY: \"auto\",\n            fieldSizing: autoGrow ? \"content\" : \"fixed\",\n          }}\n          onChange={(e) => {\n            fit();\n            if (showCount) setTyped(e.target.value.length);\n            onChange?.(e);\n            onValueChange?.(e.target.value);\n          }}\n          {...props}\n        />\n      </FieldFrame>\n    </Field>\n  );\n}\n\nexport default Textarea;\n"},{"path":"beamline/components/tooltip/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/tooltip/defaults.ts","content":"/** Pure allowed presentation defaults for Tooltip; no component or runtime imports. */\nexport interface TooltipDefaults {\n  /** Shared align setting; omit to inherit the surrounding scope or the component default. */\n  align?: \"start\" | \"center\" | \"end\";\n}\n"},{"path":"beamline/components/tooltip/tooltip.module.css","type":"registry:file","target":"src/ui/beamline/components/tooltip/tooltip.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A tooltip arrives from its control: it starts a step toward the control on the side it opens on and moves away\n     into place as it fades in (--pui-motion-in), so on any side it reads as coming out of what it explains. Inside a\n     group, the next one opens at once where the last one stood and travels to its own control (--pui-duration-fast),\n     while the last one goes in the arrival time: one tooltip moving along the toolbar, not two blinking. It leaves\n     over the departure fade. Reduced motion keeps the fades and drops the travel (--pui-motion-travel is 0). */\n  .tip {\n    --_dx: 0px;\n    --_dy: 0px;\n    --_tx: 0px;\n    --_ty: 0px;\n    z-index: var(--pui-z-tooltip);\n    transform-origin: var(--radix-tooltip-content-transform-origin);\n  }\n  .tip[data-side=\"top\"] {\n    --_dy: var(--pui-space-1);\n  }\n  .tip[data-side=\"bottom\"] {\n    --_dy: calc(-1 * var(--pui-space-1));\n  }\n  .tip[data-side=\"left\"] {\n    --_dx: var(--pui-space-1);\n  }\n  .tip[data-side=\"right\"] {\n    --_dx: calc(-1 * var(--pui-space-1));\n  }\n  .tip[data-state=\"delayed-open\"] {\n    animation: tip-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .tip[data-state=\"instant-open\"] {\n    animation: tip-fade var(--pui-fade-in) var(--pui-ease-default);\n  }\n  .tip[data-state=\"instant-open\"][data-travel] {\n    animation: tip-travel var(--pui-duration-fast) var(--pui-ease-standard);\n  }\n  .tip[data-state=\"closed\"] {\n    animation: tip-out var(--pui-fade-out) var(--pui-ease-default) forwards;\n  }\n  .tip[data-grouped][data-state=\"closed\"] {\n    animation-duration: var(--pui-fade-in);\n  }\n}\n@keyframes tip-in {\n  from {\n    opacity: 0;\n    translate: calc(var(--_dx) * var(--pui-motion-travel, 1)) calc(var(--_dy) * var(--pui-motion-travel, 1));\n  }\n}\n@keyframes tip-fade {\n  from {\n    opacity: 0;\n  }\n}\n@keyframes tip-travel {\n  from {\n    translate: calc(var(--_tx) * var(--pui-motion-travel, 1)) calc(var(--_ty) * var(--pui-motion-travel, 1));\n  }\n}\n@keyframes tip-out {\n  to {\n    opacity: 0;\n  }\n}\n"},{"path":"beamline/components/tooltip/tooltip.tsx","type":"registry:file","target":"src/ui/beamline/components/tooltip/tooltip.tsx","content":"\"use client\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { TooltipDefaults } from \"./defaults\";\nexport type { TooltipDefaults } from \"./defaults\";\n\nimport {\n  createContext,\n  useContext,\n  useRef,\n  useState,\n  type CSSProperties,\n  type Ref,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport * as RadixTooltip from \"@radix-ui/react-tooltip\";\nimport { usePortalContainer } from \"../../runtime/appearance\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./tooltip.module.css\";\n\n/** How long after one tooltip of a group closes the next opens at once (and travels from where the last one was). */\nconst SKIP = 400;\n/** The last control of a group whose tooltip showed: its centre and when its tooltip opened or closed. */\ntype Last = { el: Element; x: number; y: number; at: number };\nconst Grouped = createContext<RefObject<Last | null> | null>(null);\n\n/** Tooltips inside a group open instantly one after another (a toolbar): only the first waits, and each next one\n *  travels from where the last one stood. */\nexport function TooltipGroup({\n  children,\n  delay = 500,\n}: {\n  /** Content rendered inside the component. Required. */\n  children: ReactNode;\n  /** Milliseconds before the first tooltip in the group opens. Defaults to 500. */\n  delay?: number;\n}) {\n  const last = useRef<Last | null>(null);\n  return (\n    <RadixTooltip.Provider delayDuration={delay} skipDelayDuration={SKIP}>\n      <Grouped.Provider value={last}>{children}</Grouped.Provider>\n    </RadixTooltip.Provider>\n  );\n}\n\nexport interface TooltipClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the trigger part; no override by default. */\n  trigger?: string;\n  /** Classes for the shortcut part; no override by default. */\n  shortcut?: string;\n}\n\nexport interface TooltipProps {\n  /** What the control does; a few words. */\n  content: ReactNode;\n  /** A keyboard shortcut shown after the text, e.g. \"⌘B\". */\n  shortcut?: string;\n  /** The control: one element that can take a ref (a button). */\n  children: ReactElement;\n  /** Edge from which the panel or anchored content opens. Defaults to \"top\". */\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  /** Alignment within the available space. Defaults to \"center\". */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Open state, controlled or uncontrolled; the tooltip opens on hover and on keyboard focus either way. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Defaults to false. */\n  defaultOpen?: boolean;\n  /** Reports open state after a change; no callback by default. */\n  onOpenChange?: (open: boolean) => void;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: TooltipClassNames;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * Short supporting text for a control: it opens on hover after a moment and at once on keyboard focus, and closes on\n * Escape. It arrives out of its control on whichever side it opens. Inside a TooltipGroup the next one opens instantly\n * and travels from where the last one stood. Never put essential information or anything clickable in a tooltip.\n */\nexport function Tooltip({\n  content,\n  shortcut,\n  children,\n  side = \"top\",\n  align: alignProp,\n  open,\n  defaultOpen,\n  onOpenChange,\n  className,\n  classNames,\n  style,\n  ref,\n}: TooltipProps) {\n  const scopeDefaults = useComponentDefaults<TooltipDefaults>(\"tooltip\", [\"align\"]);\n  const align = alignProp ?? scopeDefaults.align ?? \"center\";\n\n  const group = useContext(Grouped);\n  const container = usePortalContainer();\n  const trigger = useRef<HTMLButtonElement>(null); // the control, whatever element it is (Radix types it as a button)\n  // in a group, how far this tooltip is from the last one, so it can start there and travel to its own control\n  const [travel, setTravel] = useState<{ x: number; y: number } | null>(null);\n  const change = (next: boolean) => {\n    const el = trigger.current;\n    if (group && el) {\n      const r = el.getBoundingClientRect();\n      const x = r.left + r.width / 2;\n      const y = r.top + r.height / 2;\n      const last = group.current;\n      if (next) {\n        const near = last && last.el !== el && performance.now() - last.at < SKIP;\n        setTravel(near ? { x: last.x - x, y: last.y - y } : null);\n        group.current = { el, x, y, at: Infinity };\n      } else if (last?.el === el) last.at = performance.now();\n    }\n    onOpenChange?.(next);\n  };\n  const tip = (\n    <RadixTooltip.Root open={open} defaultOpen={defaultOpen} onOpenChange={change}>\n      <RadixTooltip.Trigger ref={trigger} asChild data-slot=\"tooltip-trigger\" className={classNames?.trigger}>\n        {children}\n      </RadixTooltip.Trigger>\n      {container !== null && (\n        <RadixTooltip.Portal container={container}>\n          <RadixTooltip.Content\n            data-slot=\"tooltip\"\n            data-grouped={group ? \"\" : undefined}\n            data-travel={travel ? \"\" : undefined}\n            style={travel ? ({ \"--_tx\": `${travel.x}px`, \"--_ty\": `${travel.y}px`, ...style } as CSSProperties) : style}\n            ref={ref}\n            side={side}\n            align={align}\n            sideOffset={6}\n            collisionPadding={8}\n            className={cn(\n              \"flex max-w-[min(260px,calc(100vw-16px))] items-center gap-2 rounded-chip bg-tooltip px-2 py-1 text-xs leading-snug text-tooltip-fg shadow-overlay\",\n              styles.tip,\n              classNames?.root,\n              className,\n            )}\n          >\n            {content}\n            {shortcut && (\n              <kbd\n                data-slot=\"tooltip-shortcut\"\n                className={cn(\"font-mono text-2xs text-[var(--pui-tooltip-muted)]\", classNames?.shortcut)}\n              >\n                {shortcut}\n              </kbd>\n            )}\n          </RadixTooltip.Content>\n        </RadixTooltip.Portal>\n      )}\n    </RadixTooltip.Root>\n  );\n  return group ? tip : <RadixTooltip.Provider delayDuration={500}>{tip}</RadixTooltip.Provider>;\n}\n\nexport default Tooltip;\n"},{"path":"beamline/hooks/use-active.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-active.ts","content":"\"use client\";\n\nimport { useContext, useEffect, useState, type RefObject } from \"react\";\n\nimport { ActivityContext } from \"../runtime/activity\";\n\n/**\n * Whether an animated element should be doing work: it is (nearly) on screen and the tab is visible. Every\n * continuously animated component gates its loop, shader or CSS animation on this, so an effect scrolled out of view,\n * behind another tab, or in a collapsed panel costs nothing. One IntersectionObserver per element; no polling.\n */\nexport function useActive(\n  ref: RefObject<Element | null>,\n  {\n    margin = \"120px\",\n    key,\n    idle = true,\n  }: {\n    margin?: string;\n    /** Re-attach when this changes (an element that mounts later). */\n    key?: unknown;\n    /** Pause continuous work at the host idle boundary. Finite sequences set false so they finish. */\n    idle?: boolean;\n  } = {},\n) {\n  const active = useContext(ActivityContext);\n  const [inView, setInView] = useState(false);\n  const [visible, setVisible] = useState(\n    () => typeof document === \"undefined\" || document.visibilityState === \"visible\",\n  );\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { rootMargin: margin });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, margin, key]);\n  useEffect(() => {\n    const on = () => setVisible(document.visibilityState === \"visible\");\n    document.addEventListener(\"visibilitychange\", on);\n    return () => document.removeEventListener(\"visibilitychange\", on);\n  }, []);\n  return (!idle || active) && inView && visible;\n}\n"},{"path":"beamline/hooks/use-apple-platform.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-apple-platform.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { isApplePlatform } from \"../lib/platform\";\n\nconst never = () => () => {};\n\n/** isApplePlatform() for rendering: false on the server and in the first hydrated paint, the real answer after. */\nexport function useApplePlatform() {\n  return useSyncExternalStore(never, isApplePlatform, () => false);\n}\n"},{"path":"beamline/hooks/use-composed-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-composed-ref.ts","content":"\"use client\";\n\nimport { useCallback, type Ref, type RefObject } from \"react\";\n\n/** Keep a control's measurement/focus ref and the caller's ref, including React 19 callback cleanup. */\nexport function useComposedRef<T>(internal: RefObject<T | null>, external?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      internal.current = node;\n      if (typeof external === \"function\") {\n        const cleanup = external(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            internal.current = null;\n            cleanup();\n          };\n      } else if (external) external.current = node;\n    },\n    [internal, external],\n  );\n}\n"},{"path":"beamline/hooks/use-content-width.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-content-width.ts","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState, type RefObject } from \"react\";\n\nconst same = (width: number) => width;\n\n/**\n * The width of an element's content box, kept current with a ResizeObserver. It is first read in a layout effect, so\n * a layout chosen by width (a narrow mode, a chart's scale) is right in the first frame instead of jumping a frame\n * later. `fallback` stands in on the server and until the element exists; `key` re-measures when the element is\n * replaced (one that exists only in one view).\n */\nexport function useContentWidth(ref: RefObject<HTMLElement | null>, fallback: number, key?: unknown): number {\n  return useWidthAs(ref, same, fallback, key);\n}\n\n/**\n * The same width, kept as what a part needs from it (`(w) => w < 640` for a narrow mode): the part renders again only\n * when that changes, not at every pixel.\n */\nexport function useWidthAs<T>(\n  ref: RefObject<HTMLElement | null>,\n  read: (width: number) => T,\n  fallback: T,\n  key?: unknown,\n): T {\n  const [value, setValue] = useState(fallback);\n  const latest = useRef(read);\n  latest.current = read;\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    // the computed width is fractional and ignores transforms, like the observer's content box\n    const s = getComputedStyle(el);\n    const edges = [\"paddingLeft\", \"paddingRight\", \"borderLeftWidth\", \"borderRightWidth\"] as const;\n    const box = parseFloat(s.width);\n    const outer = Number.isFinite(box) ? box : el.offsetWidth;\n    const inner =\n      Number.isFinite(box) && s.boxSizing !== \"border-box\" ? 0 : edges.reduce((w, k) => w + parseFloat(s[k]), 0);\n    setValue(latest.current(outer - inner));\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([e]) => setValue(latest.current(e.contentRect.width)));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref, key]);\n  return value;\n}\n\n/**\n * The height of an element's content box, kept current like `useContentWidth`: for parts that lay out at whatever\n * height their parent gives them (a chart, a map or a book that fills its panel). Read in a layout effect, so the first\n * frame is right; with `enabled` false nothing is measured and the fallback stands.\n */\nexport function useContentHeight(\n  ref: RefObject<HTMLElement | null>,\n  fallback: number,\n  key?: unknown,\n  enabled = true,\n): number {\n  const [value, setValue] = useState(fallback);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!enabled || !el) return;\n    const s = getComputedStyle(el);\n    setValue(el.clientHeight - (parseFloat(s.paddingTop) || 0) - (parseFloat(s.paddingBottom) || 0));\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([e]) => setValue(e.contentRect.height));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref, key, enabled]);\n  return enabled ? value : fallback;\n}\n"},{"path":"beamline/hooks/use-controllable.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-controllable.ts","content":"\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * One value in both forms (rules/CONVENTIONS.md): the prop while it is passed, otherwise the element's own copy that\n * starts at `initial` (a value, or a function that makes it once). The setter takes a value or an updater; an updater\n * reads the latest value even twice in one event, and every change reaches `onChange`, controlled or not.\n * `Next` is what a change can carry when it is narrower than the state: a range that starts empty (null) but is only\n * ever changed to a range.\n */\nexport function useControllable<T, Next extends T = T>(\n  value: T | undefined,\n  initial: T | (() => T),\n  onChange?: (next: Next) => void,\n) {\n  const [own, setOwn] = useState<T>(initial);\n  const current = value !== undefined ? value : own;\n  const latest = useRef(current);\n  latest.current = current;\n  const set = useCallback(\n    (next: Next | ((was: T) => Next)) => {\n      const resolved = typeof next === \"function\" ? (next as (was: T) => Next)(latest.current) : next;\n      latest.current = resolved;\n      if (value === undefined) setOwn(resolved);\n      onChange?.(resolved);\n    },\n    [value, onChange],\n  );\n  return [current, set] as const;\n}\n"},{"path":"beamline/hooks/use-deprecated-prop.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-deprecated-prop.ts","content":"\"use client\";\n\nimport { useEffect } from \"react\";\n\n// Bundlers replace NODE_ENV for browser builds; this declaration emits no Node dependency.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nconst warned = new Set<string>();\n\n/** Report a legacy prop once after mount in development; production and server renders stay silent. */\nexport function useDeprecatedProp(component: string, prop: string, replacement: string, supplied: boolean) {\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"development\" || !supplied) return;\n    const key = `${component}.${prop}`;\n    if (warned.has(key)) return;\n    warned.add(key);\n    console.warn(`[Beamline] ${key} is deprecated. Use ${replacement} instead; the legacy prop still works.`);\n  }, [component, prop, replacement, supplied]);\n}\n"},{"path":"beamline/hooks/use-drag.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-drag.ts","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\nimport { VelocityTracker } from \"../lib/gesture\";\n\n/** Where the pointer is during a drag, relative to where it was pressed, with its velocity (px/s) over the last 100 ms. */\nexport interface DragPoint {\n  /** Pointer position in client coordinates. */\n  x: number;\n  y: number;\n  /** Distance from the press. */\n  dx: number;\n  dy: number;\n  /** Velocity in px/s (read from the last 100 ms, so a release carries the flick, not the whole drag). */\n  vx: number;\n  vy: number;\n  pointerType: string;\n  /** Shift was held (a part may offer a second mode on it: move one task alone, a finer step). */\n  shift: boolean;\n}\n\nexport interface DragHandlers {\n  /** A press landed on the element. Return false to leave it alone (a press on a nested button or link). */\n  onPress?: (point: DragPoint, event: PointerEvent) => boolean | void;\n  /** The pointer moved past the threshold: the drag begins (the press point is in `point.x - point.dx`). */\n  onStart?: (point: DragPoint) => void;\n  /** The pointer moved during a drag. `event` is the browser's own, for what a point does not carry: a pen's pressure,\n   *  the coalesced samples a fast stroke packs into one event (a drawing surface). */\n  onMove?: (point: DragPoint, event: PointerEvent) => void;\n  /** Released after a drag, with the release velocity. */\n  onEnd?: (point: DragPoint) => void;\n  /** Released without moving past the threshold: a tap or click (the part's tap route, WCAG 2.5.7). */\n  onTap?: (point: DragPoint) => void;\n  /** Escape, a lost pointer or the browser taking the gesture (a page scroll): put things back as they were. */\n  onCancel?: () => void;\n  /** A press that ended as neither a drag nor a tap: a finger that went on to scroll the page, or a pointer the browser\n   *  took before the threshold. Undo whatever `onPress` prepared. */\n  onAbandon?: () => void;\n}\n\nexport interface DragOptions {\n  /** The axis the part moves on. On touch, a drag that starts mostly across it is left to the page (a scroll). */\n  axis?: \"x\" | \"y\" | \"both\";\n  /** Pixels before a press becomes a drag: 3 for a mouse, 8 for a finger or pen unless given. */\n  threshold?: number;\n  disabled?: boolean;\n}\n\n/**\n * The pointer side of a hand on a part, shared by everything dragged, thrown or pulled (a thumb, a puck, a stub, a\n * task, a cable). It listens natively on the element, so moves never render React: the part writes positions in its\n * handlers (a spring, a transform, a custom property) and only `dragging`, which changes at the start and end, is\n * state (for `data-state`).\n *\n * A press becomes a drag past a small threshold, then the pointer is captured so the drag continues outside the\n * element; a release without moving is a tap (`onTap`), the route every drag needs for people who cannot drag. Escape,\n * `pointercancel` and a lost capture call `onCancel`, and the part restores what it showed before the press. On touch the\n * element needs a `touch-action` that leaves the other axis to the page (`pan-y` for a part dragged sideways; never\n * `none` on more than the dragged surface, which would block zoom), and a drag that starts across its axis is dropped\n * so the page can scroll.\n */\nexport function useDrag<T extends HTMLElement | SVGElement>(\n  ref: RefObject<T | null>,\n  handlers: DragHandlers,\n  { axis = \"both\", threshold, disabled = false }: DragOptions = {},\n): { dragging: boolean } {\n  const [dragging, setDragging] = useState(false);\n  const latest = useRef(handlers);\n  latest.current = handlers;\n\n  useEffect(() => {\n    // an SVG part (a chart's brush) is dragged the same way; its pointer events are typed as an HTML element's\n    const el = ref.current as HTMLElement | null;\n    if (!el || disabled) return;\n    const doc = el.ownerDocument;\n    // Escape during a drag belongs to the drag: listened on the window's capture phase, it runs before any dialog or\n    // popover listening on the document, which would otherwise also close around the cancelled drag\n    const win = doc.defaultView ?? window;\n    let active: {\n      id: number;\n      x0: number;\n      y0: number;\n      type: string;\n      started: boolean;\n      tracker: VelocityTracker;\n    } | null = null;\n\n    const point = (e: PointerEvent): DragPoint => {\n      const a = active!;\n      const [vx, vy] = a.tracker.velocity(e.timeStamp);\n      return {\n        x: e.clientX,\n        y: e.clientY,\n        dx: e.clientX - a.x0,\n        dy: e.clientY - a.y0,\n        vx,\n        vy,\n        pointerType: e.pointerType,\n        shift: e.shiftKey,\n      };\n    };\n\n    const finish = () => {\n      if (active?.started && el.hasPointerCapture?.(active.id)) el.releasePointerCapture(active.id);\n      active = null;\n      doc.removeEventListener(\"pointermove\", move);\n      doc.removeEventListener(\"pointerup\", up);\n      doc.removeEventListener(\"pointercancel\", cancel);\n      win.removeEventListener(\"keydown\", key, true);\n      doc.removeEventListener(\"selectstart\", hold, true);\n      doc.removeEventListener(\"dragstart\", hold, true);\n      el.removeEventListener(\"lostpointercapture\", lost);\n      setDragging(false);\n    };\n\n    const cancel = () => {\n      if (!active) return;\n      const started = active.started;\n      finish();\n      if (started) latest.current.onCancel?.();\n      else latest.current.onAbandon?.();\n    };\n\n    // only the part's own capture ending cancels: a finger's press is captured by the child it landed on, and moving\n    // the capture to the part fires lostpointercapture on that child, which bubbles here\n    const lost = (e: PointerEvent) => {\n      if (e.target === el && active?.started && e.pointerId === active.id) cancel();\n    };\n\n    const key = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || !active?.started) return;\n      e.preventDefault();\n      e.stopImmediatePropagation();\n      cancel();\n    };\n\n    // while the hand holds the part, the page neither selects text nor starts its own drag and drop (a selection left\n    // by an earlier drag would otherwise be dragged, and the browser cancels the pointer)\n    const hold = (e: Event) => e.preventDefault();\n\n    const move = (e: PointerEvent) => {\n      if (!active || e.pointerId !== active.id) return;\n      active.tracker.add(e.timeStamp, e.clientX, e.clientY);\n      const p = point(e);\n      if (!active.started) {\n        const limit = threshold ?? (active.type === \"mouse\" ? 3 : 8);\n        if (Math.hypot(p.dx, p.dy) < limit) return;\n        // a finger that starts across the part's axis is scrolling the page\n        const across = axis === \"x\" ? Math.abs(p.dy) > Math.abs(p.dx) : axis === \"y\" && Math.abs(p.dx) > Math.abs(p.dy);\n        if (active.type !== \"mouse\" && across) {\n          finish();\n          latest.current.onAbandon?.();\n          return;\n        }\n        active.started = true;\n        doc.getSelection()?.removeAllRanges();\n        el.setPointerCapture?.(active.id);\n        el.addEventListener(\"lostpointercapture\", lost);\n        setDragging(true);\n        latest.current.onStart?.(p);\n      }\n      latest.current.onMove?.(p, e);\n    };\n\n    const up = (e: PointerEvent) => {\n      if (!active || e.pointerId !== active.id) return;\n      active.tracker.add(e.timeStamp, e.clientX, e.clientY);\n      const p = point(e);\n      const started = active.started;\n      finish();\n      if (started) latest.current.onEnd?.(p);\n      else latest.current.onTap?.(p);\n    };\n\n    const down = (e: PointerEvent) => {\n      if (active || !e.isPrimary || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n      const tracker = new VelocityTracker();\n      tracker.add(e.timeStamp, e.clientX, e.clientY);\n      active = { id: e.pointerId, x0: e.clientX, y0: e.clientY, type: e.pointerType, started: false, tracker };\n      if (latest.current.onPress?.(point(e), e) === false) {\n        active = null;\n        return;\n      }\n      doc.addEventListener(\"pointermove\", move);\n      doc.addEventListener(\"pointerup\", up);\n      doc.addEventListener(\"pointercancel\", cancel);\n      win.addEventListener(\"keydown\", key, true);\n      doc.addEventListener(\"selectstart\", hold, true);\n      doc.addEventListener(\"dragstart\", hold, true);\n    };\n\n    el.addEventListener(\"pointerdown\", down);\n    return () => {\n      el.removeEventListener(\"pointerdown\", down);\n      if (active) finish();\n    };\n  }, [ref, axis, threshold, disabled]);\n\n  return { dragging };\n}\n"},{"path":"beamline/hooks/use-flip.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-flip.ts","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, type RefObject } from \"react\";\nimport { useMotionTokens } from \"./use-motion-tokens\";\nimport { cssEase } from \"../lib/motion-tokens\";\n\ntype Seen = { el: HTMLElement; x: number; y: number; w: number; h: number };\n\n/**\n * Items of a collection keep their path (the morph grammar's \"changes keep their size and their path\"): mark each item\n * with `data-flip=\"<stable key>\"` inside `container`. After a change, an item that is new grows in, items that moved\n * slide from where they were, and an item that left fades out as a ghost where it stood (a still copy, inert and\n * hidden from assistive tech, removed when it has faded). The data and focus change at once; only the picture\n * settles. Nothing runs at rest; reduced motion places at once. Positions are kept in the container's own content\n * (relative to it, plus its scroll) and read at rest, so neither a scroll of the page, nor a scroll of the container\n * itself, nor an item still settling reads as movement.\n *\n * `resize`: an item whose box changed size also scales from its old box (for marks without text, a bar or a segment, so a\n * new value glides on transforms instead of laying out every frame). `origin(key)`: an element a new item grows out of\n * (a ribbon's segment becoming a column, a shared chip dividing into pieces); with `originFirst` it applies on the\n * container's first render too, so a panel that opens unfolds its items from what was already on screen. `inner`: items\n * inside another item that lies outside the container (a merge's rows inside a card that moves itself) are this\n * container's own; nesting is judged inside the container only.\n */\nexport function useFlip(\n  container: RefObject<HTMLElement | null> | (() => HTMLElement | null),\n  {\n    ghost = true,\n    enter = true,\n    resize = false,\n    origin,\n    originFirst = false,\n    inner = false,\n  }: {\n    ghost?: boolean;\n    enter?: boolean;\n    resize?: boolean;\n    origin?: (key: string) => Element | null | undefined;\n    originFirst?: boolean;\n    inner?: boolean;\n  } = {},\n) {\n  const get = typeof container === \"function\" ? container : () => container.current;\n  // timing read at the container itself, found when the commit has attached it (a function container has no ref of its\n  // own; without a host the timing stayed unresolved where no UIRoot is around, then rendered again to say so)\n  const host =\n    typeof container === \"function\"\n      ? {\n          get current() {\n            return container();\n          },\n        }\n      : container;\n  const motion = useMotionTokens(host);\n  const last = useRef<Map<string, Seen> | null>(null);\n  const lastWidth = useRef(0);\n  const flying = useRef(new Set<Animation>());\n  // where every item stands, in the container's own content (relative to it, plus its scroll): a list scrolled since the\n  // last render has moved nothing (a row that slid back by the scroll would leave the finger that is tapping it). The\n  // items' own grow-ins and slides still in flight are lifted while they are read, so each box is where the layout puts\n  // it: a render mid-flight (a live price ticking while a new row grows in) is not a move.\n  const read = (root: HTMLElement) => {\n    const lifted = [...flying.current].map((a) => a.effect as KeyframeEffect).map((e) => [e, e.target] as const);\n    for (const [effect] of lifted) effect.target = null;\n    const box = root.getBoundingClientRect();\n    const now = new Map<string, Seen>();\n    for (const el of root.querySelectorAll<HTMLElement>(\"[data-flip]\")) {\n      // only the outermost items move (with `inner`, outermost inside this container)\n      const outer = el.parentElement?.closest(\"[data-flip]\");\n      if (outer && (!inner || (outer !== root && root.contains(outer)))) continue;\n      const r = el.getBoundingClientRect();\n      now.set(el.dataset.flip!, {\n        el,\n        x: r.left - box.left + root.scrollLeft,\n        y: r.top - box.top + root.scrollTop,\n        w: r.width,\n        h: r.height,\n      });\n    }\n    for (const [effect, target] of lifted) effect.target = target;\n    return { box, now };\n  };\n  // a container that changes size at rest, with no render (fonts arriving, a scrollbar appearing, a window resized), has\n  // its places read again then: else the first change after it would take the new width for a reflow and move nothing\n  const watched = useRef<{ el: HTMLElement; observer: ResizeObserver } | null>(null);\n  useLayoutEffect(() => () => watched.current?.observer.disconnect(), []);\n  // every render on purpose: a collection changes through its parent's state, not through anything we can depend on\n  useLayoutEffect(() => {\n    const root = get();\n    // a container that is gone (a panel folded) starts over when it comes back: its items are new, not moved\n    if (!root) {\n      last.current = null;\n      return;\n    }\n    if (watched.current?.el !== root && typeof ResizeObserver !== \"undefined\") {\n      watched.current?.observer.disconnect();\n      const observer = new ResizeObserver(() => {\n        if (flying.current.size || !last.current || !root.isConnected) return;\n        const seen = read(root);\n        last.current = seen.now;\n        lastWidth.current = seen.box.width;\n      });\n      observer.observe(root);\n      watched.current = { el: root, observer };\n    }\n    const { box, now } = read(root);\n    // a container that changed width since the last render reflowed its items by itself (a panel resized, a room\n    // step crossed): that is placement, not a change a person made, so this render records the new places and moves\n    // nothing (a reflow places, only a choice travels)\n    const reflowed = Math.abs(box.width - lastWidth.current) > 0.5;\n    lastWidth.current = box.width;\n    const before = reflowed ? null : last.current;\n    last.current = now;\n    const ms = motion.duration.standard * 1000;\n    // the first render moves nothing, except items that grow out of an origin already on screen\n    if ((!before && !(origin && originFirst)) || motion.reduced || ms < 2) return;\n    const easing = cssEase(motion.ease.standard, \"ease-out\");\n    // an item in flight says so (`data-flip-flight`: \"far\" when it crosses more than its own size, a slide between groups or\n    // a share growing out of another item; \"near\" for a neighbour's step), so a part can lift what travels over other\n    // text onto a surface of its own instead of drawing words over words\n    const fly = (el: HTMLElement, keyframes: Keyframe[], far = false) => {\n      const flight = el.animate(keyframes, { duration: ms, easing });\n      flying.current.add(flight);\n      if (far || el.dataset.flipFlight !== \"far\") el.dataset.flipFlight = far ? \"far\" : \"near\";\n      const land = () => {\n        flying.current.delete(flight);\n        if (![...flying.current].some((a) => (a.effect as KeyframeEffect | null)?.target === el))\n          delete el.dataset.flipFlight;\n      };\n      flight.finished.then(land, land);\n    };\n    const boxOf = (key: string) => {\n      const from = origin?.(key);\n      if (!from || !from.isConnected) return null;\n      const r = from.getBoundingClientRect();\n      return { x: r.left - box.left + root.scrollLeft, y: r.top - box.top + root.scrollTop, w: r.width, h: r.height };\n    };\n    // from a box to where the item stands: translate and scale about the top-left corner\n    const morph = (was: { x: number; y: number; w: number; h: number }, at: Seen): Keyframe[] => [\n      {\n        transformOrigin: \"0 0\",\n        transform: `translate(${was.x - at.x}px, ${was.y - at.y}px) scale(${was.w / Math.max(at.w, 0.5)}, ${was.h / Math.max(at.h, 0.5)})`,\n      },\n      { transformOrigin: \"0 0\", transform: \"none\" },\n    ];\n    for (const [key, at] of now) {\n      const was = before?.get(key);\n      if (!was) {\n        const from = boxOf(key);\n        if (from) fly(at.el, morph(from, at), true);\n        else if (enter && before)\n          fly(at.el, [\n            { opacity: 0, transform: \"scale(0.86)\" },\n            { opacity: 1, transform: \"none\" },\n          ]);\n        continue;\n      }\n      const dx = was.x - at.x,\n        dy = was.y - at.y;\n      const resized = resize && (Math.abs(was.w - at.w) > 0.5 || Math.abs(was.h - at.h) > 0.5);\n      const far = Math.abs(dx) > at.w * 0.5 || Math.abs(dy) > at.h * 1.5;\n      if (resized) fly(at.el, morph(was, at), far);\n      else if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)\n        fly(at.el, [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: \"none\" }], far);\n    }\n    if (!ghost || !before) return;\n    for (const [key, was] of before) {\n      if (now.has(key) || was.el.isConnected) continue;\n      const copy = was.el.cloneNode(true) as HTMLElement;\n      copy.removeAttribute(\"data-flip\");\n      // the ghost is a picture of the item, not the item: nothing that finds parts by slot or id may find it\n      for (const n of [copy, ...copy.querySelectorAll(\"[id], [data-slot]\")]) {\n        n.removeAttribute(\"id\");\n        n.removeAttribute(\"data-slot\");\n      }\n      copy.setAttribute(\"data-ghost\", \"\");\n      copy.setAttribute(\"aria-hidden\", \"true\");\n      copy.inert = true;\n      if (getComputedStyle(root).position === \"static\") root.style.position = \"relative\";\n      Object.assign(copy.style, {\n        position: \"absolute\",\n        left: `${was.x}px`,\n        top: `${was.y}px`,\n        width: `${was.w}px`,\n        height: `${was.h}px`,\n        margin: \"0\",\n        pointerEvents: \"none\",\n      });\n      root.appendChild(copy);\n      // the ghost is gone by the time a neighbour slides into its place, so their texts never mix. The clock runs\n      // straight and an even curve shapes only the fade: the front-loaded standard curve on the whole timeline passed\n      // 0.45 inside the first frame, so what left read as a pop instead of a fade\n      const fade = copy.animate(\n        [\n          { opacity: 1, transform: \"none\", easing: cssEase(motion.ease.default, \"ease-in-out\") },\n          { opacity: 0, transform: \"scale(0.9)\", offset: 0.6 },\n          { opacity: 0, transform: \"scale(0.86)\" },\n        ],\n        {\n          duration: motion.duration.standard * 1000,\n          easing: \"linear\",\n        },\n      );\n      const drop = () => copy.remove();\n      fade.finished.then(drop, drop);\n      // and by the clock as well: a fade that never reports its end (the page hidden, an animation cancelled from\n      // outside) must not leave a copy behind for a person or a test to find\n      setTimeout(drop, motion.duration.standard * 1000 + 250);\n    }\n  });\n}\n"},{"path":"beamline/hooks/use-indicator.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-indicator.ts","content":"\"use client\";\n\nimport { APPEARANCE_EVENT } from \"../lib/motion-tokens\";\nimport { useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from \"react\";\n\nexport interface IndicatorBox {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n  /** False on the first placement, so the indicator appears in place instead of sliding in from the corner. */\n  ready: boolean;\n  /** Which way the last move went on each axis (-1 left or up, 1 right or down, 0 still), for the edge that leads. */\n  dx: -1 | 0 | 1;\n  dy: -1 | 0 | 1;\n  /** choice: a selection changed; layout: the same choice moved because the page reflowed (a resize, a font, a\n   *  type scale). A surface may travel for a choice and should simply be there after a reflow. */\n  cause: \"choice\" | \"layout\";\n}\n\ntype Box = Pick<IndicatorBox, \"x\" | \"y\" | \"w\" | \"h\">;\nconst same = (a: IndicatorBox | null, b: Box) => a !== null && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;\n\n/**\n * Follows the chosen item inside a container: the thumb of a segmented control, the pill or line of tabs, the ring of\n * radio cards. Returns a ref for the container and the box of the first element matching `selector` in it, relative\n * to the container (null when nothing matches). It measures before paint whenever `key` changes, and again when the\n * container resizes or a selection attribute changes inside it (for parts whose choice lives in a library, like tabs).\n */\nexport function useIndicator<T extends HTMLElement>(\n  selector: string,\n  key?: unknown,\n  onPlace?: (item: HTMLElement, container: T) => void,\n  /** offset (default): the item's offset box, for items whose offset parent is the container. rect: measured on screen\n   *  relative to the container, for items nested in positioned cells (a calendar day in its table cell). */\n  measure: \"offset\" | \"rect\" = \"offset\",\n): [RefObject<T | null>, IndicatorBox | null] {\n  const ref = useRef<T>(null);\n  const [box, setBox] = useState<IndicatorBox | null>(null);\n  const placed = useRef(onPlace);\n  useLayoutEffect(() => {\n    placed.current = onPlace;\n  });\n  useLayoutEffect(() => {\n    const container = ref.current;\n    if (!container) return;\n    const place = (cause: IndicatorBox[\"cause\"] = \"choice\") => {\n      const item = container.querySelector<HTMLElement>(selector);\n      if (!item) {\n        setBox(null);\n        return;\n      }\n      let next = { x: item.offsetLeft, y: item.offsetTop, w: item.offsetWidth, h: item.offsetHeight };\n      if (measure === \"rect\") {\n        const a = item.getBoundingClientRect();\n        const c = container.getBoundingClientRect();\n        next = {\n          x: Math.round(a.left - c.left - container.clientLeft + container.scrollLeft),\n          y: Math.round(a.top - c.top - container.clientTop + container.scrollTop),\n          w: item.offsetWidth,\n          h: item.offsetHeight,\n        };\n      }\n      setBox((b) =>\n        same(b, next)\n          ? b\n          : {\n              ...next,\n              cause,\n              ready: b !== null,\n              dx: b ? (Math.sign(next.x - b.x) as IndicatorBox[\"dx\"]) : 0,\n              dy: b ? (Math.sign(next.y - b.y) as IndicatorBox[\"dy\"]) : 0,\n            },\n      );\n      placed.current?.(item, container);\n    };\n    const resizes = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(() => place(\"layout\"));\n    resizes?.observe(container);\n    const observed = new Set<Element>();\n    const observeItems = () => {\n      // A full-width fixed-height list can stay the same size while its tabs/radio choices grow with type or labels.\n      // Ignore the decorative indicator itself, whose own dimensions are the output of this measurement.\n      const items = new Set([...container.children].filter((child) => child.getAttribute(\"aria-hidden\") !== \"true\"));\n      for (const old of observed)\n        if (!items.has(old)) {\n          resizes?.unobserve(old);\n          observed.delete(old);\n        }\n      for (const item of items)\n        if (!observed.has(item)) {\n          resizes?.observe(item);\n          observed.add(item);\n        }\n    };\n    observeItems();\n    place();\n    const mutations = new MutationObserver(() => {\n      observeItems();\n      place(\"choice\");\n    });\n    mutations.observe(container, {\n      subtree: true,\n      childList: true,\n      attributes: true,\n      attributeFilter: [\"data-state\", \"aria-checked\", \"aria-selected\", \"aria-current\", \"data-active\", \"data-open\"],\n    });\n    const reflow = () => place(\"layout\");\n    container.ownerDocument.addEventListener(APPEARANCE_EVENT, reflow);\n    container.ownerDocument.fonts?.addEventListener(\"loadingdone\", reflow);\n    return () => {\n      mutations.disconnect();\n      resizes?.disconnect();\n      container.ownerDocument.removeEventListener(APPEARANCE_EVENT, reflow);\n      container.ownerDocument.fonts?.removeEventListener(\"loadingdone\", reflow);\n    };\n  }, [selector, key, measure]);\n  return [ref, box];\n}\n\n/** The box as custom properties, for a stylesheet to place the indicator with: --pui-indicator-x/-y/-w/-h, and its\n *  four edges --pui-glide-l/-r/-t/-b, which the `glide` class moves one at a time. */\nexport function indicatorStyle(box: IndicatorBox): CSSProperties {\n  return {\n    \"--pui-indicator-x\": `${box.x}px`,\n    \"--pui-indicator-y\": `${box.y}px`,\n    \"--pui-indicator-w\": `${box.w}px`,\n    \"--pui-indicator-h\": `${box.h}px`,\n    \"--pui-glide-l\": `${box.x}px`,\n    \"--pui-glide-r\": `${box.x + box.w}px`,\n    \"--pui-glide-t\": `${box.y}px`,\n    \"--pui-glide-b\": `${box.y + box.h}px`,\n  } as CSSProperties;\n}\n\nconst AXIS = { x: [\"left\", undefined, \"right\"], y: [\"up\", undefined, \"down\"] } as const;\n\n/** Everything an indicator wearing the `glide` class needs (utilities.css): its place, whether it may move yet, and\n *  which way it is going, so the edge on that side leads and the other follows: it stretches toward the new choice and\n *  gathers there. */\nexport function glideProps(box: IndicatorBox) {\n  return {\n    style: indicatorStyle(box),\n    \"data-ready\": box.ready ? \"\" : undefined,\n    \"data-glide-x\": AXIS.x[box.dx + 1],\n    \"data-glide-y\": AXIS.y[box.dy + 1],\n  };\n}\n"},{"path":"beamline/hooks/use-media-query.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-media-query.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// one MediaQueryList per query: React reads the snapshot on every render, and a part that renders thirty times a\n// second (a playing audio player's clock) would otherwise ask the browser for a new list each time\nconst lists = new Map<string, MediaQueryList>();\nconst list = (query: string) => {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return undefined;\n  let m = lists.get(query);\n  if (!m) lists.set(query, (m = window.matchMedia(query)));\n  return m;\n};\n\n/** True while the media query matches; false on the server and in the first client render of SSR output. */\nexport function useMediaQuery(query: string): boolean {\n  return useSyncExternalStore(\n    (notify) => {\n      const m = list(query);\n      m?.addEventListener(\"change\", notify);\n      return () => m?.removeEventListener(\"change\", notify);\n    },\n    () => list(query)?.matches ?? false,\n    () => false,\n  );\n}\n\n/** Phone-width layouts: below 768px. */\nexport const usePhone = () => useMediaQuery(\"(max-width: 767px)\");\n"},{"path":"beamline/hooks/use-motion-tokens.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-motion-tokens.ts","content":"\"use client\";\n\nimport { useMemo, type RefObject } from \"react\";\nimport { useAppearanceRoot } from \"../runtime/appearance\";\nimport { useResolvedAppearance } from \"./use-resolved-appearance\";\nimport { motionAt, motionEase, motionTokens, type MotionTokens } from \"../lib/motion-tokens\";\nimport { semanticMotion } from \"../lib/motion-source\";\n\nconst durationToken = (name: string) =>\n  name === \"exit\" ? \"motion-out\" : name === \"in\" || name === \"out\" ? `motion-${name}` : `duration-${name}`;\nconst easeToken = (name: string) => `ease-${name === \"inOut\" ? \"in-out\" : name}`;\nconst REQUEST = {\n  times: [\n    ...Object.keys(semanticMotion.duration).map(durationToken),\n    ...Object.keys(semanticMotion.spring).map((name) => `spring-${name}`),\n    ...Object.keys(semanticMotion.stagger).map((name) => `stagger-${name}`),\n    \"fade-in\",\n    \"fade-out\",\n  ],\n  easings: [...Object.keys(semanticMotion.ease).map(easeToken), \"ease-spring\", \"ease-spring-bounce\", \"ease-overshoot\"],\n  numbers: [\"motion-scale\", \"motion-look\", \"press-scale\"],\n  // timing only: the host's own type would make every control resolve differently from the page around it\n  typography: false,\n};\n\n/**\n * Resolve each timing at the actual host; omitted host uses the nearest managed root. The first/server snapshot is\n * the pure foundation default. Local CSS values, scope changes, font readiness, OS preference and explicit refresh\n * update the same consumer; no document-wide first-root fallback or persistent global listener.\n */\nexport function useMotionTokens(hostRef?: RefObject<HTMLElement | null>): MotionTokens & {\n  ready: boolean;\n  /** Reduced or none: movement, springs and staggers are instant. */\n  reduced: boolean;\n  mode: \"full\" | \"reduced\" | \"none\";\n  /** Opacity-only arrival and departure (seconds): kept under reduced, instant under none (APPEARANCE A06). */\n  fade: { in: number; out: number };\n} {\n  const root = useAppearanceRoot();\n  const scopeRef = useMemo(() => ({ current: root }), [root]);\n  const resolved = useResolvedAppearance(hostRef ?? scopeRef, REQUEST);\n  return useMemo(() => {\n    if (!resolved.ready)\n      return {\n        ...motionTokens,\n        ready: false,\n        reduced: false,\n        mode: \"full\" as const,\n        fade: { in: motionTokens.duration.in, out: motionTokens.duration.out },\n      };\n    const reduced = resolved.reducedMotion;\n    const speed = (resolved.numbers[\"motion-scale\"] ?? 1) * (resolved.numbers[\"motion-look\"] ?? 1);\n    const fade =\n      resolved.motionMode === \"none\"\n        ? { in: 0.001, out: 0.001 }\n        : {\n            in: resolved.times[\"fade-in\"] ?? semanticMotion.duration.in * speed,\n            out: resolved.times[\"fade-out\"] ?? semanticMotion.duration.out * speed,\n          };\n    const factor = reduced ? 0.001 / semanticMotion.duration.standard : speed;\n    const base = motionAt(factor);\n    const duration = Object.fromEntries(\n      Object.entries(base.duration).map(([name, fallback]) => [\n        name,\n        reduced ? 0.001 : (resolved.times[durationToken(name)] ?? fallback),\n      ]),\n    ) as MotionTokens[\"duration\"];\n    const stagger = Object.fromEntries(\n      Object.entries(base.stagger).map(([name, fallback]) => [\n        name,\n        reduced ? 0 : (resolved.times[`stagger-${name}`] ?? fallback),\n      ]),\n    ) as MotionTokens[\"stagger\"];\n    const ease = Object.fromEntries(\n      Object.entries(base.ease).map(([name, fallback]) => [\n        name,\n        motionEase(resolved.easings[easeToken(name)], fallback),\n      ]),\n    ) as MotionTokens[\"ease\"];\n    const spring = { ...base.spring };\n    for (const name of Object.keys(semanticMotion.spring) as (keyof typeof semanticMotion.spring)[])\n      spring[name] = {\n        ...base.spring[name],\n        visualDuration: reduced ? 0.001 : (resolved.times[`spring-${name}`] ?? base.spring[name].visualDuration),\n        bounce: reduced ? 0 : base.spring[name].bounce,\n      };\n    return {\n      ...base,\n      ready: true,\n      reduced,\n      mode: resolved.motionMode,\n      fade,\n      duration,\n      stagger,\n      ease,\n      spring,\n      pressScale: resolved.numbers[\"press-scale\"] ?? base.pressScale,\n      css: {\n        spring: resolved.easings[\"ease-spring\"] ?? base.css.spring,\n        springBounce: resolved.easings[\"ease-spring-bounce\"] ?? base.css.springBounce,\n        overshoot: resolved.easings[\"ease-overshoot\"] ?? base.css.overshoot,\n      },\n    };\n  }, [resolved]);\n}\n"},{"path":"beamline/hooks/use-reduced-motion-safe.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-reduced-motion-safe.ts","content":"\"use client\";\n\nimport { useMediaQuery } from \"./use-media-query\";\nimport { useScopeMotion } from \"../runtime/appearance\";\n\n/** Managed motion policy plus the OS preference, with a stable server snapshot. */\nexport function useReducedMotionSafe() {\n  const requested = useScopeMotion();\n  const os = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  return requested !== \"auto\" || os;\n}\n"},{"path":"beamline/lib/gesture.ts","type":"registry:file","target":"src/ui/beamline/lib/gesture.ts","content":"import { readMotionFactor } from \"./motion-tokens\";\n\n/**\n * The arithmetic of a hand on a part, shared by every part that is dragged, thrown or pulled (useDrag is the pointer\n * side). Three pieces, each from how real interfaces feel (Apple, \"Designing Fluid Interfaces\", WWDC 2018):\n *\n * - velocity: read from the last ~100 ms of movement, so the release carries the speed of the flick, not of the whole drag;\n * - projection: where a thrown thing would come to rest if it slid on with the look's carry (`project`), so a part\n *   can decide on release (the nearest value, step or preset to that point) instead of after an animation;\n * - give: past a limit the part follows the hand less and less (`rubberband`) instead of stopping dead, and springs back.\n *\n * How much a part carries and gives is the look's material, read once per gesture from the element (`readHand`):\n * `--m-hand-carry`, `--m-hand-give` and `--m-hand-bounce` (preset-source.css), with `--m-morph-weight` scaling how\n * long things take to settle (times a motion token's duration, which carries the speed setting). Reduced motion\n * carries nothing, gives nothing and never bounces.\n */\n\nexport interface Hand {\n  /** How far a release carries: 0 stops where the hand let go, 1 slides on freely (see `project`). */\n  carry: number;\n  /** How much a part follows the hand past a limit: 0 is a hard stop, 0.55 a phone's rubber band. */\n  give: number;\n  /** Bounce of a released held object (0 critically damped). Only for what the pointer held. */\n  bounce: number;\n  /** The look's weight (`--m-morph-weight`): multiply a motion token's duration by it. The token already carries the\n   *  speed setting, so the weight never does. */\n  weight: number;\n  /** Reduced or no motion: land at once. */\n  reduced: boolean;\n}\n\n/** The hand the defaults describe; also what a server or a test without styles gets. */\nexport const DEFAULT_HAND: Hand = { carry: 0.8, give: 0.45, bounce: 0.14, weight: 1, reduced: false };\n\nconst num = (style: CSSStyleDeclaration, name: string, fallback: number) => {\n  const v = Number.parseFloat(style.getPropertyValue(name));\n  return Number.isFinite(v) ? v : fallback;\n};\n\n/** The look's physics at `el`, read once at the start of a gesture (never per frame). */\nexport function readHand(el: Element | null | undefined): Hand {\n  if (!el || typeof getComputedStyle === \"undefined\") return DEFAULT_HAND;\n  const style = getComputedStyle(el);\n  const factor = readMotionFactor(el);\n  // the motion factor is ~0.005 under reduced motion and 1 at the foundation default\n  const reduced = factor < 0.05;\n  if (reduced) return { carry: 0, give: 0, bounce: 0, weight: 1, reduced: true };\n  return {\n    carry: num(style, \"--m-hand-carry\", DEFAULT_HAND.carry),\n    give: num(style, \"--m-hand-give\", DEFAULT_HAND.give),\n    bounce: num(style, \"--m-hand-bounce\", DEFAULT_HAND.bounce),\n    weight: num(style, \"--m-morph-weight\", 1),\n    reduced: false,\n  };\n}\n\n/** Samples of a pointer's path, kept for the last `window` ms, to read its velocity at release. */\nexport class VelocityTracker {\n  private samples: { t: number; x: number; y: number }[] = [];\n  // a declared field, not a parameter property: shipped source must also compile under erasableSyntaxOnly (Vite's\n  // TypeScript template turns it on), where TypeScript-only syntax with runtime meaning is refused\n  private readonly window: number;\n  constructor(window = 100) {\n    this.window = window;\n  }\n\n  add(t: number, x: number, y: number) {\n    this.samples.push({ t, x, y });\n    const from = t - this.window;\n    while (this.samples.length > 2 && this.samples[0].t < from) this.samples.shift();\n  }\n\n  /** Units per second over the kept window; 0 when the hand stopped before letting go (a pause of more than the window). */\n  velocity(now = this.samples.at(-1)?.t ?? 0): [number, number] {\n    const s = this.samples.filter((p) => p.t >= now - this.window);\n    if (s.length < 2) return [0, 0];\n    // least squares slope over the window: one jittery last sample does not decide the throw\n    const n = s.length;\n    const mt = s.reduce((a, p) => a + p.t, 0) / n;\n    const mx = s.reduce((a, p) => a + p.x, 0) / n;\n    const my = s.reduce((a, p) => a + p.y, 0) / n;\n    let tt = 0;\n    let tx = 0;\n    let ty = 0;\n    for (const p of s) {\n      tt += (p.t - mt) ** 2;\n      tx += (p.t - mt) * (p.x - mx);\n      ty += (p.t - mt) * (p.y - my);\n    }\n    if (tt === 0) return [0, 0];\n    return [(tx / tt) * 1000, (ty / tt) * 1000];\n  }\n\n  reset() {\n    this.samples = [];\n  }\n}\n\n/**\n * How far something released at `velocity` (units/s) slides before friction stops it, with the look's carry. `rate` is\n * the speed kept per millisecond: 0.99 (the default) suits controls on a short scale (a thumb, a puck, a cut), where a\n * flick should carry a little way; 0.998 is a phone list's deceleration, for long scrolling content.\n */\nexport function project(velocity: number, carry: number, rate = 0.99): number {\n  if (carry <= 0) return 0;\n  return (((velocity / 1000) * rate) / (1 - rate)) * carry;\n}\n\n/**\n * Where a part drawn `overshoot` units past its limit sits, given the room `extent` it moves in: it follows less and\n * less the further it goes and never reaches `extent`. `give` 0 is a hard stop.\n */\nexport function rubberband(overshoot: number, extent: number, give: number): number {\n  if (give <= 0 || extent <= 0) return 0;\n  const sign = Math.sign(overshoot);\n  const x = Math.abs(overshoot);\n  return sign * (1 - 1 / ((x * give) / extent + 1)) * extent;\n}\n\n/** `value` held inside [min, max], with the part's give past either end. */\nexport function within(value: number, min: number, max: number, extent: number, give: number): number {\n  if (value < min) return min + rubberband(value - min, extent, give);\n  if (value > max) return max + rubberband(value - max, extent, give);\n  return value;\n}\n\n/** The nearest of `stops` to `value` (a projected landing point picks its step or preset this way). */\nexport function nearest(value: number, stops: readonly number[]): number {\n  let best = stops[0];\n  for (const s of stops) if (Math.abs(s - value) < Math.abs(best - value)) best = s;\n  return best;\n}\n"},{"path":"beamline/lib/id.ts","type":"registry:file","target":"src/ui/beamline/lib/id.ts","content":"let sequence = 0;\n\n/** An id for something made here and now (a sent message, a dropped file, a toast, a new rule): unique within the page\n *  and across reloads, short, and safe in a selector. For an id that must match on the server and the client, useId. */\nexport const localId = (prefix: string) => `${prefix}-${Date.now().toString(36)}${(++sequence).toString(36)}`;\n"},{"path":"beamline/lib/platform.ts","type":"registry:file","target":"src/ui/beamline/lib/platform.ts","content":"/** Whether the person is on an Apple platform, where `mod` is ⌘ (Mac, iPhone, iPad); false where there is no browser. */\nexport const isApplePlatform = () =>\n  typeof navigator !== \"undefined\" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);\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":"Document viewer is in src/ui/beamline/components/document-viewer/document-viewer.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/document-viewer"}}