{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"rating","type":"registry:component","title":"Rating","description":"A score on a small scale: stars that fill as you point (halves too) and light one after another when chosen, with the word for the score beside them; read-only it shows an average to the tenth with its count, and nothing rated yet is hatched; or a 0–10 scale with its two ends named for likelihood questions.","categories":["forms"],"dependencies":["@radix-ui/react-direction@1.1.4","@radix-ui/react-label@2.1.15","@radix-ui/react-radio-group@1.4.7","clsx@^2.1.1","lucide-react@^1.48.0","motion@^13.4.4","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/_shared/field.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/field.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A field: label (with an optional aside at the end of its row), the control, then one message line. The gap under\n   the control belongs to the message (--pui-field-gap), so a message that arrives or leaves takes its gap with it. */\n  /* a column, not a grid: a message (or a fold) that leaves pulls its gap away with a negative margin, which a flex\n     column honours and a grid track does not (it would stop at zero and stretch the leaving part back open) */\n  .field {\n    --pui-field-gap: var(--pui-space-1-5);\n    /* positioned: a hidden label or a count's spoken words (sr-only, absolutely placed) stay inside the field */\n    position: relative;\n    display: flex;\n    min-width: 0;\n    flex-direction: column;\n    gap: var(--pui-field-gap);\n  }\n  .labelRow {\n    display: flex;\n    min-width: 0;\n    align-items: baseline;\n    gap: var(--pui-space-2);\n  }\n  .aside {\n    margin-inline-start: auto;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n  }\n\n  /* The message: one line at a time. A new line grows in to its own height (0fr to 1fr, no measuring) while the one it\n   replaces folds away above it, so the height glides from one to the other; a line that leaves with nothing after it\n   takes the gap with it. Arrival over --pui-motion-in, departure over --pui-motion-out; reduced motion keeps the fades. */\n  .messages {\n    display: grid;\n    margin-top: calc(var(--pui-field-gap, var(--pui-space-1-5)) * -1);\n  }\n  .line {\n    display: grid;\n    grid-template-rows: 1fr;\n    opacity: 1;\n    transition:\n      grid-template-rows var(--pui-motion-in) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n  }\n  @starting-style {\n    .line {\n      grid-template-rows: 0fr;\n      opacity: 0;\n    }\n  }\n  /* the line a field is first drawn with does not grow in */\n  .line[data-initial] {\n    transition: none;\n  }\n  /* a swap is one change of size: the old line folds and the new one grows over the same duration and curve, so their\n     sum (the field's height) moves straight from one to the other, with no swell between; the old words fade first */\n  .line[data-leaving] {\n    grid-template-rows: 0fr;\n    opacity: 0;\n    transition:\n      grid-template-rows var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-fade-in, var(--pui-motion-in)) var(--pui-ease-default);\n    pointer-events: none;\n  }\n  .messages:has(> .line[data-leaving]) > .line:not([data-leaving], [data-initial]) {\n    transition-duration: var(--pui-motion-out), var(--pui-fade-in, var(--pui-motion-in));\n  }\n  .clip {\n    min-height: 0;\n    overflow: clip;\n    overflow-clip-margin: var(--pui-space-1);\n  }\n  .message {\n    display: flex;\n    align-items: flex-start;\n    gap: var(--pui-space-1-5);\n    padding-top: var(--pui-field-gap, var(--pui-space-1-5));\n    font-size: var(--pui-text-xs);\n    line-height: 1.375;\n  }\n  .description {\n    display: block;\n    color: var(--pui-ink-3);\n  }\n  .error {\n    color: var(--pui-red);\n  }\n  .success {\n    color: var(--pui-green);\n  }\n  .icon {\n    flex-shrink: 0;\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    margin-top: 1px;\n  }\n\n  /* The count in the label's row: tabular, quiet, shown with attention (.reveal) and in words near the limit. */\n  .count {\n    font-variant-numeric: tabular-nums;\n    white-space: nowrap;\n  }\n  .count[data-state=\"near\"],\n  .count[data-state=\"full\"] {\n    color: var(--pui-orange);\n  }\n  .count[data-state=\"over\"] {\n    color: var(--pui-red);\n  }\n\n  /* The status at the end of the frame: the spinner arrives scaled in from its centre, and closes into the check. */\n  .status {\n    display: inline-grid;\n    flex-shrink: 0;\n    place-items: center;\n    animation: status-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  @keyframes status-in {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.4 * var(--pui-motion-travel, 1));\n    }\n  }\n\n  /* The one frame every text-like control sits in: input, textarea, password, search, number, the select and date\n   triggers. Its paint (fill, edge, hover, focus) is the field role of the look in force (looks.css); invalid,\n   read-only and disabled are drawn here so they read the same in every look. */\n  .frame {\n    --_field-height: var(--pui-control-height-md);\n    position: relative;\n    display: flex;\n    min-width: 0; /* a grid or flex parent may make it narrower than the input's natural width */\n    align-items: center;\n    gap: var(--pui-space-2);\n    min-height: var(--_field-height);\n    padding-inline: var(--pui-space-2-5);\n    border-radius: var(--pui-field-radius, var(--pui-radius-control));\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    cursor: text;\n    transition:\n      box-shadow var(--pui-motion-out) var(--pui-ease-default),\n      background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .frame:hover:not([data-disabled]) {\n    transition-duration: 0ms;\n  }\n  .frame[data-state=\"open\"] {\n    box-shadow: var(--m-field-edge-focus);\n  }\n  .frame:focus-within {\n    transition: none;\n  }\n  .frame[data-disabled] {\n    opacity: var(--pui-opacity-disabled);\n    cursor: not-allowed;\n  }\n  .frame[data-readonly] {\n    --m-field-bg: transparent;\n  }\n  .sm {\n    --_field-height: var(--pui-control-height-sm);\n    padding-inline: var(--pui-space-2);\n  }\n  .lg {\n    --_field-height: var(--pui-control-height-lg);\n    padding-inline: var(--pui-space-3);\n    font-size: var(--pui-text-base);\n  }\n  .multiline {\n    align-items: stretch;\n    padding-block: var(--pui-space-2);\n  }\n\n  /* The control itself: borderless, it takes the frame's look. Focus shows on the frame, so the control's own ring is off\n   (the frame's ring is the visible focus indicator, which is always present when the control has focus). */\n  .control {\n    flex: 1;\n    min-width: 0;\n    height: 100%;\n    min-height: calc(var(--_field-height) - 2px);\n    border: 0;\n    outline: none;\n    background: transparent;\n    color: inherit;\n    font: inherit;\n    letter-spacing: inherit;\n    padding: 0;\n  }\n  .control:focus-visible {\n    outline: none;\n  }\n  .control::placeholder {\n    color: var(--pui-ink-3);\n    opacity: 1;\n  }\n  .control:disabled {\n    cursor: not-allowed;\n  }\n  textarea.control {\n    min-height: 0;\n    resize: none;\n    line-height: 1.5;\n    field-sizing: content;\n  }\n  .adornment {\n    display: inline-flex;\n    align-items: center;\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n  }\n  .adornment :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.frame {\n  composes: m-field nest-corner from global;\n}\n"},{"path":"beamline/components/_shared/field.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/field.tsx","content":"\"use client\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english } from \"./messages\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type Ref,\n  type TransitionEvent,\n} from \"react\";\nimport { AlertCircle, CircleCheck } from \"lucide-react\";\nimport { Label } from \"../label/label\";\nimport { Spinner } from \"../spinner/spinner\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./field.module.css\";\n\nexport type FieldSize = \"sm\" | \"md\" | \"lg\";\nexport type FieldClassNames = Partial<\n  Record<\"root\" | \"label\" | \"frame\" | \"leading\" | \"trailing\" | \"description\" | \"error\" | \"success\" | \"aside\", string>\n>;\n\nexport interface FieldOptions {\n  /** Visible label, also the accessible name. */\n  label?: ReactNode;\n  /** Keep the label for assistive tech only (dense rows, toolbars). */\n  hideLabel?: boolean;\n  /** One line of help under the control. */\n  description?: ReactNode;\n  /** The problem and its fix; replaces the description and marks the control invalid. */\n  error?: ReactNode;\n  /** The confirmation that the value works (\"@mira is available\"): replaces the description, with a check. */\n  success?: ReactNode;\n  required?: boolean;\n  size?: FieldSize;\n}\n\n/** Ids and aria wiring for one field: the control gets these spread onto it. */\nexport function useFieldIds(\n  id?: string,\n  { description, error, success }: Pick<FieldOptions, \"description\" | \"error\" | \"success\"> = {},\n) {\n  const auto = useId();\n  const controlId = id ?? `field-${auto}`;\n  // the line under the field is one of three, and the control points at whichever is there\n  const descriptionId = (success || description) && !error ? `${controlId}-description` : undefined;\n  const errorId = error ? `${controlId}-error` : undefined;\n  return {\n    controlId,\n    descriptionId,\n    errorId,\n    aria: {\n      id: controlId,\n      \"aria-describedby\": [descriptionId, errorId].filter(Boolean).join(\" \") || undefined,\n      \"aria-invalid\": error ? true : undefined,\n      \"aria-errormessage\": errorId,\n    },\n  };\n}\n\n/** Label above (with an optional aside at the end of its row: a counter, a link), control frame, then the message. */\nexport function Field({\n  label,\n  hideLabel,\n  description,\n  error,\n  success,\n  required,\n  controlId,\n  descriptionId,\n  errorId,\n  aside,\n  className,\n  classNames,\n  style,\n  slot = \"field\",\n  requiredOnControl,\n  children,\n}: FieldOptions & {\n  controlId: string;\n  descriptionId?: string;\n  errorId?: string;\n  /** At the end of the label's row: a character count, \"Forgot password?\", \"Optional\". */\n  aside?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  style?: CSSProperties;\n  slot?: string;\n  /** The control carries `required` itself, so the label does not repeat it in the control's name. */\n  requiredOnControl?: boolean;\n  children: ReactNode;\n}) {\n  const labelNode = label != null && (\n    <Label\n      htmlFor={controlId}\n      required={required}\n      requiredOnControl={requiredOnControl}\n      className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n    >\n      {label}\n    </Label>\n  );\n  return (\n    <div\n      data-slot={slot}\n      data-invalid={error ? \"\" : undefined}\n      style={style}\n      className={cn(styles.field, aside != null && \"reveal-host\", classNames?.root, className)}\n    >\n      {aside != null ? (\n        <div data-slot=\"field-label-row\" className={styles.labelRow}>\n          {labelNode}\n          <span data-slot=\"field-aside\" className={cn(styles.aside, classNames?.aside)}>\n            {aside}\n          </span>\n        </div>\n      ) : (\n        labelNode\n      )}\n      {children}\n      <FieldMessage\n        error={error}\n        success={success}\n        description={description}\n        errorId={errorId}\n        descriptionId={descriptionId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n\ntype Tone = \"error\" | \"success\" | \"description\";\ninterface Line {\n  key: string;\n  tone: Tone;\n  content: ReactNode;\n}\nconst ICON = { error: AlertCircle, success: CircleCheck } as const;\n\n/**\n * The line under a field: its problem when there is one, else its confirmation, else its help. Shared by every field\n * and choice group. A change of line keeps its path: the new line grows in under the control while the old one folds\n * away above it, so what sits below glides instead of jumping, and a quick reversal turns around from where it is.\n * The leaving line is a still copy, hidden from assistive tech and inert; ids and aria point only at the line that is\n * there. It is gone when its fold ends, and by the clock as well: a line that leaves in the frame it arrived has no\n * fold to run, so no end is reported.\n */\nexport function FieldMessage({\n  error,\n  success,\n  description,\n  errorId,\n  descriptionId,\n  slot = \"field\",\n  classNames,\n}: Pick<FieldOptions, \"error\" | \"description\" | \"success\"> & {\n  errorId?: string;\n  descriptionId?: string;\n  /** The slot names' prefix: \"checkbox\" names the lines checkbox-error, checkbox-description. */\n  slot?: string;\n  classNames?: Pick<FieldClassNames, \"error\" | \"description\" | \"success\">;\n}) {\n  const tone: Tone | null = error ? \"error\" : success ? \"success\" : description ? \"description\" : null;\n  const content = error || success || description;\n  // a new text is a new line (one problem replacing another also folds and grows); rich content changes in place\n  const text = typeof content === \"string\" || typeof content === \"number\" ? String(content) : \"\";\n  const current: Line | null = tone ? { key: `${tone}\\u0000${text}`, tone, content } : null;\n  const [last, setLast] = useState<Line | null>(current);\n  const [leaving, setLeaving] = useState<Line | null>(null);\n  // the line a field is drawn with is simply there; only a change of line moves\n  const [changed, setChanged] = useState(false);\n  if ((last?.key ?? null) !== (current?.key ?? null)) {\n    // the line that was there leaves (one at a time: a line already leaving is gone at once)\n    setLeaving(last && last.key !== current?.key ? last : null);\n    setLast(current);\n    setChanged(true);\n  }\n  const box = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    if (!leaving) return;\n    // the clock is the leaving line's own longest transition, read once as it starts to leave (the fold's end event\n    // usually comes first), so a field resolves no motion tokens of its own\n    const el = box.current?.querySelector<HTMLElement>(\"[data-leaving]\");\n    const longest = el\n      ? Math.max(\n          0,\n          ...getComputedStyle(el)\n            .transitionDuration.split(\",\")\n            .map((t) => parseFloat(t) * (t.trim().endsWith(\"ms\") ? 1 : 1000) || 0),\n        )\n      : 0;\n    const id = window.setTimeout(() => setLeaving((l) => (l === leaving ? null : l)), longest + 250);\n    return () => window.clearTimeout(id);\n  }, [leaving]);\n  if (!current && !leaving) return null;\n  const shown = leaving && leaving.key === current?.key ? null : leaving;\n  const ids = { error: errorId, success: descriptionId, description: descriptionId };\n  const line = (l: Line, gone: boolean) => {\n    const Icon = l.tone === \"description\" ? null : ICON[l.tone];\n    return (\n      <div\n        key={l.key}\n        data-slot=\"field-message-line\"\n        data-leaving={gone ? \"\" : undefined}\n        data-initial={changed ? undefined : \"\"}\n        aria-hidden={gone || undefined}\n        inert={gone || undefined}\n        className={styles.line}\n        onTransitionEnd={\n          gone\n            ? (e: TransitionEvent<HTMLDivElement>) => {\n                if (e.target === e.currentTarget && e.propertyName === \"grid-template-rows\") setLeaving(null);\n              }\n            : undefined\n        }\n      >\n        <div className={styles.clip}>\n          <p\n            id={gone ? undefined : ids[l.tone]}\n            data-slot={`${slot}-${l.tone}`}\n            className={cn(styles.message, styles[l.tone], classNames?.[l.tone])}\n          >\n            {Icon && <Icon aria-hidden className={styles.icon} />}\n            {l.content}\n          </p>\n        </div>\n      </div>\n    );\n  };\n  return (\n    <div ref={box} data-slot=\"field-message\" className={styles.messages}>\n      {shown && line(shown, true)}\n      {current && line(current, false)}\n    </div>\n  );\n}\n\n/** What the end of a text field says about its work: a spinner while a value is checked, a drawn check when it works. */\nexport type FieldStatus = \"busy\" | \"success\";\n\n/** The frame a text-like control sits in; clicking anywhere on it focuses the control. */\nexport function FieldFrame({\n  size = \"md\",\n  invalid,\n  disabled,\n  readOnly,\n  multiline,\n  status,\n  statusLabel: statusLabelProp,\n  leading,\n  trailing,\n  className,\n  classNames,\n  children,\n  onPointerDown,\n  ref,\n}: {\n  size?: FieldSize;\n  invalid?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  multiline?: boolean;\n  /** busy: a spinner at the end while the value is checked or saved; success: the ring closes into a check. */\n  status?: FieldStatus;\n  /** What the spinner says while busy (\"Checking\", \"Saving\"). */\n  statusLabel?: string;\n  leading?: ReactNode;\n  trailing?: ReactNode;\n  className?: string;\n  classNames?: FieldClassNames;\n  children: ReactNode;\n  onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void;\n  /** The frame itself, for a control that lays out by the frame's width (useWidthAs). */\n  ref?: Ref<HTMLDivElement>;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const statusLabel = statusLabelProp === undefined ? i18n(\"statusLabel\") : statusLabelProp;\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"field-control\"\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-readonly={readOnly ? \"\" : undefined}\n      data-status={status}\n      className={cn(styles.frame, styles[size], multiline && styles.multiline, classNames?.frame, className)}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        // a click on the frame's padding or an adornment that is not itself interactive focuses the control\n        const t = e.target as HTMLElement;\n        if (t === e.currentTarget || (t.closest(\"[data-adornment]\") && !t.closest(\"button, a, input, [role=button]\"))) {\n          e.preventDefault();\n          e.currentTarget.querySelector<HTMLElement>(\"input, textarea\")?.focus();\n        }\n      }}\n    >\n      {leading && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"leading\"\n          className={cn(styles.adornment, classNames?.leading)}\n        >\n          {leading}\n        </span>\n      )}\n      {children}\n      {status && (\n        // one ring for both: it spins while the value is checked and closes into the check when it works\n        <span data-slot=\"field-status\" data-adornment=\"status\" className={styles.status}>\n          <Spinner size=\"sm\" state={status === \"busy\" ? \"loading\" : \"success\"} label={statusLabel} />\n        </span>\n      )}\n      {trailing && (\n        <span\n          data-slot=\"field-adornment\"\n          data-adornment=\"trailing\"\n          className={cn(styles.adornment, classNames?.trailing)}\n        >\n          {trailing}\n        </span>\n      )}\n    </div>\n  );\n}\n\n/**\n * A character count for the label's row (Field `aside`): quiet (\"58/140\") and shown while the field has attention,\n * then always shown and in words as the limit nears (\"12 left\", \"Limit reached\", \"3 over\"), so it never competes with\n * the label at rest. Assistive tech hears it only when it crosses into those words, not at every key.\n */\nexport function FieldCount({\n  length,\n  max,\n  near: nearAt,\n  slot = \"field-count\",\n  className,\n}: {\n  length: number;\n  max: number;\n  /** How many left turns it into words: by default 10 % of the limit, at least 5 (characters); 1 suits a few tags. */\n  near?: number;\n  slot?: string;\n  className?: string;\n}) {\n  const i18n = useMessages(\"_shared\", english);\n  const left = max - length;\n  const near = left <= (nearAt ?? Math.max(5, Math.ceil(max * 0.1)));\n  const state = left < 0 ? \"over\" : left === 0 ? \"full\" : near ? \"near\" : \"room\";\n  const words =\n    state === \"over\"\n      ? i18n(\"fieldOver\", { count: -left })\n      : state === \"full\"\n        ? i18n(\"fieldFull\")\n        : i18n(\"fieldLeft\", { count: left });\n  return (\n    <span\n      data-slot={slot}\n      data-state={state}\n      data-shown={state === \"room\" ? undefined : \"\"}\n      className={cn(\"reveal\", styles.count, className)}\n    >\n      <span aria-hidden>{state === \"room\" ? `${i18n.number(length)}/${i18n.number(max)}` : words}</span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {state === \"room\" ? \"\" : words}\n      </span>\n    </span>\n  );\n}\n\n/** The class a native input or textarea needs inside a FieldFrame. */\nexport const fieldControlClass = styles.control;\n\n/** What a choice group (segmented control, radio cards) spreads onto its own root when it is a form field. */\nexport interface ChoiceFieldAria {\n  \"aria-labelledby\": string;\n  \"aria-describedby\"?: string;\n  \"aria-invalid\"?: true;\n}\n\n/**\n * Whether a choice control (segmented control, radio cards, chip group) is laid out as a form field, its label shown\n * above like every field's: when asked (`field`), when it has a description, error or required mark, and whenever its\n * label is not hidden. A view switch or a quick filter passes `hideLabel`: its label stays for assistive tech only.\n * (Trials t16 and t19: agents wrote <SegmentedControl label=\"Catering needed\" …> and the question never showed.)\n */\nexport function choiceIsField(\n  field: boolean | undefined,\n  label: ReactNode,\n  hideLabel: boolean | undefined,\n  marked: boolean,\n) {\n  return field ?? (marked || (label != null && label !== \"\" && !hideLabel));\n}\n\n/**\n * A choice group as a form field: the visible label above (with the required marker), the group, then its help or its\n * problem — the same frame and message every text field has. Groups that name themselves only for assistive tech\n * (a view switch in a toolbar) do not use it.\n */\nexport function ChoiceField({\n  slot,\n  label,\n  hideLabel,\n  required,\n  description,\n  error,\n  className,\n  style,\n  classNames,\n  requiredOnControl,\n  children,\n}: Omit<FieldOptions, \"size\" | \"success\"> & {\n  slot: string;\n  /** The group carries `aria-required` itself (a radiogroup), so the label does not repeat it in the group's name. */\n  requiredOnControl?: boolean;\n  className?: string;\n  style?: CSSProperties;\n  classNames?: Pick<FieldClassNames, \"label\" | \"description\" | \"error\">;\n  children: (aria: ChoiceFieldAria) => ReactNode;\n}) {\n  const auto = useId();\n  const labelId = `choice-${auto}-label`;\n  const messageId = error || description ? `choice-${auto}-message` : undefined;\n  return (\n    <div data-slot={slot} data-invalid={error ? \"\" : undefined} style={style} className={cn(styles.field, className)}>\n      <Label\n        id={labelId}\n        required={required}\n        requiredOnControl={requiredOnControl}\n        className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n      >\n        {label}\n      </Label>\n      {children({\n        \"aria-labelledby\": labelId,\n        ...(messageId ? { \"aria-describedby\": messageId } : {}),\n        ...(error ? { \"aria-invalid\": true as const } : {}),\n      })}\n      <FieldMessage\n        error={error}\n        description={description}\n        errorId={messageId}\n        descriptionId={messageId}\n        classNames={classNames}\n      />\n    </div>\n  );\n}\n"},{"path":"beamline/components/_shared/messages.ts","type":"registry:file","target":"src/ui/beamline/components/_shared/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy for this helper only. */\nexport const english = {\n  results: {\n    one: \"{count} result\",\n    other: \"{count} results\",\n  },\n  noMatch: \"No match for {query}\",\n  fieldOver: \"{count} over\",\n  fieldFull: \"Limit reached\",\n  fieldLeft: \"{count} left\",\n  foldMore: \"{list} and {count} more\",\n  foldShow: \"Show {count} more {noun}: {list}\",\n  statusLabel: \"Checking\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/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/rating/messages.ts","type":"registry:file","target":"src/ui/beamline/components/rating/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English defaults; instance labels take precedence. */\nexport const english = {\n  noRatingsYet: \"{label}: no ratings yet\",\n  outOf: \"{label}: {value} out of {max}{value2}\",\n  noRatingsYet2: \"No ratings yet\",\n  ratings: \"· {value} ratings\",\n  noRating: \"No rating\",\n  ofStars: \"{value} of {max} stars{value2}\",\n  fromRatings: \", from {value} ratings\",\n  notLikely: \"Not likely\",\n  veryLikely: \"Very likely\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/rating/rating.module.css","type":"registry:file","target":"src/ui/beamline/components/rating/rating.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  .root {\n    --_star: var(--pui-size-5);\n    display: grid;\n    min-width: 0;\n    justify-items: start;\n    gap: var(--pui-space-1-5);\n    container-type: inline-size;\n  }\n  .root[data-size=\"sm\"] {\n    --_star: var(--pui-size-4);\n  }\n  .root[data-size=\"lg\"] {\n    --_star: var(--pui-size-7);\n  }\n  .root[data-variant=\"scale\"] {\n    justify-items: stretch;\n  }\n  .root[data-disabled] {\n    opacity: var(--pui-opacity-disabled);\n  }\n  .row {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    gap: var(--pui-space-1) var(--pui-space-3);\n  }\n\n  /* stars: one track, each star an outline with its filled share laid over it */\n  .track {\n    display: inline-flex;\n    gap: calc(var(--_star) * 0.12);\n    border-radius: var(--pui-radius-chip);\n    outline: none;\n  }\n  .interactive {\n    cursor: pointer;\n    touch-action: manipulation;\n  }\n  .root[data-disabled] .interactive {\n    cursor: not-allowed;\n  }\n  .track:focus-visible {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: 3px;\n  }\n  .root[data-invalid] .track {\n    box-shadow: 0 2px 0 -1px var(--pui-red);\n  }\n  .track[data-unrated] {\n    padding: 2px 4px;\n  }\n  .star {\n    position: relative;\n    display: inline-block;\n    inline-size: var(--_star);\n    block-size: var(--_star);\n  }\n  .outline,\n  .fill {\n    position: absolute;\n    inset: 0;\n    inline-size: 100%;\n    block-size: 100%;\n  }\n  .outline path {\n    fill: color-mix(in oklab, var(--pui-ink-3) 18%, transparent);\n    stroke: var(--pui-ink-3);\n    stroke-width: 1.25;\n    stroke-linejoin: round;\n  }\n  .fill path {\n    fill: var(--pui-orange);\n    stroke: var(--pui-orange);\n    stroke-width: 1.25;\n    stroke-linejoin: round;\n  }\n  /* the filled share is a clip from the start edge: halves and averages to the tenth */\n  .fill {\n    clip-path: inset(0 calc((1 - var(--_f)) * 100%) 0 0);\n  }\n  .fill:dir(rtl) {\n    clip-path: inset(0 0 0 calc((1 - var(--_f)) * 100%));\n  }\n  .track[data-hover] .fill path {\n    fill: color-mix(in oklab, var(--pui-orange) 82%, var(--pui-ink));\n  }\n  /* a choice lights the new stars one after another: each swells a little and settles, no overshoot */\n  .star[data-lit] {\n    animation: light var(--pui-duration-considered) var(--pui-ease-standard) both;\n    animation-delay: calc(var(--_i) * var(--pui-stagger-item) * 2);\n  }\n  @keyframes light {\n    0% {\n      scale: 1;\n    }\n    35% {\n      scale: calc(1 + 0.18 * var(--pui-motion-travel, 1));\n    }\n    100% {\n      scale: 1;\n    }\n  }\n  .word {\n    min-inline-size: 6em;\n    color: var(--pui-ink-2);\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-medium);\n  }\n  .summary {\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-sm);\n    font-variant-numeric: tabular-nums;\n  }\n  .summary strong {\n    color: var(--pui-ink);\n    font-weight: var(--pui-weight-medium);\n  }\n\n  /* scale: numbered cells that share the width, the two ends named under them */\n  .scale {\n    display: grid;\n    inline-size: 100%;\n    gap: var(--pui-space-1-5);\n  }\n  .cells {\n    display: grid;\n    gap: var(--pui-space-1);\n    grid-template-columns: repeat(var(--_n), minmax(var(--pui-size-7), 1fr));\n  }\n  @container (width < 22rem) {\n    .cells {\n      grid-template-columns: repeat(6, minmax(0, 1fr));\n    }\n  }\n  .cell {\n    display: grid;\n    block-size: var(--pui-size-9);\n    place-items: center;\n    border-radius: var(--pui-radius-control);\n    color: var(--pui-ink-2);\n    font-family: var(--pui-font-numeric);\n    font-size: var(--pui-text-sm);\n    font-variant-numeric: tabular-nums;\n    cursor: pointer;\n    transition:\n      var(--m-transition),\n      transform var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .cell[data-state=\"checked\"] {\n    --m-color: var(--pui-control-on);\n    color: var(--pui-control-glyph);\n  }\n  .cell:focus-visible {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: 2px;\n  }\n  .cell:disabled {\n    cursor: not-allowed;\n  }\n  .ends {\n    display: flex;\n    justify-content: space-between;\n    gap: var(--pui-space-3);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n  }\n}\n"},{"path":"beamline/components/rating/rating.tsx","type":"registry:file","target":"src/ui/beamline/components/rating/rating.tsx","content":"\"use client\";\nimport { createFormatters, FALLBACK_LOCALE } from \"../../runtime/messages\";\nimport { useMessages, numberFormatter } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useLocale } from \"../../runtime/localization\";\n\nimport { useDeprecatedProp } from \"../../hooks/use-deprecated-prop\";\n\nimport {\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport * as RadixRadioGroup from \"@radix-ui/react-radio-group\";\nimport { FieldMessage } from \"../_shared/field\";\nimport { Label } from \"../label/label\";\nimport { TextMorph } from \"../text-morph/text-morph\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport styles from \"./rating.module.css\";\n\nexport type RatingClassNames = Partial<\n  Record<\"root\" | \"label\" | \"track\" | \"star\" | \"word\" | \"summary\" | \"cell\" | \"ends\", string>\n>;\n\nexport interface RatingProps {\n  /** Controlled score; null means no rating yet; omit to use defaultValue. */\n  value?: number | null;\n  /** Initial score when uncontrolled. Defaults to null. */\n  defaultValue?: number | null;\n  /** Reports score after an edit; no callback by default. */\n  onValueChange?: (value: number | null) => void;\n  /** Star rating or a numbered scale; defaults to stars. */\n  mode?: \"stars\" | \"scale\";\n  /** Legacy mode setting; omitted by default. @deprecated Use mode instead. */\n  variant?: \"stars\" | \"scale\";\n  /** Stars: how many (default 5). Scale: the top (default 10). */\n  max?: number;\n  /** Scale: the bottom. Default 0. */\n  min?: number;\n  /** Allows choosing a half-star rating. Defaults to false. */\n  allowHalf?: boolean;\n  /** Stars: a word for each whole score, lowest first. */\n  labels?: string[];\n  /** Scale: what the two ends mean. */\n  ends?: [string, string];\n  /** Show a score, such as an average, to the tenth. */\n  readOnly?: boolean;\n  /** Read-only: how many people rated. */\n  count?: number;\n  /** Choosing the current score again clears it. Default true. */\n  clearable?: boolean;\n  /** The question: \"How was this answer?\". */\n  label: string;\n  /** Show the visible label; defaults to true and overrides hideLabel. */\n  showLabel?: boolean;\n  /** Hide the visible label; defaults to false. @deprecated Use showLabel={false}. */\n  hideLabel?: boolean;\n  /** Supporting text beneath the label or heading. Defaults to no supporting text. */\n  description?: string;\n  /** Error message shown in place of the usual supporting text. Defaults to no error message. */\n  error?: string;\n  /** Marks the field as required for form submission. Defaults to false. */\n  required?: boolean;\n  /** Field name used in native form submission. Defaults to no form field name. */\n  name?: string;\n  /** Prevents interaction with the control. Defaults to false. */\n  disabled?: boolean;\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\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?: RatingClassNames;\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// a five-pointed star in a 24-unit box, with softened points\nconst STAR = \"M12 2.6l2.75 5.82 6.37.79-4.7 4.37 1.23 6.3L12 16.77 6.35 19.88l1.23-6.3-4.7-4.37 6.37-.79L12 2.6z\";\nconst one = (v: number, locale = FALLBACK_LOCALE) => createFormatters(locale).number(v, { maximumFractionDigits: 1 });\n\n/**\n * A score on a small scale. Stars fill as the pointer moves (halves too) and light one after another when chosen, with\n * the word for the score beside them; read-only they show an average to the tenth with its count, and nothing rated yet\n * is hatched. The scale variant is a numbered row (0–10 by default) with its two ends named.\n */\nexport function Rating({\n  value,\n  defaultValue = null,\n  onValueChange,\n  mode,\n  variant: variantProp,\n  max: maxProp,\n  min = 0,\n  allowHalf,\n  labels,\n  ends: endsInput,\n  readOnly,\n  count,\n  clearable = true,\n  label,\n  hideLabel: hideLabelProp,\n  showLabel,\n  description,\n  error,\n  required,\n  name,\n  disabled,\n  size = \"md\",\n  className,\n  classNames,\n  style,\n  ref,\n}: RatingProps) {\n  const i18n = useMessages(\"rating\", englishMessages);\n  const ends: [string, string] = endsInput === undefined ? [i18n(\"notLikely\"), i18n(\"veryLikely\")] : endsInput;\n\n  const locale = useLocale();\n\n  useDeprecatedProp(\"rating\", \"variant\", \"mode\", variantProp !== undefined);\n  const variant = mode ?? variantProp ?? \"stars\";\n  useDeprecatedProp(\"Rating\", \"hideLabel\", \"showLabel={false}\", hideLabelProp !== undefined);\n  const hideLabel = showLabel === undefined ? hideLabelProp : !showLabel;\n  const max = maxProp ?? (variant === \"scale\" ? 10 : 5);\n  const [score, setScore] = useControllable<number | null>(value, defaultValue, onValueChange);\n  const [hover, setHover] = useState<number | null>(null);\n  // the stars that light in turn after a choice: those between the old score and the new one\n  const [lit, setLit] = useState<{ from: number; to: number; at: number } | null>(null);\n  const track = useRef<HTMLDivElement>(null);\n  const uid = useId();\n  const labelId = `${uid}-label`;\n  const messageId = error || description ? `${uid}-message` : undefined;\n  const step = allowHalf ? 0.5 : 1;\n\n  const set = (v: number | null) => {\n    const next = v === null ? null : Math.max(variant === \"scale\" ? min : 0, Math.min(max, v));\n    if (next !== null && score !== null && next > score) setLit({ from: score, to: next, at: Date.now() });\n    else if (next !== null && score === null) setLit({ from: 0, to: next, at: Date.now() });\n    else setLit(null);\n    setScore(next);\n  };\n\n  const word = (v: number | null) => (v === null || !labels?.length ? null : labels[Math.max(0, Math.ceil(v) - 1)]);\n\n  const field = (control: ReactNode, wordNode?: ReactNode) => (\n    <div\n      ref={ref}\n      data-slot=\"rating\"\n      data-variant={variant}\n      data-size={size}\n      data-invalid={error ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(styles.root, classNames?.root, className)}\n      style={style}\n    >\n      <Label\n        id={labelId}\n        required={required}\n        className={cn(\"text-label\", hideLabel && \"sr-only\", classNames?.label)}\n        data-slot=\"rating-label\"\n      >\n        {label}\n      </Label>\n      <div className={styles.row}>\n        {control}\n        {wordNode}\n      </div>\n      <FieldMessage\n        error={error}\n        description={description}\n        errorId={error ? messageId : undefined}\n        descriptionId={!error && description ? messageId : undefined}\n        slot=\"rating\"\n      />\n      {name && <input type=\"hidden\" name={name} value={score ?? \"\"} />}\n    </div>\n  );\n\n  // ── scale: a numbered radio group ───────────────────────────────────────────────────────────────────────────────\n  if (variant === \"scale\") {\n    const options = Array.from({ length: max - min + 1 }, (_, i) => min + i);\n    return field(\n      <div className={styles.scale}>\n        <RadixRadioGroup.Root\n          aria-labelledby={labelId}\n          aria-describedby={messageId}\n          aria-invalid={error ? true : undefined}\n          aria-required={required || undefined}\n          orientation=\"horizontal\"\n          disabled={disabled || readOnly}\n          value={score === null ? \"\" : String(score)}\n          onValueChange={(v) => set(Number(v))}\n          className={cn(styles.cells, classNames?.track)}\n          data-slot=\"rating-scale\"\n          style={{ \"--_n\": options.length } as CSSProperties}\n        >\n          {options.map((n) => (\n            <RadixRadioGroup.Item\n              key={n}\n              value={String(n)}\n              aria-label={`${n}${n === min ? `, ${ends[0]}` : n === max ? `, ${ends[1]}` : \"\"}`}\n              data-slot=\"rating-cell\"\n              className={cn(styles.cell, score === n ? \"m-solid\" : \"m-control\", \"ix\", classNames?.cell)}\n            >\n              {n}\n            </RadixRadioGroup.Item>\n          ))}\n        </RadixRadioGroup.Root>\n        <div className={cn(styles.ends, classNames?.ends)} aria-hidden data-slot=\"rating-ends\">\n          <span>{ends[0]}</span>\n          <span>{ends[1]}</span>\n        </div>\n      </div>,\n    );\n  }\n\n  // ── stars ───────────────────────────────────────────────────────────────────────────────────────────────────────\n  const shown = hover ?? score;\n  const unrated = readOnly && score === null;\n  const at = (e: ReactPointerEvent<HTMLElement>) => {\n    const el = track.current;\n    if (!el) return null;\n    const r = el.getBoundingClientRect();\n    const rtl = getComputedStyle(el).direction === \"rtl\";\n    const x = (rtl ? r.right - e.clientX : e.clientX - r.left) / r.width;\n    const raw = Math.max(0, Math.min(1, x)) * max;\n    return Math.max(step, Math.ceil(raw / step) * step);\n  };\n  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    const now = score ?? 0;\n    const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const keys: Record<string, () => void> = {\n      [rtl ? \"ArrowLeft\" : \"ArrowRight\"]: () => set(now + step),\n      ArrowUp: () => set(now + step),\n      [rtl ? \"ArrowRight\" : \"ArrowLeft\"]: () => set(now - step > 0 ? now - step : null),\n      ArrowDown: () => set(now - step > 0 ? now - step : null),\n      Home: () => set(null),\n      End: () => set(max),\n      Backspace: () => set(null),\n      Delete: () => set(null),\n    };\n    const run = keys[e.key] ?? (/^[1-9]$/.test(e.key) && Number(e.key) <= max ? () => set(Number(e.key)) : undefined);\n    if (run) {\n      e.preventDefault();\n      run();\n    }\n  };\n  const valueText =\n    score === null\n      ? i18n(\"noRating\")\n      : i18n(\"ofStars\", { value: one(score, i18n.locale), max: max, value2: word(score) ? `, ${word(score)}` : \"\" });\n\n  const stars = Array.from({ length: max }, (_, i) => {\n    const fillShare = unrated ? 0 : Math.max(0, Math.min(1, (shown ?? 0) - i));\n    const lights = lit && !hover && i + 1 > lit.from && i < lit.to;\n    return (\n      <span\n        // a new light restarts its swell\n        key={lights && lit ? `${i}-${lit.at}` : i}\n        aria-hidden\n        data-slot=\"rating-star\"\n        data-fill={fillShare >= 1 ? \"full\" : fillShare > 0 ? \"part\" : undefined}\n        data-lit={lights ? \"\" : undefined}\n        className={cn(styles.star, classNames?.star)}\n        style={\n          {\n            \"--_f\": fillShare,\n            \"--_i\": lights && lit ? i - Math.floor(lit.from) : 0,\n          } as CSSProperties\n        }\n      >\n        <svg viewBox=\"0 0 24 24\" className={styles.outline}>\n          <path d={STAR} />\n        </svg>\n        <svg viewBox=\"0 0 24 24\" className={styles.fill}>\n          <path d={STAR} />\n        </svg>\n      </span>\n    );\n  });\n\n  if (readOnly)\n    return field(\n      <div\n        role=\"img\"\n        aria-label={\n          unrated\n            ? i18n(\"noRatingsYet\", { label: label })\n            : i18n(\"outOf\", {\n                label: label,\n                value: one(score as number, i18n.locale),\n                max: max,\n                value2:\n                  count !== undefined ? i18n(\"fromRatings\", { value: numberFormatter(locale).format(count) }) : \"\",\n              })\n        }\n        data-slot=\"rating-track\"\n        data-unrated={unrated ? \"\" : undefined}\n        className={cn(styles.track, unrated && \"unmeasured\", classNames?.track)}\n      >\n        {stars}\n      </div>,\n      <span className={cn(styles.summary, classNames?.summary)} data-slot=\"rating-summary\" aria-hidden>\n        {unrated ? (\n          i18n(\"noRatingsYet2\")\n        ) : (\n          <>\n            <strong>{one(score as number, i18n.locale)}</strong>\n            {count !== undefined && <span> {i18n(\"ratings\", { value: numberFormatter(locale).format(count) })}</span>}\n          </>\n        )}\n      </span>,\n    );\n\n  const w = word(shown);\n  return field(\n    <div\n      ref={track}\n      role=\"slider\"\n      tabIndex={disabled ? -1 : 0}\n      aria-labelledby={labelId}\n      aria-describedby={messageId}\n      aria-invalid={error ? true : undefined}\n      aria-required={required || undefined}\n      aria-disabled={disabled || undefined}\n      aria-valuemin={0}\n      aria-valuemax={max}\n      aria-valuenow={score ?? 0}\n      aria-valuetext={valueText}\n      data-slot=\"rating-track\"\n      data-hover={hover !== null ? \"\" : undefined}\n      className={cn(styles.track, styles.interactive, classNames?.track)}\n      onKeyDown={disabled ? undefined : onKey}\n      onPointerMove={(e) => !disabled && e.pointerType === \"mouse\" && setHover(at(e))}\n      onPointerLeave={() => setHover(null)}\n      onClick={(e) => {\n        if (disabled) return;\n        const v = at(e as unknown as ReactPointerEvent<HTMLElement>);\n        setHover(null);\n        set(clearable && v === score ? null : v);\n      }}\n    >\n      {stars}\n    </div>,\n    labels?.length ? (\n      <span className={cn(styles.word, classNames?.word)} data-slot=\"rating-word\" data-empty={w ? undefined : \"\"}>\n        <TextMorph>{w ?? \"\\u00a0\"}</TextMorph>\n      </span>\n    ) : undefined,\n  );\n}\n\nexport default Rating;\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/text-morph/text-morph.tsx","type":"registry:file","target":"src/ui/beamline/components/text-morph/text-morph.tsx","content":"\"use client\";\n\nimport { useMemo, useRef, type CSSProperties, type Ref } from \"react\";\nimport { AnimatePresence, LayoutGroup, motion } from \"motion/react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { useWidthMorph } from \"../../hooks/use-width-morph\";\nimport { cn } from \"../../lib/utils\";\n\nexport interface TextMorphClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the letter part; no override by default. */\n  letter?: string;\n}\n\nexport interface TextMorphProps {\n  /** A short label; when it changes, letters it shares with the old one slide into place and the rest fade. */\n  children: string;\n  /** HTML element used for the text or container. Defaults to \"span\". */\n  as?: \"span\" | \"div\" | \"p\" | \"strong\" | \"h1\" | \"h2\" | \"h3\";\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** DOM id on the animated text root; omitted by default. */\n  id?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: TextMorphClassNames;\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/** A short label that becomes its next state letter by letter: shared letters glide, new ones arrive, old ones leave,\n *  and the label's width eases with them, so whatever sits after it (a button, a counter) glides instead of jumping.\n *  Reduced motion swaps the text. Assistive tech reads the label once, not the letters. */\nexport function TextMorph({ children, as = \"span\", className, classNames, style, ref, id }: TextMorphProps) {\n  const host = useRef<HTMLElement>(null);\n  const attach = useComposedRef(host, ref);\n  const motionTokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = motionTokens.reduced || managedReduced;\n  const Tag = motion[as];\n  useWidthMorph(host, reduced ? null : children);\n  // key each letter by its character and how many times it has appeared so far, so repeats map in order\n  const letters = useMemo(() => {\n    const seen: Record<string, number> = {};\n    return [...children].map((ch) => {\n      seen[ch] = (seen[ch] ?? 0) + 1;\n      return { ch, key: `${ch}-${seen[ch]}` };\n    });\n  }, [children]);\n  // a script written right to left is joined letter to letter and can't be split into glyphs: it swaps whole, like\n  // reduced motion; everything else keeps its own left-to-right letter order even on a right-to-left page\n  const rtlScript = /[\\u0590-\\u08FF\\uFB1D-\\uFDFF\\uFE70-\\uFEFF]/.test(children);\n  if (reduced || rtlScript)\n    return (\n      <Tag ref={attach} id={id} data-slot=\"text-morph\" className={cn(classNames?.root, className)} style={style}>\n        {children}\n      </Tag>\n    );\n  return (\n    <Tag\n      ref={attach}\n      id={id}\n      data-slot=\"text-morph\"\n      aria-label={children}\n      dir=\"ltr\"\n      className={cn(\"inline-flex overflow-x-clip whitespace-pre\", classNames?.root, className)}\n      style={style}\n    >\n      <LayoutGroup>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {letters.map(({ ch, key }) => (\n            <motion.span\n              key={key}\n              aria-hidden\n              layout=\"position\"\n              // the letters move only when the text changes: a re-render with the same words (a scroll, a resize, a\n              // parent's update) leaves them still\n              layoutDependency={children}\n              data-slot=\"text-morph-letter\"\n              className={cn(\"inline-block\", classNames?.letter)}\n              initial={{ opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, y: 3 }}\n              animate={{ opacity: 1, filter: \"blur(0px)\", y: 0 }}\n              exit={{ opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, y: -3 }}\n              transition={motionTokens.spring.morph}\n            >\n              {ch === \" \" ? \" \" : ch}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </LayoutGroup>\n    </Tag>\n  );\n}\n\nexport default TextMorph;\n"},{"path":"beamline/hooks/use-active.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-active.ts","content":"\"use client\";\n\nimport { useContext, useEffect, useState, type RefObject } from \"react\";\n\nimport { ActivityContext } from \"../runtime/activity\";\n\n/**\n * Whether an animated element should be doing work: it is (nearly) on screen and the tab is visible. Every\n * continuously animated component gates its loop, shader or CSS animation on this, so an effect scrolled out of view,\n * behind another tab, or in a collapsed panel costs nothing. One IntersectionObserver per element; no polling.\n */\nexport function useActive(\n  ref: RefObject<Element | null>,\n  {\n    margin = \"120px\",\n    key,\n    idle = true,\n  }: {\n    margin?: string;\n    /** Re-attach when this changes (an element that mounts later). */\n    key?: unknown;\n    /** Pause continuous work at the host idle boundary. Finite sequences set false so they finish. */\n    idle?: boolean;\n  } = {},\n) {\n  const active = useContext(ActivityContext);\n  const [inView, setInView] = useState(false);\n  const [visible, setVisible] = useState(\n    () => typeof document === \"undefined\" || document.visibilityState === \"visible\",\n  );\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { rootMargin: margin });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, margin, key]);\n  useEffect(() => {\n    const on = () => setVisible(document.visibilityState === \"visible\");\n    document.addEventListener(\"visibilitychange\", on);\n    return () => document.removeEventListener(\"visibilitychange\", on);\n  }, []);\n  return (!idle || active) && inView && visible;\n}\n"},{"path":"beamline/hooks/use-composed-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-composed-ref.ts","content":"\"use client\";\n\nimport { useCallback, type Ref, type RefObject } from \"react\";\n\n/** Keep a control's measurement/focus ref and the caller's ref, including React 19 callback cleanup. */\nexport function useComposedRef<T>(internal: RefObject<T | null>, external?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      internal.current = node;\n      if (typeof external === \"function\") {\n        const cleanup = external(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            internal.current = null;\n            cleanup();\n          };\n      } else if (external) external.current = node;\n    },\n    [internal, external],\n  );\n}\n"},{"path":"beamline/hooks/use-controllable.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-controllable.ts","content":"\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * One value in both forms (rules/CONVENTIONS.md): the prop while it is passed, otherwise the element's own copy that\n * starts at `initial` (a value, or a function that makes it once). The setter takes a value or an updater; an updater\n * reads the latest value even twice in one event, and every change reaches `onChange`, controlled or not.\n * `Next` is what a change can carry when it is narrower than the state: a range that starts empty (null) but is only\n * ever changed to a range.\n */\nexport function useControllable<T, Next extends T = T>(\n  value: T | undefined,\n  initial: T | (() => T),\n  onChange?: (next: Next) => void,\n) {\n  const [own, setOwn] = useState<T>(initial);\n  const current = value !== undefined ? value : own;\n  const latest = useRef(current);\n  latest.current = current;\n  const set = useCallback(\n    (next: Next | ((was: T) => Next)) => {\n      const resolved = typeof next === \"function\" ? (next as (was: T) => Next)(latest.current) : next;\n      latest.current = resolved;\n      if (value === undefined) setOwn(resolved);\n      onChange?.(resolved);\n    },\n    [value, onChange],\n  );\n  return [current, set] as const;\n}\n"},{"path":"beamline/hooks/use-deprecated-prop.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-deprecated-prop.ts","content":"\"use client\";\n\nimport { useEffect } from \"react\";\n\n// Bundlers replace NODE_ENV for browser builds; this declaration emits no Node dependency.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nconst warned = new Set<string>();\n\n/** Report a legacy prop once after mount in development; production and server renders stay silent. */\nexport function useDeprecatedProp(component: string, prop: string, replacement: string, supplied: boolean) {\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"development\" || !supplied) return;\n    const key = `${component}.${prop}`;\n    if (warned.has(key)) return;\n    warned.add(key);\n    console.warn(`[Beamline] ${key} is deprecated. Use ${replacement} instead; the legacy prop still works.`);\n  }, [component, prop, replacement, supplied]);\n}\n"},{"path":"beamline/hooks/use-media-query.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-media-query.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// one MediaQueryList per query: React reads the snapshot on every render, and a part that renders thirty times a\n// second (a playing audio player's clock) would otherwise ask the browser for a new list each time\nconst lists = new Map<string, MediaQueryList>();\nconst list = (query: string) => {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return undefined;\n  let m = lists.get(query);\n  if (!m) lists.set(query, (m = window.matchMedia(query)));\n  return m;\n};\n\n/** True while the media query matches; false on the server and in the first client render of SSR output. */\nexport function useMediaQuery(query: string): boolean {\n  return useSyncExternalStore(\n    (notify) => {\n      const m = list(query);\n      m?.addEventListener(\"change\", notify);\n      return () => m?.removeEventListener(\"change\", notify);\n    },\n    () => list(query)?.matches ?? false,\n    () => false,\n  );\n}\n\n/** Phone-width layouts: below 768px. */\nexport const usePhone = () => useMediaQuery(\"(max-width: 767px)\");\n"},{"path":"beamline/hooks/use-motion-tokens.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-motion-tokens.ts","content":"\"use client\";\n\nimport { useMemo, type RefObject } from \"react\";\nimport { useAppearanceRoot } from \"../runtime/appearance\";\nimport { useResolvedAppearance } from \"./use-resolved-appearance\";\nimport { motionAt, motionEase, motionTokens, type MotionTokens } from \"../lib/motion-tokens\";\nimport { semanticMotion } from \"../lib/motion-source\";\n\nconst durationToken = (name: string) =>\n  name === \"exit\" ? \"motion-out\" : name === \"in\" || name === \"out\" ? `motion-${name}` : `duration-${name}`;\nconst easeToken = (name: string) => `ease-${name === \"inOut\" ? \"in-out\" : name}`;\nconst REQUEST = {\n  times: [\n    ...Object.keys(semanticMotion.duration).map(durationToken),\n    ...Object.keys(semanticMotion.spring).map((name) => `spring-${name}`),\n    ...Object.keys(semanticMotion.stagger).map((name) => `stagger-${name}`),\n    \"fade-in\",\n    \"fade-out\",\n  ],\n  easings: [...Object.keys(semanticMotion.ease).map(easeToken), \"ease-spring\", \"ease-spring-bounce\", \"ease-overshoot\"],\n  numbers: [\"motion-scale\", \"motion-look\", \"press-scale\"],\n  // timing only: the host's own type would make every control resolve differently from the page around it\n  typography: false,\n};\n\n/**\n * Resolve each timing at the actual host; omitted host uses the nearest managed root. The first/server snapshot is\n * the pure foundation default. Local CSS values, scope changes, font readiness, OS preference and explicit refresh\n * update the same consumer; no document-wide first-root fallback or persistent global listener.\n */\nexport function useMotionTokens(hostRef?: RefObject<HTMLElement | null>): MotionTokens & {\n  ready: boolean;\n  /** Reduced or none: movement, springs and staggers are instant. */\n  reduced: boolean;\n  mode: \"full\" | \"reduced\" | \"none\";\n  /** Opacity-only arrival and departure (seconds): kept under reduced, instant under none (APPEARANCE A06). */\n  fade: { in: number; out: number };\n} {\n  const root = useAppearanceRoot();\n  const scopeRef = useMemo(() => ({ current: root }), [root]);\n  const resolved = useResolvedAppearance(hostRef ?? scopeRef, REQUEST);\n  return useMemo(() => {\n    if (!resolved.ready)\n      return {\n        ...motionTokens,\n        ready: false,\n        reduced: false,\n        mode: \"full\" as const,\n        fade: { in: motionTokens.duration.in, out: motionTokens.duration.out },\n      };\n    const reduced = resolved.reducedMotion;\n    const speed = (resolved.numbers[\"motion-scale\"] ?? 1) * (resolved.numbers[\"motion-look\"] ?? 1);\n    const fade =\n      resolved.motionMode === \"none\"\n        ? { in: 0.001, out: 0.001 }\n        : {\n            in: resolved.times[\"fade-in\"] ?? semanticMotion.duration.in * speed,\n            out: resolved.times[\"fade-out\"] ?? semanticMotion.duration.out * speed,\n          };\n    const factor = reduced ? 0.001 / semanticMotion.duration.standard : speed;\n    const base = motionAt(factor);\n    const duration = Object.fromEntries(\n      Object.entries(base.duration).map(([name, fallback]) => [\n        name,\n        reduced ? 0.001 : (resolved.times[durationToken(name)] ?? fallback),\n      ]),\n    ) as MotionTokens[\"duration\"];\n    const stagger = Object.fromEntries(\n      Object.entries(base.stagger).map(([name, fallback]) => [\n        name,\n        reduced ? 0 : (resolved.times[`stagger-${name}`] ?? fallback),\n      ]),\n    ) as MotionTokens[\"stagger\"];\n    const ease = Object.fromEntries(\n      Object.entries(base.ease).map(([name, fallback]) => [\n        name,\n        motionEase(resolved.easings[easeToken(name)], fallback),\n      ]),\n    ) as MotionTokens[\"ease\"];\n    const spring = { ...base.spring };\n    for (const name of Object.keys(semanticMotion.spring) as (keyof typeof semanticMotion.spring)[])\n      spring[name] = {\n        ...base.spring[name],\n        visualDuration: reduced ? 0.001 : (resolved.times[`spring-${name}`] ?? base.spring[name].visualDuration),\n        bounce: reduced ? 0 : base.spring[name].bounce,\n      };\n    return {\n      ...base,\n      ready: true,\n      reduced,\n      mode: resolved.motionMode,\n      fade,\n      duration,\n      stagger,\n      ease,\n      spring,\n      pressScale: resolved.numbers[\"press-scale\"] ?? base.pressScale,\n      css: {\n        spring: resolved.easings[\"ease-spring\"] ?? base.css.spring,\n        springBounce: resolved.easings[\"ease-spring-bounce\"] ?? base.css.springBounce,\n        overshoot: resolved.easings[\"ease-overshoot\"] ?? base.css.overshoot,\n      },\n    };\n  }, [resolved]);\n}\n"},{"path":"beamline/hooks/use-reduced-motion-safe.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-reduced-motion-safe.ts","content":"\"use client\";\n\nimport { useMediaQuery } from \"./use-media-query\";\nimport { useScopeMotion } from \"../runtime/appearance\";\n\n/** Managed motion policy plus the OS preference, with a stable server snapshot. */\nexport function useReducedMotionSafe() {\n  const requested = useScopeMotion();\n  const os = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  return requested !== \"auto\" || os;\n}\n"},{"path":"beamline/hooks/use-width-morph.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-width-morph.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\n/**\n * A label whose text changes keeps its path: when `key` changes, the element's width eases from what it was to what the\n * new text needs, so whatever sits after it (a chevron, a counter) glides instead of jumping. The element clips while it\n * eases (give it `overflow-clip whitespace-nowrap`). Nothing runs at rest; reduced motion and motion \"none\" switch at\n * once, and so does a label off screen.\n */\nexport function useWidthMorph(ref: RefObject<HTMLElement | null>, key: unknown) {\n  const motion = useMotionTokens(ref);\n  const last = useRef<number | null>(null);\n  const easing = useRef<Animation | null>(null);\n  // the width it last rested at, kept current when it changes on its own (a web font arriving) so the next ease starts\n  // where the label really is; the ease's own frames are not a resting width\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      if (!easing.current) last.current = el.getBoundingClientRect().width;\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    // mid-way through an earlier change, it starts from where it is now, not from where that change was going\n    const from = easing.current ? el.getBoundingClientRect().width : last.current;\n    easing.current?.cancel();\n    easing.current = null;\n    const box = el.getBoundingClientRect();\n    const to = box.width;\n    last.current = to;\n    const ms = motion.duration.morph * 1000;\n    if (from === null || Math.abs(from - to) < 0.5 || motion.reduced || ms < 2) return;\n    // off screen nobody sees the glide: the label takes its width at once and no frame is spent on it\n    if (box.bottom < 0 || box.top > innerHeight || box.right < 0 || box.left > innerWidth) return;\n    const ease = motion.ease.default;\n    // pixel to pixel: a Web Animations keyframe to \"auto\" (with interpolate-size, or calc-size) jumps straight to the\n    // end in Chromium 153 instead of easing (measured: 537 of 537 px at 300 of 1000 ms); a late change of the natural\n    // width is caught by the observer above when the ease ends\n    const animation = el.animate([{ width: `${from}px` }, { width: `${to}px` }], {\n      duration: ms,\n      // even in and out: the glide never moves a neighbour more than a pixel or two in one frame\n      easing: cssEase(ease, \"ease-in-out\"),\n    });\n    easing.current = animation;\n    void animation.finished.then(\n      () => {\n        if (easing.current !== animation) return;\n        easing.current = null;\n        last.current = el.getBoundingClientRect().width;\n      },\n      () => {},\n    );\n  }, [key]); // eslint-disable-line react-hooks/exhaustive-deps -- eases on a change of the text it stands for\n}\n"},{"path":"beamline/lib/utils.ts","type":"registry:file","target":"src/ui/beamline/lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\n/* The type roles (utilities.css) and the 2xs step are sizes, not text colours: without this, tailwind-merge reads\n   `text-overline` as a colour and drops it next to `text-ink-3`. A role and a size step still replace each other.\n   Likewise `font-title` is the look's title weight, not a family: read as a family it dropped `font-display` beside it,\n   and every merged page title lost the look's display face. */\nconst twMerge = extendTailwindMerge({\n  extend: {\n    classGroups: {\n      \"font-weight\": [{ font: [\"title\"] }],\n      \"font-family\": [{ font: [\"display\", \"numeric\", \"figure\"] }],\n      \"font-size\": [\n        {\n          text: [\n            \"2xs\",\n            \"display\",\n            \"title\",\n            \"heading\",\n            \"subheading\",\n            \"body\",\n            \"body-sm\",\n            \"reading\",\n            \"label\",\n            \"caption\",\n            \"overline\",\n            \"numeric\",\n            \"code\",\n          ],\n        },\n      ],\n    },\n  },\n});\n\n/** Merge class names, letting later Tailwind utilities win over earlier ones. */\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/** A small seeded random generator (LCG): the same seed gives the same sequence, for stable sample data and jitter. */\nexport function rng(seed = 1) {\n  let s = seed >>> 0 || 1;\n  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32;\n}\n\nexport const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n"}],"docs":"Rating is in src/ui/beamline/components/rating/rating.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/rating"}}