{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inline-edit","type":"registry:component","title":"Inline edit","description":"Text, a number or an amount of money that becomes a field in the same place: Enter (or ⌘Enter when multiline) saves, Escape cancels, leaving saves; saving shows progress and a refusal keeps the edit with the reason. InlineNumber reads and writes in the reader's locale (money, units), checks min, max and step, and saves a number; density=\"cell\" fits either in a table cell.","categories":["forms"],"dependencies":["@radix-ui/react-direction@1.1.4","clsx@^2.1.1","lucide-react@^1.48.0","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/inline-edit/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/inline-edit/defaults.ts","content":"/** Pure allowed presentation defaults for InlineEdit; no component or runtime imports. */\nexport interface InlineEditDefaults {\n  /** Shared textStyle setting; omit to inherit the component default. Scoped defaults retain the legacy key variant. */\n  variant?: \"body\" | \"title\";\n}\n"},{"path":"beamline/components/inline-edit/inline-edit.module.css","type":"registry:file","target":"src/ui/beamline/components/inline-edit/inline-edit.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Inline edit: the text and the field it turns into share one box (same padding, same type), so nothing moves when\n   editing starts. Reading is unpainted text that steps one rung on hover and shows its pencil; editing wears the\n   field role of the look, with invalid drawn through the role's inputs like every field (O1 §2). */\n  .display {\n    position: relative;\n    margin-inline: calc(var(--pui-space-1-5) * -1);\n    padding: var(--pui-size-0-5, 0.125rem) var(--pui-space-1-5); /* block = size: the line keeps its height */\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink);\n    transition: background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .display[data-empty] {\n    color: var(--pui-ink-3);\n  }\n  .display:where([role=\"button\"]) {\n    cursor: text;\n  }\n  .display:where([role=\"button\"]):hover {\n    background: var(--pui-hover);\n    transition-duration: 0ms;\n  }\n  /* the pencil is the shared reveal (utilities.css): it arrives with the pointer or focus and is always there on touch */\n  .pencil {\n    display: inline;\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    margin-inline-start: var(--pui-space-1-5);\n    vertical-align: baseline;\n    color: var(--pui-ink-3);\n  }\n  .tail {\n    white-space: nowrap;\n  }\n  /* saved: the text is the new text at once; afterwards a green tint fades from it and a check stands where the pencil\n     was, then the pencil comes back (commit at once, explain after) */\n  .display[data-saved] {\n    animation: saved var(--pui-duration-flash) var(--pui-ease-default);\n  }\n  @keyframes saved {\n    from {\n      background-color: var(--pui-green-tint);\n    }\n  }\n  .saved {\n    color: var(--pui-green);\n    animation: saved-mark var(--pui-motion-in) var(--pui-ease-default);\n  }\n  @keyframes saved-mark {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.4 * var(--pui-motion-travel, 1));\n    }\n  }\n\n  /* a number: tabular figures, so a column of them lines up and a digit typed never shifts the rest */\n  .numeric,\n  .numeric .field {\n    font-variant-numeric: tabular-nums;\n  }\n  [data-align=\"end\"],\n  [data-align=\"end\"] .field {\n    text-align: end;\n  }\n  /* cell: the value fills its cell (a block: the whole cell is the target), the field takes the cell's width */\n  .editor[data-density=\"cell\"] {\n    display: grid;\n    width: 100%;\n    min-width: 0;\n  }\n  .editor[data-density=\"cell\"] .field {\n    min-width: 0;\n  }\n  /* a problem in a cell floats under it, so the row never opens */\n  .editor[data-density=\"cell\"] .problem {\n    position: absolute;\n    z-index: 2;\n    inset-block-start: calc(100% + var(--pui-space-1));\n    inset-inline-end: 0;\n    width: max-content;\n    max-width: 24ch;\n    padding: var(--pui-space-1-5) var(--pui-space-2);\n    border-radius: var(--pui-radius-chip);\n    background: var(--pui-surface);\n    box-shadow:\n      var(--pui-shadow-xs),\n      0 0 0 1px var(--pui-line);\n    text-align: start;\n    contain: none;\n  }\n  .editor[data-density=\"cell\"]:not([data-align=\"end\"]) .problem {\n    inset-inline: 0 auto;\n  }\n\n  .title {\n    /* the heading role: face, size, weight, leading and tracking from the look */\n  }\n  .body {\n    font-size: var(--pui-text-base);\n    line-height: var(--pui-leading-body);\n    letter-spacing: var(--pui-tracking-body);\n  }\n\n  .editor {\n    position: relative;\n    display: grid;\n    gap: var(--pui-space-1-5);\n  }\n  .editor[data-multiline] {\n    width: 100%;\n  }\n  .editor:not([data-multiline]) {\n    display: inline-grid;\n    min-width: min(24ch, 100%); /* room to type, and a message wraps at a readable width; never past a narrow room */\n    max-width: 100%;\n  }\n  .box {\n    position: relative;\n    display: grid;\n  }\n  .field {\n    width: calc(100% + 12px);\n    margin-inline: calc(var(--pui-space-1-5) * -1);\n    padding: var(--pui-size-0-5, 0.125rem) var(--pui-space-1-5); /* block = size: the line keeps its height */\n    border: 0;\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink);\n    outline: none;\n    resize: none;\n    field-sizing: content;\n    transition: var(--m-transition);\n  }\n  .field::placeholder {\n    color: var(--pui-ink-3);\n    opacity: 1;\n  }\n  .field:focus-visible {\n    outline: none; /* the field role's focus edge is the visible indicator */\n  }\n  .field[aria-busy=\"true\"] {\n    padding-inline-end: var(--pui-space-7);\n    color: var(--pui-ink-2);\n  }\n  .spinner {\n    position: absolute;\n    inset-inline-end: 0;\n    inset-block: 0;\n    display: grid;\n    place-items: center;\n  }\n\n  .hint,\n  .problem {\n    font-size: var(--pui-text-xs);\n    line-height: 1.35;\n    contain: inline-size; /* the message wraps to the field's width instead of widening it */\n  }\n  .hint {\n    color: var(--pui-ink-3);\n    unicode-bidi: plaintext; /* \"⌘Enter saves\" keeps its order in a right-to-left page */\n  }\n  .problem {\n    display: flex;\n    align-items: flex-start;\n    gap: var(--pui-space-1-5);\n    color: var(--pui-red);\n    animation: problem-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  @keyframes problem-in {\n    from {\n      opacity: 0;\n      translate: 0 calc(-2px * var(--pui-motion-travel, 1));\n    }\n  }\n  .problem svg {\n    flex-shrink: 0;\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    margin-top: calc(var(--pui-space-0-5) / 2);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.title {\n  composes: text-heading from global;\n}\n.field {\n  composes: m-field from global;\n}\n"},{"path":"beamline/components/inline-edit/inline-edit.tsx","type":"registry:file","target":"src/ui/beamline/components/inline-edit/inline-edit.tsx","content":"\"use client\";\n\nimport { numberFormatter, useLocale, useMessages } from \"../../runtime/localization\";\nimport { parseLocaleNumber } from \"../../lib/number\";\nimport { english as englishMessages } from \"./messages\";\nimport { useDeprecatedProp } from \"../../hooks/use-deprecated-prop\";\n\nimport type { CSSProperties, Ref } from \"react\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { InlineEditDefaults } from \"./defaults\";\nimport { cssEase } from \"../../lib/motion-tokens\";\nexport type { InlineEditDefaults } from \"./defaults\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type AnimationEvent,\n  type ElementType,\n  type KeyboardEvent,\n} from \"react\";\nimport { AlertCircle, Check, Pencil } from \"lucide-react\";\nimport { Spinner } from \"../spinner/spinner\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport styles from \"./inline-edit.module.css\";\n\nexport type InlineEditClassNames = Partial<\n  Record<\"root\" | \"tail\" | \"pencil\" | \"box\" | \"spinner\" | \"problem\" | \"hint\" | \"field\", string>\n>;\n\nexport interface InlineEditProps {\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: InlineEditClassNames;\n  /** Controlled committed text; omitted by default to keep accepted edits locally. */\n  value?: string;\n  /** Initial committed text when uncontrolled; defaults to an empty string. */\n  defaultValue?: string;\n  /** Reports committed text after onSave succeeds; no callback by default. */\n  onValueChange?: (value: string) => void;\n  /** Saves the new text; return a promise to show progress, throw to keep editing with the message. */\n  onSave: (next: string) => void | Promise<unknown>;\n  /** What is being edited, for assistive tech: \"Report title\". */\n  label: string;\n  /** Say what is wrong with the text, or nothing to accept it. */\n  validate?: (next: string) => string | null | undefined;\n  /** Text shown before a value is entered or selected. Defaults to \"Empty\". */\n  placeholder?: string;\n  /** Wrap and allow line breaks (⌘/Ctrl+Enter saves). */\n  multiline?: boolean;\n  /** Title or body text treatment; defaults to body. */\n  textStyle?: \"title\" | \"body\";\n  /** Legacy textStyle setting; omitted by default. @deprecated Use textStyle instead. */\n  variant?: \"title\" | \"body\";\n  /** The element the text renders as. Default span (title: h2). */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"p\" | \"span\";\n  /** Prevents interaction with the control. Defaults to false. */\n  disabled?: boolean;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the display text or editing root; no ref by default. */\n  ref?: Ref<HTMLElement>;\n  /** Receives the native field; no ref by default. */\n  fieldRef?: Ref<HTMLInputElement | HTMLTextAreaElement>;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /**\n   * text (default): a value in running text or a heading, with its pencil and the \"Enter saves\" hint. cell: a value in a\n   * table cell or a dense row: fills the cell, no pencil and no hint (the hover tint says it edits), and a problem floats\n   * under the cell instead of pushing the row open.\n   */\n  density?: \"text\" | \"cell\";\n  /** Where the text sits in its box: start (default) or end (numbers in a column). */\n  align?: \"start\" | \"end\";\n  /** The text shown for the committed value (a price written as money); the value itself by default. */\n  format?: (value: string) => string;\n  /** The text the field starts with when editing begins (a price without its currency sign); the value by default. */\n  toDraft?: (value: string) => string;\n  /** Turns the typed text, once validate accepts it, into the value saved (a canonical number); the text by default. */\n  parse?: (draft: string) => string;\n  /** The on-screen keyboard to offer (decimal or numeric for numbers); text by default. */\n  inputMode?: \"text\" | \"decimal\" | \"numeric\";\n}\n\n/**\n * Text that becomes a field in the same place, without moving: click it or press Enter to edit, Enter (or ⌘Enter\n * when multiline) saves, Escape cancels, leaving saves. Saving shows progress; a failure keeps the edit and says why.\n */\nexport function InlineEdit({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onSave,\n  label,\n  validate,\n  placeholder: placeholderProp,\n  multiline = false,\n  textStyle: textStyleProp,\n  variant: variantProp,\n  as,\n  disabled,\n  className,\n  classNames,\n  style,\n  ref,\n  fieldRef,\n  density = \"text\",\n  align = \"start\",\n  format,\n  toDraft,\n  parse,\n  inputMode,\n}: InlineEditProps) {\n  const i18n = useMessages(\"inline-edit\", englishMessages);\n  const placeholder = placeholderProp === undefined ? i18n(\"placeholder\") : placeholderProp;\n\n  const scopeDefaults = useComponentDefaults<InlineEditDefaults>(\"inline-edit\", [\"variant\"]);\n  useDeprecatedProp(\n    \"inline-edit\",\n    \"variant\",\n    \"textStyle\",\n    variantProp !== undefined || (textStyleProp === undefined && scopeDefaults.variant !== undefined),\n  );\n  const variant = textStyleProp ?? variantProp ?? scopeDefaults.variant ?? \"body\";\n\n  const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange);\n  const [editing, setEditing] = useState(false);\n  const draftOf = (v: string) => (toDraft && v ? toDraft(v) : v);\n  const [draft, setDraft] = useState(() => draftOf(value));\n  const [state, setState] = useState<\"idle\" | \"saving\">(\"idle\");\n  const [problem, setProblem] = useState<string | null>(null);\n  // a save that worked leaves an afterglow on the text: a check where the pencil was and a tint that fades\n  const [saved, setSaved] = useState(0);\n  const field = useRef<HTMLTextAreaElement & HTMLInputElement>(null);\n  const display = useRef<HTMLElement>(null);\n  const attach = useComposedRef(display, ref);\n  const attachField = useComposedRef<HTMLInputElement | HTMLTextAreaElement>(field, fieldRef);\n  const problemId = useId();\n  // the text becomes the field in place: the frame grows from the text's width to the field's, and shrinks back to\n  // the text when the edit ends; the words themselves never move\n  const motion = useMotionTokens(display);\n  const swapFrom = useRef<{ w: number; h: number } | null>(null);\n  const switchTo = (next: boolean) => {\n    const el = display.current;\n    swapFrom.current = el ? { w: el.offsetWidth, h: el.offsetHeight } : null;\n    setEditing(next);\n  };\n  useLayoutEffect(() => {\n    const el = display.current,\n      from = swapFrom.current;\n    swapFrom.current = null;\n    if (!el || !from || motion.reduced) return;\n    const ease = motion.ease.standard;\n    const timing = {\n      duration: motion.duration.considered * 1000 * 0.6,\n      easing: cssEase(ease, undefined),\n    };\n    const w = el.offsetWidth,\n      h = el.offsetHeight;\n    if (Math.abs(w - from.w) > 1 || Math.abs(h - from.h) > 1)\n      el.animate(\n        [\n          { width: `${from.w}px`, height: `${from.h}px`, overflow: \"clip\" },\n          { width: `${w}px`, height: `${h}px`, overflow: \"clip\" },\n        ],\n        timing,\n      );\n    el.querySelector(\"[data-slot=inline-edit-hint]\")?.animate(\n      [\n        { opacity: 0, transform: \"translateY(-4px)\" },\n        { opacity: 1, transform: \"none\" },\n      ],\n      timing,\n    );\n  }, [editing]); // eslint-disable-line react-hooks/exhaustive-deps -- only a swap of modes morphs\n  const Tag = (as ?? (variant === \"title\" ? \"h2\" : \"span\")) as ElementType;\n  useEffect(() => {\n    if (!editing) setDraft(draftOf(value));\n  }, [value, editing]); // eslint-disable-line react-hooks/exhaustive-deps -- toDraft is read with the value it shapes\n  useLayoutEffect(() => {\n    if (editing) {\n      field.current?.focus();\n      field.current?.select();\n    }\n  }, [editing]);\n\n  const type = variant === \"title\" ? styles.title : styles.body;\n  const cell = density === \"cell\";\n  const shown = (value && format ? format(value) : value) || placeholder;\n  const cut = shown.search(/\\S+\\s*$/);\n  const lead = cut > 0 ? shown.slice(0, cut) : \"\";\n  const tail = cut > 0 ? shown.slice(cut) : shown;\n  // focus goes back to the value once it shows again, unless it has already moved on to another control (tabbing on,\n  // a click into the next field: focusing the value then would blur that field, which saves and closes it)\n  const focusBack = () =>\n    requestAnimationFrame(() => {\n      const now = document.activeElement;\n      if (now && now !== document.body && now.isConnected && !display.current?.contains(now)) return;\n      display.current?.focus();\n    });\n  const save = async () => {\n    if (state === \"saving\") return;\n    const typed = multiline ? draft : draft.trim();\n    if (typed === draftOf(value)) {\n      switchTo(false);\n      setProblem(null);\n      return;\n    }\n    const p = validate?.(typed);\n    if (p) {\n      setProblem(p);\n      field.current?.focus();\n      return;\n    }\n    const next = parse ? parse(typed) : typed;\n    if (next === value) {\n      switchTo(false);\n      setProblem(null);\n      return;\n    }\n    setState(\"saving\");\n    try {\n      await onSave(next);\n      setValue(next);\n      switchTo(false);\n      setProblem(null);\n      setSaved((n) => n + 1);\n      focusBack();\n    } catch (e) {\n      setProblem(e instanceof Error ? e.message : i18n(\"notSavedTryAgain\"));\n      field.current?.focus();\n    } finally {\n      setState(\"idle\");\n    }\n  };\n  const cancel = () => {\n    setDraft(draftOf(value));\n    setProblem(null);\n    switchTo(false);\n    focusBack();\n  };\n\n  if (!editing) {\n    return (\n      <Tag\n        ref={attach}\n        style={style}\n        data-slot=\"inline-edit\"\n        tabIndex={disabled ? undefined : 0}\n        role={disabled ? undefined : \"button\"}\n        aria-label={disabled ? undefined : i18n(\"edit\", { label: label, value: value || placeholder })}\n        onClick={() => !disabled && switchTo(true)}\n        onKeyDown={(e: KeyboardEvent) => {\n          if (!disabled && (e.key === \"Enter\" || e.key === \" \")) {\n            e.preventDefault();\n            switchTo(true);\n          }\n        }}\n        data-empty={value ? undefined : \"\"}\n        data-saved={saved ? \"\" : undefined}\n        data-density={cell ? \"cell\" : undefined}\n        data-align={align === \"end\" ? \"end\" : undefined}\n        onAnimationEnd={(e: AnimationEvent) => {\n          if (e.target === e.currentTarget) setSaved(0);\n        }}\n        dir=\"auto\"\n        className={cn(\n          \"reveal-host\",\n          styles.display,\n          type,\n          multiline ? \"block whitespace-pre-wrap\" : cell ? \"block\" : \"inline-block\",\n          classNames?.root,\n          className,\n        )}\n      >\n        {disabled ? (\n          shown\n        ) : (\n          <>\n            {lead}\n            {/* the last word and the pencil never part: a full last line moves both to the next line together */}\n            <span data-slot=\"inline-edit-tail\" className={cn(styles.tail, classNames?.tail)}>\n              {tail}\n              {saved ? (\n                <Check aria-hidden data-slot=\"inline-edit-saved\" className={cn(styles.pencil, styles.saved)} />\n              ) : cell ? null : (\n                <Pencil\n                  aria-hidden\n                  data-slot=\"inline-edit-pencil\"\n                  className={cn(\"reveal\", styles.pencil, classNames?.pencil)}\n                />\n              )}\n            </span>\n          </>\n        )}\n      </Tag>\n    );\n  }\n  const common = {\n    ref: attachField,\n    value: draft,\n    \"aria-label\": label,\n    \"aria-invalid\": problem ? true : undefined,\n    \"aria-errormessage\": problem ? problemId : undefined,\n    \"aria-busy\": state === \"saving\" ? true : undefined,\n    // saving keeps the field and its focus; it only stops typing until the result arrives\n    readOnly: state === \"saving\",\n    \"data-slot\": \"inline-edit-field\",\n    dir: \"auto\",\n    inputMode,\n    placeholder,\n    onChange: (e: ChangeEvent<HTMLInputElement & HTMLTextAreaElement>) => {\n      setDraft(e.target.value);\n      if (problem) setProblem(null);\n    },\n    onBlur: () => void save(),\n    onKeyDown: (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        cancel();\n      } else if (e.key === \"Enter\" && (!multiline || e.metaKey || e.ctrlKey)) {\n        e.preventDefault();\n        void save();\n      }\n    },\n    className: cn(styles.field, type, classNames?.field),\n  };\n  return (\n    <div\n      data-slot=\"inline-edit\"\n      ref={attach}\n      style={style}\n      data-editing=\"\"\n      data-multiline={multiline ? \"\" : undefined}\n      data-density={cell ? \"cell\" : undefined}\n      data-align={align === \"end\" ? \"end\" : undefined}\n      className={cn(styles.editor, classNames?.root, className)}\n    >\n      <div data-slot=\"inline-edit-box\" className={cn(styles.box, classNames?.box)}>\n        {multiline ? (\n          <textarea\n            /* organism-allow: raw-control -- the edit field itself, sized to the text it replaces */ {...common}\n            rows={1}\n          />\n        ) : (\n          <input\n            // organism-allow: raw-control -- the edit field itself, sized to the text it replaces\n            {...common}\n            size={Math.max(8, draft.length + 1)}\n          />\n        )}\n        {state === \"saving\" && (\n          <span data-slot=\"inline-edit-spinner\" className={cn(styles.spinner, classNames?.spinner)}>\n            <Spinner size=\"sm\" label={i18n(\"saving\")} />\n          </span>\n        )}\n      </div>\n      {problem ? (\n        <span\n          id={problemId}\n          role=\"alert\"\n          data-slot=\"inline-edit-problem\"\n          className={cn(styles.problem, classNames?.problem)}\n        >\n          <AlertCircle aria-hidden />\n          {problem}\n        </span>\n      ) : cell ? null : (\n        <span data-slot=\"inline-edit-hint\" className={cn(styles.hint, classNames?.hint)}>\n          {multiline ? i18n(\"enterSavesEscCancels\") : i18n(\"enterSavesEscCancels2\")}\n        </span>\n      )}\n    </div>\n  );\n}\n\nexport interface InlineNumberProps extends Omit<\n  InlineEditProps,\n  | \"value\"\n  | \"defaultValue\"\n  | \"onValueChange\"\n  | \"onSave\"\n  | \"validate\"\n  | \"multiline\"\n  | \"format\"\n  | \"toDraft\"\n  | \"parse\"\n  | \"inputMode\"\n> {\n  /** Controlled committed number; null is no value. Omitted by default to keep accepted edits locally. */\n  value?: number | null;\n  /** Initial number when uncontrolled; null (no value) by default. */\n  defaultValue?: number | null;\n  /** Reports the committed number after onSave succeeds. */\n  onValueChange?: (value: number | null) => void;\n  /** Saves the new number (null when cleared and `optional`); return a promise to show progress, throw to refuse. */\n  onSave: (next: number | null) => void | Promise<unknown>;\n  /** A further check on the number read (after the range and step checks); say what is wrong, or nothing. */\n  validate?: (next: number | null) => string | null | undefined;\n  /** How the number reads, as Intl options: { style: \"unit\", unit: \"kilogram\" }, fraction digits, sign display. */\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Money in this ISO currency (\"EUR\"): shorthand for formatOptions { style: \"currency\", currency }. */\n  currency?: string;\n  /** The locale to read and write in; the page's locale by default. */\n  locale?: string;\n  /** The smallest accepted number; none by default. */\n  min?: number;\n  /** The largest accepted number; none by default. */\n  max?: number;\n  /** Accepted numbers are min (or 0) plus whole steps: 1 for counts, 0.01 for cents. None by default. */\n  step?: number;\n  /** Clearing the field saves null instead of being refused. Default false. */\n  optional?: boolean;\n}\n\n/**\n * A number or an amount of money that reads as formatted text and edits in place: typed in the reader's locale (\"1.234,5\"\n * in German, \"1,234.5\" in English, a pasted \"€1,234.50\" too), checked against min, max and step with the reason said,\n * and saved as a number. Tabular figures; `density=\"cell\"` fits it in a table cell with the figures at the end.\n */\nexport function InlineNumber({\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  onSave,\n  validate,\n  formatOptions,\n  currency,\n  locale: localeProp,\n  min,\n  max,\n  step,\n  optional = false,\n  density = \"text\",\n  align,\n  className,\n  ...rest\n}: InlineNumberProps) {\n  const i18n = useMessages(\"inline-edit\", englishMessages);\n  const locale = useLocale(localeProp);\n  const [value, setValue] = useControllable<number | null>(valueProp, defaultValue, onValueChange);\n  const show = useMemo(\n    () => numberFormatter(locale, currency ? { style: \"currency\", currency, ...formatOptions } : formatOptions),\n    [locale, currency, formatOptions],\n  );\n  // the field holds the bare number in the reader's locale, at least the fraction digits it is shown with (\"12.50\")\n  const typed = useMemo(() => {\n    const digits = show.resolvedOptions().minimumFractionDigits;\n    return numberFormatter(locale, {\n      useGrouping: false,\n      minimumFractionDigits: digits,\n      maximumFractionDigits: 20,\n    });\n  }, [show, locale]);\n  const plain = (n: number) => show.format(n);\n  const check = (text: string) => {\n    if (!text.trim()) return optional ? validate?.(null) : i18n(\"needed\");\n    const n = parseLocaleNumber(text, locale);\n    if (n === null) return i18n(\"notANumber\", { example: typed.format(1234.5) });\n    if (min !== undefined && n < min) return i18n(\"atLeast\", { min: plain(min) });\n    if (max !== undefined && n > max) return i18n(\"atMost\", { max: plain(max) });\n    if (step) {\n      const k = (n - (min ?? 0)) / step;\n      if (Math.abs(k - Math.round(k)) > 1e-9) return i18n(\"inSteps\", { step: plain(step) });\n    }\n    return validate?.(n);\n  };\n  const canonical = (n: number | null) => (n === null ? \"\" : String(n));\n  return (\n    <InlineEdit\n      {...rest}\n      density={density}\n      align={align ?? (density === \"cell\" ? \"end\" : \"start\")}\n      className={cn(styles.numeric, className)}\n      value={canonical(value)}\n      onValueChange={(v) => setValue(v === \"\" ? null : Number(v))}\n      format={(v) => show.format(Number(v))}\n      toDraft={(v) => typed.format(Number(v))}\n      parse={(t) => canonical(t.trim() ? parseLocaleNumber(t, locale) : null)}\n      validate={check}\n      inputMode={step !== undefined && Number.isInteger(step) && (min ?? 0) >= 0 ? \"numeric\" : \"decimal\"}\n      onSave={(v) => onSave(v === \"\" ? null : Number(v))}\n    />\n  );\n}\n\nexport default InlineEdit;\n"},{"path":"beamline/components/inline-edit/messages.ts","type":"registry:file","target":"src/ui/beamline/components/inline-edit/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  placeholder: \"Empty\",\n  edit: \"{label}: {value}. Edit\",\n  saving: \"Saving\",\n  enterSavesEscCancels: \"⌘Enter saves · Esc cancels\",\n  enterSavesEscCancels2: \"Enter saves · Esc cancels\",\n  notSavedTryAgain: \"Not saved. Try again.\",\n  notANumber: \"Enter a number, like {example}.\",\n  needed: \"Enter a value.\",\n  atLeast: \"At least {min}.\",\n  atMost: \"At most {max}.\",\n  inSteps: \"In steps of {step}.\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/spinner/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/defaults.ts","content":"/** Pure allowed presentation defaults for Spinner; no component or runtime imports. */\nexport interface SpinnerDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/spinner/messages.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  label: \"Loading\",\n  done: \"Done\",\n  error: \"Failed\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/spinner/spinner.module.css","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* With a delay the ring waits unseen and then fades in, so quick work never flashes it; the result (a check or a\n     cross) always shows at once. */\n  .root[data-delay] {\n    animation: appear var(--pui-fade-in) var(--pui-ease-default) var(--_delay) both;\n  }\n  @keyframes appear {\n    from {\n      opacity: 0;\n    }\n  }\n  /* The turn runs on the compositor (a CSS animation on the svg box) and costs no JS per frame. When the work ends the\n     arc closes into a full ring over the standard duration, so stopping the turn is invisible, and the check or cross\n     draws itself a beat later. All timing comes from the motion tokens; the loop period follows --pui-motion-scale. */\n  .svg[data-state=\"loading\"] {\n    animation: turn calc(0.8s * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1)) linear infinite;\n  }\n  .track {\n    stroke-opacity: 0.18;\n  }\n  .arc {\n    stroke-dasharray: 0.3 1;\n    transition: stroke-dasharray var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .svg:not([data-state=\"loading\"]) .arc {\n    stroke-dasharray: 1 1;\n  }\n  .mark {\n    stroke-dasharray: 1 1;\n    animation: draw var(--pui-duration-standard) var(--pui-ease-enter) var(--pui-duration-fast) both;\n  }\n  @keyframes turn {\n    to {\n      transform: rotate(1turn);\n    }\n  }\n  @keyframes draw {\n    from {\n      stroke-dashoffset: 1;\n    }\n    to {\n      stroke-dashoffset: 0;\n    }\n  }\n  .svg[data-paused] {\n    animation-play-state: paused;\n  }\n  .svg[data-reduced],\n  .svg[data-reduced] .mark {\n    animation: none;\n  }\n  /* Reduction retains the loading/result shape without a decorative loop. */\n  @media (prefers-reduced-motion: reduce) {\n    .svg[data-state=\"loading\"],\n    .mark {\n      animation: none;\n    }\n  }\n}\n"},{"path":"beamline/components/spinner/spinner.tsx","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SpinnerDefaults } from \"./defaults\";\nexport type { SpinnerDefaults } from \"./defaults\";\n\nimport { useRef, type CSSProperties, type Ref } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useActive } from \"../../hooks/use-active\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./spinner.module.css\";\n\nexport interface SpinnerClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the svg part; no override by default. */\n  svg?: string;\n  /** Classes for the track part; no override by default. */\n  track?: string;\n  /** Classes for the arc part; no override by default. */\n  arc?: string;\n  /** Classes for the mark part; no override by default. */\n  mark?: string;\n}\n\nexport interface SpinnerProps {\n  /** `success` draws a check out of the ring, `error` a cross; both stop spinning. */\n  state?: \"loading\" | \"success\" | \"error\";\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Accessible name while loading. */\n  label?: string;\n  /** ms to wait before it shows: work that ends sooner never flashes a spinner (about 300 is a good start). Default 0. */\n  delay?: number;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLSpanElement>;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: SpinnerClassNames;\n}\n\n/** Icon box from the foundation icon steps (§14.2): sm 14, md 16, lg 20. */\nconst PX = { sm: \"var(--pui-icon-sm)\", md: \"var(--pui-icon-md)\", lg: \"var(--pui-icon-lg)\" } as const;\n\n/**\n * A ring that spins while work runs and closes into a drawn check (or cross) when it ends — the result is a shape as\n * well as a colour. With reduced motion the loading ring holds still.\n */\nexport function Spinner({\n  state = \"loading\",\n  size: sizeProp,\n  label: labelProp,\n  delay = 0,\n  className,\n  classNames,\n  style,\n  ref,\n}: SpinnerProps) {\n  const i18n = useMessages(\"spinner\", englishMessages);\n  const label = labelProp === undefined ? i18n(\"label\") : labelProp;\n\n  const scopeDefaults = useComponentDefaults<SpinnerDefaults>(\"spinner\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n  const host = useRef<HTMLSpanElement>(null);\n  const attach = useComposedRef(host, ref);\n  const active = useActive(host);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = tokens.reduced || managedReduced;\n\n  const box = PX[size];\n  const done = state !== \"loading\";\n  const tone = state === \"success\" ? \"text-green\" : state === \"error\" ? \"text-red\" : \"text-ink-2\";\n  return (\n    <span\n      ref={attach}\n      data-slot=\"spinner\"\n      data-state={state}\n      data-delay={delay > 0 && state === \"loading\" ? \"\" : undefined}\n      role={done ? \"img\" : \"status\"}\n      aria-label={state === \"success\" ? i18n(\"done\") : state === \"error\" ? i18n(\"error\") : label}\n      className={cn(styles.root, \"relative inline-grid shrink-0 place-items-center\", tone, classNames?.root, className)}\n      style={{ width: box, height: box, \"--_delay\": `${delay}ms`, ...style } as CSSProperties}\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        width=\"100%\"\n        height=\"100%\"\n        fill=\"none\"\n        aria-hidden=\"true\"\n        data-state={state}\n        data-slot=\"spinner-svg\"\n        data-reduced={reduced ? \"\" : undefined}\n        data-paused={active ? undefined : \"\"}\n        className={cn(styles.svg, classNames?.svg)}\n      >\n        <circle\n          data-slot=\"spinner-track\"\n          className={cn(styles.track, classNames?.track)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n        />\n        <circle\n          data-slot=\"spinner-arc\"\n          className={cn(styles.arc, classNames?.arc)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          pathLength={1}\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n          strokeLinecap=\"round\"\n          transform=\"rotate(-90 12 12)\"\n        />\n        {state === \"success\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M7.5 12.5l3 3 6-6.5\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        )}\n        {state === \"error\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M8.5 8.5l7 7M15.5 8.5l-7 7\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n          />\n        )}\n      </svg>\n    </span>\n  );\n}\n\nexport default Spinner;\n"},{"path":"beamline/hooks/use-active.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-active.ts","content":"\"use client\";\n\nimport { useContext, useEffect, useState, type RefObject } from \"react\";\n\nimport { ActivityContext } from \"../runtime/activity\";\n\n/**\n * Whether an animated element should be doing work: it is (nearly) on screen and the tab is visible. Every\n * continuously animated component gates its loop, shader or CSS animation on this, so an effect scrolled out of view,\n * behind another tab, or in a collapsed panel costs nothing. One IntersectionObserver per element; no polling.\n */\nexport function useActive(\n  ref: RefObject<Element | null>,\n  {\n    margin = \"120px\",\n    key,\n    idle = true,\n  }: {\n    margin?: string;\n    /** Re-attach when this changes (an element that mounts later). */\n    key?: unknown;\n    /** Pause continuous work at the host idle boundary. Finite sequences set false so they finish. */\n    idle?: boolean;\n  } = {},\n) {\n  const active = useContext(ActivityContext);\n  const [inView, setInView] = useState(false);\n  const [visible, setVisible] = useState(\n    () => typeof document === \"undefined\" || document.visibilityState === \"visible\",\n  );\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { rootMargin: margin });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, margin, key]);\n  useEffect(() => {\n    const on = () => setVisible(document.visibilityState === \"visible\");\n    document.addEventListener(\"visibilitychange\", on);\n    return () => document.removeEventListener(\"visibilitychange\", on);\n  }, []);\n  return (!idle || active) && inView && visible;\n}\n"},{"path":"beamline/hooks/use-composed-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-composed-ref.ts","content":"\"use client\";\n\nimport { useCallback, type Ref, type RefObject } from \"react\";\n\n/** Keep a control's measurement/focus ref and the caller's ref, including React 19 callback cleanup. */\nexport function useComposedRef<T>(internal: RefObject<T | null>, external?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      internal.current = node;\n      if (typeof external === \"function\") {\n        const cleanup = external(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            internal.current = null;\n            cleanup();\n          };\n      } else if (external) external.current = node;\n    },\n    [internal, external],\n  );\n}\n"},{"path":"beamline/hooks/use-controllable.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-controllable.ts","content":"\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * One value in both forms (rules/CONVENTIONS.md): the prop while it is passed, otherwise the element's own copy that\n * starts at `initial` (a value, or a function that makes it once). The setter takes a value or an updater; an updater\n * reads the latest value even twice in one event, and every change reaches `onChange`, controlled or not.\n * `Next` is what a change can carry when it is narrower than the state: a range that starts empty (null) but is only\n * ever changed to a range.\n */\nexport function useControllable<T, Next extends T = T>(\n  value: T | undefined,\n  initial: T | (() => T),\n  onChange?: (next: Next) => void,\n) {\n  const [own, setOwn] = useState<T>(initial);\n  const current = value !== undefined ? value : own;\n  const latest = useRef(current);\n  latest.current = current;\n  const set = useCallback(\n    (next: Next | ((was: T) => Next)) => {\n      const resolved = typeof next === \"function\" ? (next as (was: T) => Next)(latest.current) : next;\n      latest.current = resolved;\n      if (value === undefined) setOwn(resolved);\n      onChange?.(resolved);\n    },\n    [value, onChange],\n  );\n  return [current, set] as const;\n}\n"},{"path":"beamline/hooks/use-deprecated-prop.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-deprecated-prop.ts","content":"\"use client\";\n\nimport { useEffect } from \"react\";\n\n// Bundlers replace NODE_ENV for browser builds; this declaration emits no Node dependency.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nconst warned = new Set<string>();\n\n/** Report a legacy prop once after mount in development; production and server renders stay silent. */\nexport function useDeprecatedProp(component: string, prop: string, replacement: string, supplied: boolean) {\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"development\" || !supplied) return;\n    const key = `${component}.${prop}`;\n    if (warned.has(key)) return;\n    warned.add(key);\n    console.warn(`[Beamline] ${key} is deprecated. Use ${replacement} instead; the legacy prop still works.`);\n  }, [component, prop, replacement, supplied]);\n}\n"},{"path":"beamline/hooks/use-media-query.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-media-query.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// one MediaQueryList per query: React reads the snapshot on every render, and a part that renders thirty times a\n// second (a playing audio player's clock) would otherwise ask the browser for a new list each time\nconst lists = new Map<string, MediaQueryList>();\nconst list = (query: string) => {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return undefined;\n  let m = lists.get(query);\n  if (!m) lists.set(query, (m = window.matchMedia(query)));\n  return m;\n};\n\n/** True while the media query matches; false on the server and in the first client render of SSR output. */\nexport function useMediaQuery(query: string): boolean {\n  return useSyncExternalStore(\n    (notify) => {\n      const m = list(query);\n      m?.addEventListener(\"change\", notify);\n      return () => m?.removeEventListener(\"change\", notify);\n    },\n    () => list(query)?.matches ?? false,\n    () => false,\n  );\n}\n\n/** Phone-width layouts: below 768px. */\nexport const usePhone = () => useMediaQuery(\"(max-width: 767px)\");\n"},{"path":"beamline/hooks/use-motion-tokens.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-motion-tokens.ts","content":"\"use client\";\n\nimport { useMemo, type RefObject } from \"react\";\nimport { useAppearanceRoot } from \"../runtime/appearance\";\nimport { useResolvedAppearance } from \"./use-resolved-appearance\";\nimport { motionAt, motionEase, motionTokens, type MotionTokens } from \"../lib/motion-tokens\";\nimport { semanticMotion } from \"../lib/motion-source\";\n\nconst durationToken = (name: string) =>\n  name === \"exit\" ? \"motion-out\" : name === \"in\" || name === \"out\" ? `motion-${name}` : `duration-${name}`;\nconst easeToken = (name: string) => `ease-${name === \"inOut\" ? \"in-out\" : name}`;\nconst REQUEST = {\n  times: [\n    ...Object.keys(semanticMotion.duration).map(durationToken),\n    ...Object.keys(semanticMotion.spring).map((name) => `spring-${name}`),\n    ...Object.keys(semanticMotion.stagger).map((name) => `stagger-${name}`),\n    \"fade-in\",\n    \"fade-out\",\n  ],\n  easings: [...Object.keys(semanticMotion.ease).map(easeToken), \"ease-spring\", \"ease-spring-bounce\", \"ease-overshoot\"],\n  numbers: [\"motion-scale\", \"motion-look\", \"press-scale\"],\n  // timing only: the host's own type would make every control resolve differently from the page around it\n  typography: false,\n};\n\n/**\n * Resolve each timing at the actual host; omitted host uses the nearest managed root. The first/server snapshot is\n * the pure foundation default. Local CSS values, scope changes, font readiness, OS preference and explicit refresh\n * update the same consumer; no document-wide first-root fallback or persistent global listener.\n */\nexport function useMotionTokens(hostRef?: RefObject<HTMLElement | null>): MotionTokens & {\n  ready: boolean;\n  /** Reduced or none: movement, springs and staggers are instant. */\n  reduced: boolean;\n  mode: \"full\" | \"reduced\" | \"none\";\n  /** Opacity-only arrival and departure (seconds): kept under reduced, instant under none (APPEARANCE A06). */\n  fade: { in: number; out: number };\n} {\n  const root = useAppearanceRoot();\n  const scopeRef = useMemo(() => ({ current: root }), [root]);\n  const resolved = useResolvedAppearance(hostRef ?? scopeRef, REQUEST);\n  return useMemo(() => {\n    if (!resolved.ready)\n      return {\n        ...motionTokens,\n        ready: false,\n        reduced: false,\n        mode: \"full\" as const,\n        fade: { in: motionTokens.duration.in, out: motionTokens.duration.out },\n      };\n    const reduced = resolved.reducedMotion;\n    const speed = (resolved.numbers[\"motion-scale\"] ?? 1) * (resolved.numbers[\"motion-look\"] ?? 1);\n    const fade =\n      resolved.motionMode === \"none\"\n        ? { in: 0.001, out: 0.001 }\n        : {\n            in: resolved.times[\"fade-in\"] ?? semanticMotion.duration.in * speed,\n            out: resolved.times[\"fade-out\"] ?? semanticMotion.duration.out * speed,\n          };\n    const factor = reduced ? 0.001 / semanticMotion.duration.standard : speed;\n    const base = motionAt(factor);\n    const duration = Object.fromEntries(\n      Object.entries(base.duration).map(([name, fallback]) => [\n        name,\n        reduced ? 0.001 : (resolved.times[durationToken(name)] ?? fallback),\n      ]),\n    ) as MotionTokens[\"duration\"];\n    const stagger = Object.fromEntries(\n      Object.entries(base.stagger).map(([name, fallback]) => [\n        name,\n        reduced ? 0 : (resolved.times[`stagger-${name}`] ?? fallback),\n      ]),\n    ) as MotionTokens[\"stagger\"];\n    const ease = Object.fromEntries(\n      Object.entries(base.ease).map(([name, fallback]) => [\n        name,\n        motionEase(resolved.easings[easeToken(name)], fallback),\n      ]),\n    ) as MotionTokens[\"ease\"];\n    const spring = { ...base.spring };\n    for (const name of Object.keys(semanticMotion.spring) as (keyof typeof semanticMotion.spring)[])\n      spring[name] = {\n        ...base.spring[name],\n        visualDuration: reduced ? 0.001 : (resolved.times[`spring-${name}`] ?? base.spring[name].visualDuration),\n        bounce: reduced ? 0 : base.spring[name].bounce,\n      };\n    return {\n      ...base,\n      ready: true,\n      reduced,\n      mode: resolved.motionMode,\n      fade,\n      duration,\n      stagger,\n      ease,\n      spring,\n      pressScale: resolved.numbers[\"press-scale\"] ?? base.pressScale,\n      css: {\n        spring: resolved.easings[\"ease-spring\"] ?? base.css.spring,\n        springBounce: resolved.easings[\"ease-spring-bounce\"] ?? base.css.springBounce,\n        overshoot: resolved.easings[\"ease-overshoot\"] ?? base.css.overshoot,\n      },\n    };\n  }, [resolved]);\n}\n"},{"path":"beamline/hooks/use-reduced-motion-safe.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-reduced-motion-safe.ts","content":"\"use client\";\n\nimport { useMediaQuery } from \"./use-media-query\";\nimport { useScopeMotion } from \"../runtime/appearance\";\n\n/** Managed motion policy plus the OS preference, with a stable server snapshot. */\nexport function useReducedMotionSafe() {\n  const requested = useScopeMotion();\n  const os = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  return requested !== \"auto\" || os;\n}\n"},{"path":"beamline/lib/number.ts","type":"registry:file","target":"src/ui/beamline/lib/number.ts","content":"import { createFormatters, FALLBACK_LOCALE } from \"../runtime/messages\";\n/** Reads a number typed in `locale` (\"1.234,5\" in de, \"1,234.5\" in en): group separators dropped, the locale's decimal\n *  mark read as the point, currency signs and units ignored. Empty or unreadable text is null; nothing is clamped. */\nexport function parseLocaleNumber(text: string, locale?: string): number | null {\n  if (!text.trim()) return null;\n  // exponent form: \"1e3\" is a thousand and \"2.5e-3\" a fraction (the digits are never run together into 13)\n  const exp = text.trim().match(/^(.*\\d)\\s*e\\s*([+\\u2212-]?\\d+)$/i);\n  if (exp) {\n    const base = parseLocaleNumber(exp[1], locale);\n    return base === null ? null : Number((base * 10 ** Number(exp[2].replace(\"\\u2212\", \"-\"))).toPrecision(15));\n  }\n  const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);\n  const group = parts.find((p) => p.type === \"group\")?.value ?? \",\";\n  const decimal = parts.find((p) => p.type === \"decimal\")?.value ?? \".\";\n  let ascii = text;\n  // one grouping mark that cannot group (not three digits after it) and no decimal mark: the decimal was written the\n  // other locale's way (\"1,5\" in English is one and a half, never fifteen)\n  const marks = text.split(group).length - 1;\n  if (marks === 1 && !text.includes(decimal) && !new RegExp(`\\\\${group}\\\\d{3}(?!\\\\d)`).test(text))\n    ascii = text.replace(group, decimal);\n  if (/[^\\p{ASCII}]/u.test(text)) {\n    const digits = new Intl.NumberFormat(locale, { useGrouping: false });\n    for (let value = 0; value < 10; value++) ascii = ascii.replaceAll(digits.format(value), String(value));\n  }\n  const normalized = ascii\n    .replaceAll(group, \"\")\n    .replaceAll(\"\\u202f\", \"\")\n    .replace(decimal, \".\")\n    .replace(/[\\u2212\\u2013]/g, \"-\")\n    .replace(/[^\\d.-]/g, \"\");\n  if (!/\\d/.test(normalized)) return null;\n  const n = Number(normalized);\n  return Number.isFinite(n) ? n : null;\n}\n\nconst formatters = new Map<string, Intl.NumberFormat>();\n/** A number written from serializable settings, so a server component can pass them where a `format` function cannot\n *  cross: Intl options in a locale, with words before and after (\"$\" … \" / mo\"). One Intl.NumberFormat per setting. */\nexport function formatNumber(\n  value: number,\n  { locale, formatOptions, prefix = \"\", suffix = \"\" }: NumberFormatSettings = {},\n): string {\n  const key = `${locale ?? \"\"}|${JSON.stringify(formatOptions ?? {})}`;\n  let f = formatters.get(key);\n  if (!f) formatters.set(key, (f = new Intl.NumberFormat(locale, formatOptions)));\n  return `${prefix}${f.format(value)}${suffix}`;\n}\n\nexport interface NumberFormatSettings {\n  locale?: string;\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Written before the number: \"$\". */\n  prefix?: string;\n  /** Written after the number: \" ms\", \" / mo\". */\n  suffix?: string;\n}\n\n/** Binary-scaled file sizes; the digits and unit names follow the supplied locale. */\nexport function formatByteSize(bytes: number, locale = FALLBACK_LOCALE, maximumUnit = 3, fractionDigits?: number) {\n  const units = [\"byte\", \"kilobyte\", \"megabyte\", \"gigabyte\"];\n  let value = bytes,\n    unit = 0;\n  while (value >= 1024 && unit < maximumUnit) {\n    value /= 1024;\n    unit++;\n  }\n  const digits = fractionDigits ?? (value < 10 && unit > 0 ? 1 : 0);\n  return createFormatters(locale).number(value, {\n    style: \"unit\",\n    unit: units[unit],\n    unitDisplay: \"short\",\n    minimumFractionDigits: digits,\n    maximumFractionDigits: digits,\n  });\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":"Inline edit is in src/ui/beamline/components/inline-edit/inline-edit.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/inline-edit"}}