{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"rich-text-editor","type":"registry:component","title":"Rich text editor","description":"A Notion-grade writing surface: slash menu for every block, a formatting toolbar on selection, drag-to-move blocks, nesting, markdown shortcuts, paste from the web or as markdown; outputs HTML, Markdown and JSON.","categories":["forms"],"dependencies":["@floating-ui/dom@1.8.0","@radix-ui/react-direction@1.1.4","@radix-ui/react-slot@1.3.3","@radix-ui/react-tooltip@1.2.16","@tiptap/core@3.31.3","@tiptap/extension-code-block@3.31.3","@tiptap/extension-code-block-lowlight@3.31.3","@tiptap/extension-drag-handle@3.31.3","@tiptap/extension-drag-handle-react@3.31.3","@tiptap/extension-highlight@3.31.3","@tiptap/extension-image@3.31.3","@tiptap/extension-list@3.31.3","@tiptap/extension-text-style@3.31.3","@tiptap/extension-typography@3.31.3","@tiptap/extensions@3.31.3","@tiptap/markdown@3.31.3","@tiptap/pm@3.31.3","@tiptap/react@3.31.3","@tiptap/starter-kit@3.31.3","@tiptap/suggestion@3.31.3","@types/react@^19.3.0","@types/react-dom@^19.3.0","clsx@^2.1.1","highlight.js@11.11.2","lowlight@3.3.0","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/glide.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/glide.tsx","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type RefObject } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport overlay from \"./overlay.module.css\";\n\n/**\n * The gliding highlight for a list whose active row is tracked in state (command menu, listbox): one plate travels to\n * the active row for the arrow keys and the pointer alike, over --pui-duration-fast. It appears in place (no travel)\n * the first time and whenever `reset` changes (a new page or a new query), so typing never sends it flying.\n * The list must be the plate's offset parent (position: relative); rows stay above it (overlay.item is z-index 1).\n */\nexport function GlidePlate({\n  list,\n  selector,\n  reset,\n  axis = \"y\",\n  className,\n}: {\n  list: RefObject<HTMLElement | null>;\n  /** Finds the active row inside `list`; null when nothing is active. */\n  selector: string | null;\n  reset?: unknown;\n  /** \"y\" for a list (the plate spans the width); \"xy\" for a row or grid of cells (it takes each cell's size). */\n  axis?: \"y\" | \"xy\";\n  className?: string;\n}) {\n  const [plate, setPlate] = useState<{ x: number; y: number; w: number; h: number; jump: boolean } | null>(null);\n  const lastReset = useRef(reset);\n  const self = useRef<HTMLSpanElement>(null);\n  // eslint-disable-next-line react-hooks/exhaustive-deps -- every render on purpose; setPlate bails out when unchanged\n  useLayoutEffect(() => {\n    // the list holds the plate, and React attaches a parent's ref only after its children's layout effects: on the\n    // first commit the list is the plate's own parent (it placed itself only when something rendered again later)\n    const host = list.current ?? self.current?.parentElement;\n    const row = selector ? host?.querySelector<HTMLElement>(selector) : null;\n    const fresh = lastReset.current !== reset;\n    lastReset.current = reset;\n    if (!row) {\n      setPlate((p) => (p && p.h ? { ...p, h: 0 } : p));\n      return;\n    }\n    const x = row.offsetLeft,\n      y = row.offsetTop,\n      w = row.offsetWidth,\n      h = row.offsetHeight;\n    setPlate((p) =>\n      p && p.x === x && p.y === y && p.w === w && p.h === h && !fresh ? p : { x, y, w, h, jump: fresh || !p?.h },\n    );\n    // every render: rows can arrive or change height without the selector changing; an unchanged place keeps the state\n  });\n  return (\n    <span\n      ref={self}\n      aria-hidden\n      data-slot=\"list-highlight\"\n      className={cn(overlay.glide, className)}\n      data-on={plate?.h ? \"\" : undefined}\n      data-jump={plate?.jump ? \"\" : undefined}\n      data-axis={axis}\n      style={\n        !plate\n          ? undefined\n          : axis === \"y\"\n            ? { transform: `translateY(${plate.y}px)`, height: plate.h || undefined }\n            : { transform: `translate(${plate.x}px, ${plate.y}px)`, width: plate.w, height: plate.h || undefined }\n      }\n    />\n  );\n}\n\n/**\n * A GlidePlate for lists whose highlight is the pointer or keyboard focus rather than state (time picker, plain option\n * lists): it follows the row under the pointer or holding focus, and fades when neither is in the list. Rows drop\n * their own hover and focus tint.\n */\nexport function FollowPlate({\n  list,\n  rows,\n  className,\n}: {\n  list: RefObject<HTMLElement | null>;\n  /** Matches a row inside `list`, e.g. \"[role=option]\". */\n  rows: string;\n  className?: string;\n}) {\n  const [hot, setHot] = useState<string | null>(null);\n  // after the commit, when the list's ref is attached (a layout effect here ran before it and never listened)\n  useEffect(() => {\n    const el = list.current;\n    if (!el) return;\n    let n = 0;\n    const mark = (target: EventTarget | null) => {\n      const row = (target as HTMLElement | null)?.closest?.<HTMLElement>(rows);\n      if (!row || !el.contains(row)) return;\n      row.dataset.glideKey ??= String(++n);\n      setHot(row.dataset.glideKey);\n    };\n    const over = (e: Event) => mark(e.target);\n    const leave = () => {\n      if (!el.contains(document.activeElement)) setHot(null);\n    };\n    const blur = (e: FocusEvent) => {\n      if (!el.contains(e.relatedTarget as Node)) setHot(null);\n    };\n    el.addEventListener(\"pointerover\", over);\n    el.addEventListener(\"focusin\", over);\n    el.addEventListener(\"pointerleave\", leave);\n    el.addEventListener(\"focusout\", blur);\n    return () => {\n      el.removeEventListener(\"pointerover\", over);\n      el.removeEventListener(\"focusin\", over);\n      el.removeEventListener(\"pointerleave\", leave);\n      el.removeEventListener(\"focusout\", blur);\n    };\n  }, [list, rows]);\n  return <GlidePlate list={list} selector={hot ? `[data-glide-key=\"${hot}\"]` : null} className={className} />;\n}\n"},{"path":"beamline/components/_shared/overlay.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/overlay.module.css","content":"@layer properties, theme, base, components, utilities;\n/* the source morph's moving parts: the clip's four sides, the radius and the offset back to the trigger */\n@property --ci-t {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-r {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-b {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-l {\n  syntax: \"<length-percentage>\";\n  inherits: false;\n  initial-value: -48px;\n}\n@property --ci-x {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --ci-y {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@layer components {\n  /* Every floating layer in the system: one surface (painted by the overlay role of the look in force), one menu-item\n   look, one scrim, one enter and exit. A layer placed against a trigger opens out of it (the source morph below); one\n   without a placement arrives in --pui-motion-in scaling from its origin. Each leaves in --pui-motion-out; Radix keeps\n   it mounted until the exit animation ends. Reduced motion makes both instant (the duration tokens become 1ms). */\n  .surface {\n    z-index: var(--pui-z-popover);\n    min-width: 8rem;\n    /* never past the screen: as wide and as tall as the room beside the trigger (Radix measures it) */\n    max-width: var(--radix-popper-available-width, none);\n    max-height: var(--radix-popper-available-height, 70vh);\n    overflow: auto;\n    border-radius: var(--pui-overlay-radius, var(--pui-radius-card));\n    color: var(--pui-ink);\n    transform-origin: var(--radix-popper-transform-origin, var(--radix-dropdown-menu-content-transform-origin, center));\n    outline: none;\n  }\n  .surface[data-state=\"open\"],\n  .surface[data-state=\"delayed-open\"],\n  .surface[data-state=\"instant-open\"] {\n    animation: pop-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .surface[data-state=\"closed\"] {\n    animation: pop-out var(--pui-motion-out) var(--pui-ease-default) forwards;\n  }\n\n  /* The source morph (research/morphing-interfaces-2026-10-04, M3): a layer placed against a trigger does not fade in\n     beside it, it opens out of it. It starts as the trigger's own box (Radix gives its size and the side and alignment\n     it was placed on), offset back onto the trigger, and grows: the side nearest the trigger is there at once, the\n     sides across follow, the far side arrives last (~200 ms), so the rows by the trigger are readable within the\n     ruled 90 ms while the outline settles. Content fades in over those 90 ms. It closes back into the trigger in the\n     ruled 180 ms. The clip ends 48px outside the box so the layer's shadow is never cut. Reduced motion: instant. */\n  .surface[data-side] {\n    --_aw: var(--radix-popper-anchor-width, 0px);\n    --_ah: var(--radix-popper-anchor-height, 0px);\n    --_gap: 6px;\n    --from-t: -48px;\n    --from-r: -48px;\n    --from-b: -48px;\n    --from-l: -48px;\n    --from-x: 0px;\n    --from-y: 0px;\n    --d-near: calc(var(--pui-duration-standard) * 0.55);\n    --d-cross: calc(var(--pui-duration-standard) * 0.85);\n    --d-far: var(--pui-duration-standard);\n    clip-path: inset(\n      var(--ci-t) var(--ci-r) var(--ci-b) var(--ci-l) round var(--pui-overlay-radius, var(--pui-radius-card))\n    );\n    translate: var(--ci-x) var(--ci-y);\n  }\n  .surface[data-side=\"bottom\"] {\n    --from-t: 0px;\n    --from-b: calc(100% - var(--_ah));\n    --from-y: calc(-1 * (var(--_ah) + var(--_gap)));\n    --d-t: var(--d-near);\n    --d-b: var(--d-far);\n    --d-l: var(--d-cross);\n    --d-r: var(--d-cross);\n  }\n  .surface[data-side=\"top\"] {\n    --from-b: 0px;\n    --from-t: calc(100% - var(--_ah));\n    --from-y: calc(var(--_ah) + var(--_gap));\n    --d-b: var(--d-near);\n    --d-t: var(--d-far);\n    --d-l: var(--d-cross);\n    --d-r: var(--d-cross);\n  }\n  .surface[data-side=\"right\"] {\n    --from-l: 0px;\n    --from-r: calc(100% - var(--_aw));\n    --from-x: calc(-1 * (var(--_aw) + var(--_gap)));\n    --d-l: var(--d-near);\n    --d-r: var(--d-far);\n    --d-t: var(--d-cross);\n    --d-b: var(--d-cross);\n  }\n  .surface[data-side=\"left\"] {\n    --from-r: 0px;\n    --from-l: calc(100% - var(--_aw));\n    --from-x: calc(var(--_aw) + var(--_gap));\n    --d-r: var(--d-near);\n    --d-l: var(--d-far);\n    --d-t: var(--d-cross);\n    --d-b: var(--d-cross);\n  }\n  /* alignment along the trigger: which part of the layer the trigger sits over */\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"start\"] {\n    --from-l: 0px;\n    --from-r: calc(100% - var(--_aw));\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"end\"] {\n    --from-l: calc(100% - var(--_aw));\n    --from-r: 0px;\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"start\"]:dir(rtl) {\n    --from-l: calc(100% - var(--_aw));\n    --from-r: 0px;\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"end\"]:dir(rtl) {\n    --from-l: 0px;\n    --from-r: calc(100% - var(--_aw));\n  }\n  .surface:is([data-side=\"bottom\"], [data-side=\"top\"])[data-align=\"center\"] {\n    --from-l: calc((100% - var(--_aw)) / 2);\n    --from-r: calc((100% - var(--_aw)) / 2);\n  }\n  .surface:is([data-side=\"left\"], [data-side=\"right\"])[data-align=\"start\"] {\n    --from-t: 0px;\n    --from-b: calc(100% - var(--_ah));\n  }\n  .surface:is([data-side=\"left\"], [data-side=\"right\"])[data-align=\"end\"] {\n    --from-t: calc(100% - var(--_ah));\n    --from-b: 0px;\n  }\n  .surface:is([data-side=\"left\"], [data-side=\"right\"])[data-align=\"center\"] {\n    --from-t: calc((100% - var(--_ah)) / 2);\n    --from-b: calc((100% - var(--_ah)) / 2);\n  }\n  .surface[data-side]:is([data-state=\"open\"], [data-state=\"delayed-open\"], [data-state=\"instant-open\"]) {\n    animation:\n      grow-t var(--d-t) var(--pui-ease-out-strong),\n      grow-r var(--d-r) var(--pui-ease-out-strong),\n      grow-b var(--d-b) var(--pui-ease-out-strong),\n      grow-l var(--d-l) var(--pui-ease-out-strong),\n      grow-xy var(--d-cross) var(--pui-ease-out-strong),\n      settling var(--d-far) step-end;\n  }\n  .surface[data-side]:is([data-state=\"open\"], [data-state=\"delayed-open\"], [data-state=\"instant-open\"]) > * {\n    animation: content-in var(--pui-motion-in) var(--pui-ease-default) both;\n  }\n  /* one duration for every part of the exit: the layer unmounts when the first of them ends */\n  .surface[data-side][data-state=\"closed\"] {\n    animation: shrink var(--pui-motion-out) var(--pui-ease-exit) forwards;\n  }\n  .surface[data-side][data-state=\"closed\"] > * {\n    animation: content-out var(--pui-motion-in) var(--pui-ease-default) forwards;\n  }\n  @keyframes grow-t {\n    from {\n      --ci-t: var(--from-t);\n    }\n  }\n  @keyframes grow-r {\n    from {\n      --ci-r: var(--from-r);\n    }\n  }\n  @keyframes grow-b {\n    from {\n      --ci-b: var(--from-b);\n    }\n  }\n  @keyframes grow-l {\n    from {\n      --ci-l: var(--from-l);\n    }\n  }\n  @keyframes grow-xy {\n    from {\n      --ci-x: var(--from-x);\n      --ci-y: var(--from-y);\n    }\n  }\n  /* it starts over its own trigger: until it has grown, the press that opened it must not land on a row */\n  @keyframes settling {\n    0%,\n    100% {\n      pointer-events: none;\n    }\n  }\n  @keyframes shrink {\n    to {\n      --ci-t: var(--from-t);\n      --ci-r: var(--from-r);\n      --ci-b: var(--from-b);\n      --ci-l: var(--from-l);\n      --ci-x: var(--from-x);\n      --ci-y: var(--from-y);\n      opacity: 0;\n    }\n  }\n  @keyframes content-in {\n    from {\n      opacity: 0;\n    }\n  }\n  @keyframes content-out {\n    to {\n      opacity: 0;\n    }\n  }\n  .padded {\n    padding: var(--pui-pad-popover);\n  }\n  .menu {\n    padding: var(--pui-space-1);\n  }\n  @keyframes pop-in {\n    from {\n      opacity: 0;\n      transform: scale(0.97);\n    }\n  }\n  @keyframes pop-out {\n    to {\n      opacity: 0;\n      transform: scale(0.98);\n    }\n  }\n\n  /* rows (and the gliding plate) nest in the list: its radius minus its padding, never rounder than a chip; on the\n     row itself, so any list that uses .item gets it */\n  .item,\n  .glide {\n    --_item-radius: min(\n      var(--pui-radius-chip),\n      calc(var(--pui-overlay-radius, var(--pui-radius-card)) - var(--pui-space-1))\n    );\n  }\n\n  /* menu rows: shared by dropdown, context menu, menubar, select, combobox lists */\n  .item {\n    position: relative;\n    z-index: 1;\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-2);\n    min-height: var(--pui-control-height-sm);\n    padding: 0 var(--pui-space-2);\n    /* nested in the list: the overlay's radius minus its padding, never rounder than a chip */\n    border-radius: var(--_item-radius);\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    cursor: default;\n    user-select: none;\n    outline: none;\n  }\n  /* on a touch screen a row is a finger's target: the touch height (44px) from the tokens; a mouse keeps the dense row */\n  @media (pointer: coarse) {\n    .item {\n      min-height: var(--pui-control-height-touch);\n    }\n  }\n  /* a destructive row speaks in the danger ink only while it can be used: disabled wins (muted ink, no alarm for an\n     action that cannot happen) */\n  .item[data-tone=\"danger\"]:not([data-disabled]) {\n    color: var(--pui-red);\n  }\n  .item[data-disabled] {\n    color: var(--pui-ink-3);\n    pointer-events: none;\n  }\n  .item :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n    flex-shrink: 0;\n    color: var(--pui-ink-3);\n  }\n  .item[data-tone=\"danger\"]:not([data-disabled]) :where(svg) {\n    color: currentColor;\n  }\n  .item[data-highlighted]:not([data-glide]) {\n    background: var(--pui-hover-2);\n  }\n  /* a press is acknowledged at once, over the plate, before the menu closes or the row toggles */\n  .item:active:not([data-disabled]) {\n    background: var(--pui-hover);\n  }\n  .shortcut {\n    margin-inline-start: auto;\n    padding-inline-start: var(--pui-space-4);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    letter-spacing: var(--pui-tracking-mono-caps);\n  }\n  .heading {\n    padding: var(--pui-space-1-5) var(--pui-space-2) var(--pui-space-1);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    font-weight: var(--pui-weight-medium);\n  }\n  .separator {\n    height: 1px;\n    margin: var(--pui-space-1) calc(var(--pui-space-1) * -1);\n    background: var(--pui-line);\n  }\n  .check {\n    margin-inline-start: auto;\n    color: var(--pui-accent) !important;\n  }\n  /* a check arrives in its row as the value changes (at once): it grows from the middle over the arrival */\n  .check :where(svg) {\n    animation: check-in var(--pui-motion-in) var(--pui-ease-out-strong);\n  }\n  @keyframes check-in {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.5 * var(--pui-motion-travel, 1));\n    }\n  }\n  /* the gliding highlight: one plate moves between rows for the pointer and the arrow keys; it appears in place */\n  .glide {\n    position: absolute;\n    z-index: 0;\n    inset-inline: var(--pui-space-1);\n    top: 0;\n    border-radius: var(--_item-radius);\n    background: var(--pui-hover-2);\n    pointer-events: none;\n    opacity: 0;\n    transition: opacity var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .glide[data-on] {\n    opacity: 1;\n    transition:\n      transform var(--pui-duration-fast) var(--pui-ease-standard),\n      height var(--pui-duration-fast) var(--pui-ease-standard),\n      opacity 0ms;\n  }\n  .glide[data-jump] {\n    transition: opacity 0ms;\n  }\n  /* a plate over a row or grid of cells takes each cell's size and travels both ways */\n  .glide[data-axis=\"xy\"] {\n    inset-inline: auto;\n    left: 0;\n  }\n  .glide[data-axis=\"xy\"][data-on] {\n    transition:\n      transform var(--pui-duration-fast) var(--pui-ease-standard),\n      width var(--pui-duration-fast) var(--pui-ease-standard),\n      height var(--pui-duration-fast) var(--pui-ease-standard),\n      opacity 0ms;\n  }\n\n  /* a layer that has begun to close is out of the way at once: for the length of its exit it takes no pointer, so a tap\n     meant for the page behind (the next row, the button the panel covered) lands there, never on the leaving ghost.\n     Focus has already gone back (Radix) and it unmounts when the exit ends. */\n  .scrim[data-state=\"closed\"],\n  .surface[data-state=\"closed\"],\n  .dialog[data-state=\"closed\"],\n  .sheet[data-state=\"closed\"] {\n    pointer-events: none;\n  }\n\n  /* modal layers */\n  .scrim {\n    position: fixed;\n    inset: 0;\n    z-index: var(--pui-z-overlay);\n    background: var(--pui-scrim);\n    backdrop-filter: blur(var(--pui-blur-overlay));\n  }\n  .scrim[data-state=\"open\"] {\n    animation: fade-in var(--pui-fade-in) var(--pui-ease-default);\n  }\n  .scrim[data-state=\"closed\"] {\n    animation: fade-out var(--pui-fade-out) var(--pui-ease-default) forwards;\n  }\n  @keyframes fade-in {\n    from {\n      opacity: 0;\n    }\n  }\n  @keyframes fade-out {\n    to {\n      opacity: 0;\n    }\n  }\n  .dialog {\n    /* the content answers to the dialog's own width (a footer's buttons share the row on a phone) */\n    container-type: inline-size;\n    position: fixed;\n    z-index: var(--pui-z-modal);\n    left: 50%;\n    top: 50%;\n    width: min(calc(100vw - var(--pui-space-8)), var(--pui-dialog-width, var(--_dialog-width, 440px)));\n    max-height: calc(100dvh - var(--pui-space-12));\n    overflow: hidden;\n    translate: -50% -50%;\n    border-radius: var(--pui-dialog-radius, var(--pui-radius-window));\n    color: var(--pui-ink);\n    outline: none;\n    /* a size chosen while open (a step that needs more room) eases the width in place, centred */\n    transition: width var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  .dialog[data-state=\"open\"] {\n    animation: dialog-in var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .dialog[data-state=\"closed\"] {\n    animation: dialog-out var(--pui-motion-out) var(--pui-ease-default) forwards;\n  }\n  /* A dialog opened from something (use-open-from.ts) grows out of it: it starts as the opener's box, offset back onto\n     it, and grows to its own place over the considered duration while its content fades in; it closes back into the\n     opener in the ruled 180 ms. Shares the source morph's moving parts and guard with .surface. */\n  .dialog[data-open-from] {\n    clip-path: inset(\n      var(--ci-t) var(--ci-r) var(--ci-b) var(--ci-l) round var(--pui-dialog-radius, var(--pui-radius-window))\n    );\n    transform: translate(var(--ci-x), var(--ci-y));\n  }\n  .dialog[data-open-from][data-state=\"open\"] {\n    animation:\n      grow-t var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-r var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-b var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-l var(--pui-duration-considered) var(--pui-ease-out-strong),\n      grow-xy calc(var(--pui-duration-considered) * 0.9) var(--pui-ease-out-strong),\n      settling var(--pui-duration-considered) step-end;\n  }\n  .dialog[data-open-from][data-state=\"open\"] > * {\n    animation: content-in var(--pui-duration-standard) var(--pui-ease-default) calc(var(--pui-duration-standard) * 0.35)\n      both;\n  }\n  .dialog[data-open-from][data-state=\"closed\"] {\n    animation: shrink var(--pui-motion-out) var(--pui-ease-exit) forwards;\n  }\n  .dialog[data-open-from][data-state=\"closed\"] > * {\n    animation: content-out var(--pui-motion-in) var(--pui-ease-default) forwards;\n  }\n  /* A sheet: a panel from an edge. It slides in from its edge over the considered duration on the drawer curve and\n     goes back out to that edge in the ruled departure (a layer closes back into where it came from). The edge is\n     logical: an end (\"right\") sheet comes from the left in a right-to-left page. Its content answers to its width. */\n  .sheet {\n    --_sx: 0px;\n    --_sy: 0px;\n    container-type: inline-size;\n    /* where a finger has swiped it toward its edge (sheet.tsx); it settles back over the departure */\n    translate: var(--_swipe, 0px) 0;\n  }\n  /* a side panel leaves sideways pans to the swipe and keeps vertical scrolling native, in its body too */\n  .sheet:is([data-side=\"left\"], [data-side=\"right\"]),\n  .sheet:is([data-side=\"left\"], [data-side=\"right\"]) [data-slot=\"sheet-body\"] {\n    touch-action: pan-y;\n  }\n  .sheet:is([data-side=\"left\"], [data-side=\"right\"]) {\n    transition:\n      var(--m-transition),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .sheet[data-swiping] {\n    transition: none;\n  }\n  .sheet[data-side=\"right\"] {\n    --_sx: 100%;\n  }\n  .sheet[data-side=\"left\"] {\n    --_sx: -100%;\n  }\n  .sheet[data-side=\"right\"]:dir(rtl) {\n    --_sx: -100%;\n  }\n  .sheet[data-side=\"left\"]:dir(rtl) {\n    --_sx: 100%;\n  }\n  .sheet[data-side=\"top\"] {\n    --_sy: -100%;\n  }\n  .sheet[data-side=\"bottom\"] {\n    --_sy: 100%;\n  }\n  .sheet[data-state=\"open\"] {\n    animation: sheet-in var(--pui-duration-considered) var(--pui-ease-drawer) both;\n  }\n  .sheet[data-state=\"closed\"] {\n    animation: sheet-out var(--pui-motion-out) var(--pui-ease-exit) forwards;\n  }\n  @keyframes sheet-in {\n    from {\n      translate: var(--_sx) var(--_sy);\n    }\n  }\n  @keyframes sheet-out {\n    to {\n      translate: var(--_sx) var(--_sy);\n    }\n  }\n  @keyframes dialog-in {\n    from {\n      opacity: 0;\n      transform: scale(0.96);\n    }\n  }\n  @keyframes dialog-out {\n    to {\n      opacity: 0;\n      transform: scale(0.98);\n    }\n  }\n}\n\n/* Reduced motion: surfaces and dialogs fade without scaling or growing, over the fade tokens (restrained under reduced,\n   1ms under none; APPEARANCE A06). */\n@media (prefers-reduced-motion: reduce) {\n  .surface,\n  .dialog,\n  .sheet {\n    animation-name: fade-in !important;\n    animation-duration: var(--pui-fade-in) !important;\n  }\n  .surface[data-state=\"closed\"],\n  .dialog[data-state=\"closed\"],\n  .sheet[data-state=\"closed\"] {\n    animation-name: fade-out !important;\n    animation-duration: var(--pui-fade-out) !important;\n  }\n}\n@layer components {\n  :global(.pui[data-motion=\"reduced\"]) .surface,\n  :global(.pui[data-motion=\"none\"]) .surface,\n  :global(.pui[data-motion=\"reduced\"]) .dialog,\n  :global(.pui[data-motion=\"none\"]) .dialog,\n  :global(.pui[data-motion=\"reduced\"]) .sheet,\n  :global(.pui[data-motion=\"none\"]) .sheet {\n    animation-name: fade-in;\n    animation-duration: var(--pui-fade-in);\n  }\n  :global(.pui[data-motion=\"reduced\"]) .surface[data-state=\"closed\"],\n  :global(.pui[data-motion=\"none\"]) .surface[data-state=\"closed\"],\n  :global(.pui[data-motion=\"reduced\"]) .dialog[data-state=\"closed\"],\n  :global(.pui[data-motion=\"none\"]) .dialog[data-state=\"closed\"],\n  :global(.pui[data-motion=\"reduced\"]) .sheet[data-state=\"closed\"],\n  :global(.pui[data-motion=\"none\"]) .sheet[data-state=\"closed\"] {\n    animation-name: fade-out;\n    animation-duration: var(--pui-fade-out);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.surface {\n  composes: m-overlay from global;\n}\n.dialog {\n  composes: m-overlay from global;\n}\n"},{"path":"beamline/components/alert/alert.module.css","type":"registry:file","target":"src/ui/beamline/components/alert/alert.module.css","content":"@layer properties, theme, base, components, utilities;\n/* the tone's tint, registered so a change of tone blends instead of switching */\n@property --pui-alert-tint {\n  syntax: \"<color>\";\n  inherits: false;\n  initial-value: transparent;\n}\n@layer components {\n  /* An alert is a surface like any other, so every look frames it (blueprint's rule, pop's ink outline, ink's pen,\n     mono's dashes, metal's pocket). The tone is a tint composed over the role's own fill, never a replacement, plus\n     the icon in the tone's colour and the words: colour is never the only signal. */\n  .alert {\n    display: flex;\n    gap: var(--pui-space-3);\n    padding: var(--pui-space-3);\n    border-radius: var(--pui-radius-card);\n    background: linear-gradient(var(--pui-alert-tint), var(--pui-alert-tint)), var(--m-surface-bg);\n    color: var(--pui-ink-2);\n    /* a change of tone (warning → resolved) blends the tint instead of switching it */\n    transition:\n      var(--m-transition),\n      --pui-alert-tint var(--pui-duration-standard) var(--pui-ease-default);\n  }\n  .alert[data-tone=\"info\"] {\n    --_tone: var(--pui-blue);\n  }\n  .alert[data-tone=\"success\"] {\n    --_tone: var(--pui-green);\n  }\n  .alert[data-tone=\"warning\"] {\n    --_tone: var(--pui-orange);\n  }\n  .alert[data-tone=\"danger\"] {\n    --_tone: var(--pui-red);\n  }\n  .alert[data-tone=\"neutral\"] {\n    --_tone: var(--pui-ink-3);\n  }\n  .alert:not([data-tone=\"neutral\"]) {\n    --pui-alert-tint: color-mix(in oklab, var(--_tone) 9%, transparent);\n  }\n  .icon {\n    display: grid;\n    flex: none;\n    margin-top: var(--pui-space-0-5);\n    color: var(--_tone);\n    transition: color var(--pui-duration-standard) var(--pui-ease-default);\n  }\n  /* a new tone's glyph arrives from a little smaller, so the change of meaning is seen as well as the colour */\n  .icon[data-changed] > svg {\n    animation: glyph var(--pui-duration-standard) var(--pui-ease-default) both;\n  }\n  @keyframes glyph {\n    from {\n      opacity: 0;\n      scale: calc(1 - 0.4 * var(--pui-motion-travel, 1));\n    }\n  }\n  .action {\n    margin-top: var(--pui-space-2);\n  }\n  /* a wide alert (from 36rem) keeps its action on the message's line, at the end; a narrow one puts it underneath */\n  .alert[data-wide] > .body {\n    display: grid;\n    grid-template-columns: minmax(0, 1fr) auto;\n    column-gap: var(--pui-space-4);\n    align-items: center;\n  }\n  .alert[data-wide] > .body > :not(.action) {\n    grid-column: 1;\n  }\n  .alert[data-wide] > .body > .action {\n    grid-column: 2;\n    grid-row: 1 / span var(--_rows, 1);\n    margin-top: 0;\n    justify-content: flex-end;\n  }\n  .icon > svg {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n  }\n  /* the fold: rows collapse to zero and the alert fades, then it unmounts; it arrives the same way, growing to its\n     height where it belongs (so what sits below glides down instead of jumping) */\n  .wrap {\n    display: grid;\n    grid-template-rows: 1fr;\n    transition:\n      grid-template-rows var(--pui-duration-standard) var(--pui-ease-standard),\n      opacity var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  @starting-style {\n    .wrap[data-state=\"open\"] {\n      grid-template-rows: 0fr;\n      opacity: 0;\n    }\n  }\n  .wrap[data-state=\"closed\"] {\n    grid-template-rows: 0fr;\n    opacity: 0;\n  }\n  /* clips the fold, with room for the look's outer edge and shadow (pop's lip, soft's drop) so they are not cut */\n  .clip {\n    min-height: 0;\n    margin: calc(var(--pui-space-1-5) * -1);\n    padding: var(--pui-space-1-5);\n    overflow: hidden;\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.alert {\n  composes: m-surface from global;\n}\n"},{"path":"beamline/components/alert/alert.tsx","type":"registry:file","target":"src/ui/beamline/components/alert/alert.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport type { CSSProperties, Ref } from \"react\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { AlertDefaults } from \"./defaults\";\nexport type { AlertDefaults } from \"./defaults\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from \"lucide-react\";\nimport { Button } from \"../button/button\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useWidthAs } from \"../../hooks/use-content-width\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport { focusAfterRemoval } from \"../../lib/focus\";\nimport styles from \"./alert.module.css\";\n\nexport type AlertTone = \"info\" | \"success\" | \"warning\" | \"danger\" | \"neutral\";\n\nexport interface AlertClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the wrap part; no override by default. */\n  wrap?: string;\n  /** Classes for the clip part; no override by default. */\n  clip?: string;\n  /** Classes for the icon part; no override by default. */\n  icon?: string;\n  /** Classes for the body part; no override by default. */\n  body?: string;\n  /** Classes for the title part; no override by default. */\n  title?: string;\n  /** Classes for the description part; no override by default. */\n  description?: string;\n  /** Classes for the action part; no override by default. */\n  action?: string;\n  /** Classes for the dismiss part; no override by default. */\n  dismiss?: string;\n}\n\nexport interface AlertProps {\n  /** Semantic colour of the displayed content. Defaults to \"info\". */\n  tone?: AlertTone;\n  /** Heading shown above the content. Defaults to no heading. */\n  title?: ReactNode;\n  /** What happened and what to do. */\n  children?: ReactNode;\n  /** Buttons or links: Retry, Update card. */\n  action?: ReactNode;\n  /** Replace the tone's icon, or false for none. */\n  icon?: ReactNode | false;\n  /** Controlled visibility; omitted by default to keep visibility locally. */\n  open?: boolean;\n  /** Initial visibility when uncontrolled; defaults to true. */\n  defaultOpen?: boolean;\n  /** Reports a request to close; no callback by default. */\n  onOpenChange?: (open: boolean) => void;\n  /** Shows a close button; defaults to whether onDismiss or onOpenChange is supplied. */\n  dismissible?: boolean;\n  /** Adds a close button; the alert folds away when open turns false. */\n  onDismiss?: () => void;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: AlertClassNames;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n}\n\nconst ICON: Record<AlertTone, ReactNode> = {\n  info: <Info />,\n  success: <CheckCircle2 />,\n  warning: <AlertTriangle />,\n  danger: <AlertCircle />,\n  neutral: <Info />,\n};\n\n/** A message that stays until it is dealt with: what happened, what to do, and the action. Danger and warning are\n *  announced as they appear; a dismissed alert folds its height away. */\nexport function Alert({\n  tone: toneProp,\n  title,\n  children,\n  action,\n  icon,\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  dismissible,\n  onDismiss,\n  className,\n  style,\n  classNames,\n  ref,\n}: AlertProps) {\n  const i18n = useMessages(\"alert\", englishMessages);\n\n  const scopeDefaults = useComponentDefaults<AlertDefaults>(\"alert\", [\"tone\"]);\n  const tone = toneProp ?? scopeDefaults.tone ?? \"info\";\n\n  // an older caller that only listens to onDismiss decides the removal itself, so closing is guarded and reported beside\n  const [open, setOpen] = useControllable(openProp, defaultOpen);\n  const canDismiss = dismissible ?? !!(onDismiss || onOpenChange);\n  const [shown, setShown] = useState(open);\n  useEffect(() => {\n    if (open) setShown(true);\n  }, [open]);\n  const host = useRef<HTMLDivElement>(null);\n  const attach = useComposedRef(host, ref);\n  // a wide alert keeps its action on the message's line (read once per change of that answer)\n  const wide = useWidthAs(host, (w) => w >= 576, false, shown);\n  // the glyph of a tone it changed to arrives visibly; the first one is simply there\n  const firstTone = useRef(tone);\n  if (!shown) return null;\n  return (\n    <div\n      data-slot=\"alert-wrap\"\n      data-state={open ? \"open\" : \"closed\"}\n      className={cn(styles.wrap, classNames?.wrap)}\n      onTransitionEnd={(e) => {\n        if (!open && e.target === e.currentTarget) setShown(false);\n      }}\n    >\n      <div className={cn(styles.clip, classNames?.clip)} data-slot=\"alert-clip\">\n        <div\n          ref={attach}\n          style={{ \"--_rows\": (title ? 1 : 0) + (children ? 1 : 0) || 1, ...style } as CSSProperties}\n          data-slot=\"alert\"\n          data-tone={tone}\n          data-wide={wide && action ? \"\" : undefined}\n          role={tone === \"danger\" || tone === \"warning\" ? \"alert\" : \"status\"}\n          className={cn(styles.alert, \"text-sm leading-relaxed\", classNames?.root, className)}\n        >\n          {icon !== false && (\n            <span\n              key={tone}\n              aria-hidden\n              data-slot=\"alert-icon\"\n              data-changed={tone !== firstTone.current ? \"\" : undefined}\n              className={cn(styles.icon, classNames?.icon)}\n            >\n              {icon ?? ICON[tone]}\n            </span>\n          )}\n          <div data-slot=\"alert-body\" className={cn(styles.body, \"grid min-w-0 flex-1 gap-0.5\", classNames?.body)}>\n            {title && (\n              <p data-slot=\"alert-title\" className={cn(\"font-medium text-ink\", classNames?.title)}>\n                {title}\n              </p>\n            )}\n            {children && (\n              <div data-slot=\"alert-description\" className={classNames?.description}>\n                {children}\n              </div>\n            )}\n            {action && (\n              <div data-slot=\"alert-action\" className={cn(styles.action, \"flex flex-wrap gap-2\", classNames?.action)}>\n                {action}\n              </div>\n            )}\n          </div>\n          {canDismiss && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              aria-label={i18n(\"dismiss\")}\n              data-slot=\"alert-dismiss\"\n              className={cn(\"-me-1.5 -mt-1 shrink-0 text-ink-3\", classNames?.dismiss)}\n              onClick={(e) => {\n                focusAfterRemoval(e.currentTarget.closest(\"[data-slot=alert-wrap]\"));\n                // Existing event-only callers still decide when removal happens; the new state API can close locally.\n                if (dismissible !== undefined || onOpenChange !== undefined) setOpen(false);\n                onOpenChange?.(false);\n                onDismiss?.();\n              }}\n            >\n              <X />\n            </Button>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default Alert;\n"},{"path":"beamline/components/alert/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/alert/defaults.ts","content":"/** Pure allowed presentation defaults for Alert; no component or runtime imports. */\nexport interface AlertDefaults {\n  /** Shared tone setting; omit to inherit the surrounding scope or the component default. */\n  tone?: \"danger\" | \"success\" | \"warning\" | \"info\" | \"neutral\";\n}\n"},{"path":"beamline/components/alert/messages.ts","type":"registry:file","target":"src/ui/beamline/components/alert/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  dismiss: \"Dismiss\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/button/button-variants.ts","type":"registry:file","target":"src/ui/beamline/components/button/button-variants.ts","content":"import { cn } from \"../../lib/utils\";\nimport styles from \"./button.module.css\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"danger\" | \"accent\";\n/** sm 32 · md 36 · lg 40 · touch 44 (a bottom action bar on a phone). */\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"touch\";\n/** Market side: buy fills with --pui-up, sell with --pui-down (a red-up market flips the two tokens, not the code). */\nexport type ButtonTone = \"up\" | \"down\";\n\n/** Which paint role paints each variant: filled variants are solids, the quiet ones keep their own light paint. */\nconst ROLE: Record<ButtonVariant, string | undefined> = {\n  primary: \"m-solid\",\n  accent: \"m-solid\",\n  secondary: \"m-control\",\n  outline: undefined,\n  ghost: undefined,\n  danger: undefined,\n};\n\n/** A variant's paint without the button's layout, for a group that wears one button's look (SplitButton paints the\n *  pair as one button and leaves its halves transparent). */\nexport function buttonPaint(variant: ButtonVariant = \"primary\", tone?: ButtonTone) {\n  return cn(ROLE[variant], styles[variant], tone && styles[tone]);\n}\n\n/** The classes that make any element look like a Button, for parts that render their own element. */\nexport function buttonClassName({\n  variant = \"primary\",\n  size = \"md\",\n  tone,\n  className,\n}: { variant?: ButtonVariant; size?: ButtonSize; tone?: ButtonTone; className?: string } = {}) {\n  return cn(styles.root, ROLE[variant], styles[variant], tone && styles[tone], styles[size], className);\n}\n"},{"path":"beamline/components/button/button.module.css","type":"registry:file","target":"src/ui/beamline/components/button/button.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Button. Every colour, size and duration is a token; a variant only sets the four local properties below. */\n  .root {\n    --_button-bg: transparent;\n    --_button-bg-hover: var(--pui-hover);\n    --_button-fg: var(--pui-ink);\n    --_button-edge: none;\n    --_button-height: var(--pui-control-height-md);\n    --_button-pad: 14px;\n    position: relative;\n    isolation: isolate;\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    gap: var(--pui-space-1-5);\n    flex-shrink: 0;\n    height: var(--_button-height);\n    min-width: var(--_button-height);\n    /* never wider than its room: in a narrow card the label ends in an ellipsis instead of spilling (the full words\n       stay the accessible name) */\n    max-width: 100%;\n    padding-inline: var(--_button-pad);\n    border: 0;\n    /* Nested context: inside a frame that sets --pui-nest-radius and --pui-nest-inset (and --pui-nest-ceiling for pills)\n       the corner is the shared nested corner (.nest-corner, utilities.css: radius − inset, or the button's own radius\n       where that would fall under the chip radius); elsewhere the control radius. --pui-button-radius wins over both. */\n    border-radius: var(--pui-button-radius, var(--pui-nested, var(--pui-radius-control)));\n    color: var(--_button-fg);\n    font-family: var(--pui-font-body);\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-medium);\n    line-height: 1;\n    letter-spacing: var(--pui-tracking-body);\n    white-space: nowrap;\n    text-decoration: none;\n    cursor: pointer;\n    user-select: none;\n    -webkit-tap-highlight-color: transparent;\n    /* frequency law: hover arrives at 0ms (below), leaves over the ruled departure; opacity and translate are listed\n       so a button that waits for attention (.reveal) fades away like every reveal instead of vanishing */\n    transition:\n      var(--m-transition),\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  /* the quiet variants paint themselves; filled and secondary variants are painted by their look role (m-solid,\n   m-control), which these zero-specificity rules can never override */\n  :where(.root) {\n    background: var(--_button-bg);\n    box-shadow: var(--_button-edge);\n  }\n  :where(.root:hover:not(:disabled, [aria-disabled=\"true\"])) {\n    background: var(--_button-bg-hover);\n  }\n  .root:hover:not(:disabled, [aria-disabled=\"true\"]) {\n    transition-duration: 0ms;\n  }\n  /* the asChild form has no motion: a CSS press instead */\n  .root:not(button):active {\n    transform: scale(0.97);\n  }\n  .root:disabled,\n  .root[aria-disabled=\"true\"]:not([data-loading]) {\n    opacity: var(--pui-opacity-disabled);\n    cursor: not-allowed;\n  }\n  .root[data-loading] {\n    cursor: progress;\n  }\n\n  /* Icons: 16px unless the icon says otherwise (zero specificity, so size-5 or an inline size wins) */\n  :where(.root) :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n    flex-shrink: 0;\n  }\n\n  .sm {\n    --_button-height: var(--pui-control-height-sm);\n    --_button-pad: var(--pui-space-2-5);\n    /* the base gap (--pui-space-1-5); 5px had no scale step, and 6px also lets the loader's negative margin cancel\n       the gap exactly at every size */\n  }\n  .md {\n    --_button-height: var(--pui-control-height-md);\n    --_button-pad: 14px;\n  }\n  .lg {\n    --_button-height: var(--pui-control-height-lg);\n    --_button-pad: var(--pui-space-4);\n    font-size: var(--pui-text-base);\n  }\n  /* a bottom action bar on a phone: the 44 px touch target (a platform minimum, not a spacing step, so it holds when\n     the spacing parameter changes — O1 §14.2), the large label */\n  .touch {\n    --_button-height: calc(var(--pui-control-height-lg) + 0.25rem);\n    --_button-pad: var(--pui-space-5);\n    font-size: var(--pui-text-base);\n  }\n  .root[data-icon-only] {\n    padding-inline: 0;\n    width: var(--_button-height);\n  }\n\n  /* the main action: ink on the page, inverted per theme */\n  .primary {\n    --m-color: var(--pui-ink);\n    --_button-fg: var(--pui-ink-inverse);\n  }\n  .secondary {\n    --_button-fg: var(--pui-ink);\n  }\n  .outline {\n    --_button-edge: inset 0 0 0 1px var(--pui-line-strong);\n    --_button-bg-hover: var(--pui-hover);\n  }\n  .ghost {\n    --_button-fg: var(--pui-ink-2);\n    --_button-bg-hover: var(--pui-hover);\n  }\n  .ghost:hover:not(:disabled) {\n    color: var(--pui-ink);\n  }\n  .danger {\n    --_button-fg: var(--pui-red);\n    --_button-edge: inset 0 0 0 1px color-mix(in oklab, var(--pui-red) 42%, transparent);\n    --_button-bg-hover: var(--pui-red-tint);\n  }\n  .accent {\n    --m-color: var(--pui-accent);\n    --_button-fg: var(--pui-accent-foreground);\n  }\n\n  /* Market side. Declared after the variants so it wins: a filled variant takes the side's colour and its on-colour\n     ink; a quiet variant keeps its paint and colours the label. */\n  .up {\n    --m-color: var(--pui-up);\n    --_button-fg: var(--pui-up);\n  }\n  .down {\n    --m-color: var(--pui-down);\n    --_button-fg: var(--pui-down);\n  }\n  .ghost:is(.up, .down):hover:not(:disabled) {\n    color: var(--_button-fg);\n  }\n  .primary.up,\n  .accent.up {\n    --_button-fg: var(--pui-on-up);\n  }\n  .primary.down,\n  .accent.down {\n    --_button-fg: var(--pui-on-down);\n  }\n\n  /* The main actions' light, where the look provides one, as a spectrum. Glow spills a soft light in the brand's\n     spectrum from under the button's bottom edge (clipped at it, so it never tints the face) that brightens under the\n     pointer. Prism keeps it inside the glass (--m-solid-glow-fit: 1): the face the light leaves by, the bottom edge,\n     splits it into a crisp line with a faint wash rising into the face, only while pointed at. Every other look\n     leaves --m-solid-glow at none, so the layer does not exist. */\n  .primary::after,\n  .accent::after {\n    --_fit: var(--m-solid-glow-fit, 0);\n    content: var(--m-solid-glow);\n    position: absolute;\n    z-index: -1;\n    top: calc((1 - var(--_fit)) * (100% + var(--m-solid-glow-gap, 0px)));\n    inset-inline: var(--m-solid-glow-inset, 14%);\n    height: var(--m-solid-glow-height, 14px);\n    border-radius: calc(\n      var(--_fit) * var(--pui-button-radius, var(--pui-nested, var(--pui-radius-control))) + (1 - var(--_fit)) *\n        var(--pui-radius-pill)\n    );\n    background: var(--m-spectrum);\n    background-size: var(--m-spectrum-size, 100% 100%);\n    /* the part of the spectrum that shows: this button's own angle (--m-prism-seed, new at every press), turned by\n       where the pointer is across the face (--m-prism-x) in a look that turns it (--m-solid-glow-turn) */\n    background-position: calc((var(--m-prism-seed, 0.3) + var(--m-prism-x, 0.5) * var(--m-solid-glow-turn, 0)) * 100%) 0;\n    filter: blur(var(--m-solid-glow-blur, 9px));\n    clip-path: var(--m-solid-glow-clip, inset(0 -32px -32px -32px));\n    mask: var(--m-solid-glow-mask, none);\n    mask-position:\n      0 0,\n      calc(var(--m-prism-seed, 0.3) * 100%) 0;\n    mask-composite: intersect;\n    opacity: var(--m-solid-glow-rest, 0.75);\n    scale: var(--m-solid-glow-scale, 1) 1;\n    pointer-events: none;\n    transition:\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      scale var(--pui-motion-out) var(--pui-ease-default),\n      background-position var(--pui-duration-considered) var(--pui-ease-default),\n      mask-position var(--pui-duration-considered) var(--pui-ease-default);\n  }\n  /* pointed at: the light brightens (glow) or grows out from the centre and drifts along while the pointer stays\n     (prism) */\n  .primary:hover::after,\n  .accent:hover::after {\n    opacity: 1;\n    scale: 1 1;\n    transition-duration: var(--m-solid-glow-in, 0ms);\n    animation: var(--m-solid-glow-flow, none);\n  }\n  /* A change of word or colour (Save → Saved, a tone turning green) passes a band of split light across the face once,\n     in a look that has one (prism: --m-solid-shift-light; the pen hook's ::before is free on a solid otherwise). */\n  .primary[data-shift]::before,\n  .accent[data-shift]::before {\n    content: var(--m-solid-shift-light, none);\n    position: absolute;\n    inset: 0;\n    z-index: -1;\n    border: 0;\n    border-radius: inherit;\n    background: var(--m-spectrum) 0 0 / var(--m-spectrum-size, 100% 100%);\n    mask: linear-gradient(105deg, transparent 39%, #000 47.5%, #000 52.5%, transparent 61%) 0 0 / 300% 100% no-repeat;\n    opacity: 0;\n    animation: prism-shift calc(var(--pui-duration-considered) * 2.2) var(--pui-ease-default);\n    pointer-events: none;\n  }\n  @keyframes prism-shift {\n    from {\n      mask-position: 100% 0;\n      opacity: 0.25;\n    }\n    35% {\n      opacity: 0.55;\n    }\n    to {\n      mask-position: 0 0;\n      opacity: 0;\n    }\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .primary::after,\n    .accent::after {\n      transition: none;\n      animation: none !important;\n    }\n    .primary[data-shift]::before,\n    .accent[data-shift]::before {\n      content: none;\n    }\n  }\n  .root:disabled::after,\n  .root[aria-disabled=\"true\"]::after {\n    content: none;\n  }\n\n  /* The loader opens its own width, and the negative margin cancels the gap so the label does not jump */\n  .loader {\n    display: inline-flex;\n    overflow: hidden;\n    margin-inline-end: calc(var(--pui-space-1-5) * -1);\n  }\n  .loader > * {\n    margin-inline-end: var(--pui-space-1-5);\n  }\n  .spinner {\n    color: currentColor;\n  }\n  .root[data-icon-only] .loader {\n    margin-inline-end: 0;\n  }\n  .root[data-icon-only] .loader > * {\n    margin-inline-end: 0;\n  }\n\n  /* The label holds its place while the text inside swaps; the leaving text pops out of flow against it */\n  .label {\n    position: relative;\n    display: inline-flex;\n    justify-content: center;\n    min-width: 0;\n    overflow: visible;\n  }\n  /* the clip box is let out by a little over a line so descenders are never cut, without changing the layout */\n  .text {\n    display: inline-block;\n    max-width: 100%;\n    margin-block: -0.2em;\n    padding-block: 0.2em;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .label[data-morphing] > .text {\n    overflow: visible;\n    text-overflow: clip;\n  }\n}\n\n@layer components {\n  :global(.pui[data-motion=\"reduced\"]) .root:not(button):active,\n  :global(.pui[data-motion=\"none\"]) .root:not(button):active {\n    transform: none;\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .root:not(button):active {\n      transform: none;\n    }\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.root {\n  composes: nest-corner from global;\n}\n"},{"path":"beamline/components/button/button.tsx","type":"registry:file","target":"src/ui/beamline/components/button/button.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport {\n  Children,\n  Fragment,\n  isValidElement,\n  useLayoutEffect,\n  useRef,\n  type ButtonHTMLAttributes,\n  type PointerEvent,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  type ComponentProps,\n} from \"react\";\nimport { AnimatePresence, LazyMotion, m, spring, useIsPresent } from \"motion/react\";\nimport { animate } from \"motion/mini\";\nimport * as Slot from \"@radix-ui/react-slot\";\nimport { Spinner } from \"../spinner/spinner\";\nimport type { BuiltInLook as Look } from \"../../runtime/preset\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./button.module.css\";\n\nexport type { ButtonVariant, ButtonSize, ButtonTone } from \"./button-variants\";\nexport { buttonPaint, buttonClassName } from \"./button-variants\";\nimport { buttonClassName, type ButtonVariant, type ButtonSize, type ButtonTone } from \"./button-variants\";\nimport type { ButtonDefaults } from \"./defaults\";\nexport type { ButtonDefaults } from \"./defaults\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\n\ntype NativeProps = Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\"\n>;\n\nexport interface ButtonProps extends NativeProps {\n  /** Visual weight: primary (ink-filled), secondary, outline, ghost, danger, or accent (filled with the brand). */\n  variant?: ButtonVariant;\n  /** Height 32 / 36 / 40 px; touch 44 px for a bottom action bar on a phone. */\n  size?: ButtonSize;\n  /** Market side. On primary/accent the fill becomes --pui-up (buy) or --pui-down (sell); on the quiet variants the\n   *  label takes that colour. Pair it with a word or arrow: colour is never the only signal. */\n  tone?: ButtonTone;\n  /** Shows a loader in front of the label, sets aria-busy and ignores clicks; size and focus stay. */\n  loading?: boolean;\n  classNames?: Partial<Record<\"root\" | \"label\" | \"loader\", string>>;\n  /** Paint this button in another look than the one in force around it (DESIGN-LANGUAGE.md \"Looks\"). */\n  look?: Look;\n  /** Render the single child (a link, a Radix trigger) with the button's look instead of a <button>. */\n  asChild?: boolean;\n  /** A square button holding only an icon (give it an aria-label). Decided by the children when left out: icon-only when\n   *  no words sit anywhere among them (an icon, or a frame of icons), so `<span>Close</span>` is a label. */\n  iconOnly?: boolean;\n  ref?: Ref<HTMLButtonElement>;\n}\n\n// Motion's animation features arrive in a deferred chunk (AT11): the button renders and works at once, and its press,\n// loader and label motion run once they land, a frame or two after mount. Buttons that mount after that get the\n// loaded features directly, so they render once instead of again when a lazy load resolves.\ntype Features = typeof import(\"./motion-features\").default;\nlet loaded: Features | null = null;\nconst features = () =>\n  import(\"./motion-features\").then((module) => {\n    loaded = module.default;\n    return loaded;\n  });\n\n/** Children with fragments opened up, so `{busy ? <><Icon />Saving</> : …}` reads as icon plus text. */\nconst flat = (children: ReactNode): ReactNode[] =>\n  Children.toArray(children).flatMap((c) =>\n    isValidElement<{ children?: ReactNode }>(c) && c.type === Fragment ? flat(c.props.children) : [c],\n  );\n\n/** Whether any words sit anywhere inside (an icon, or a frame of icons, has none). */\nfunction hasText(node: ReactNode): boolean {\n  if (typeof node === \"string\") return node.trim() !== \"\";\n  if (typeof node === \"number\") return true;\n  if (Array.isArray(node)) return node.some(hasText);\n  return isValidElement<{ children?: ReactNode }>(node) && hasText(node.props.children);\n}\n\n/** Splits children into the text run (the label that can change) and everything else (icons), in order, and says\n *  whether any element carries content of its own (a `<span>Close</span>` label, unlike an icon). */\nfunction split(children: ReactNode) {\n  const parts = flat(children);\n  const text = parts\n    .filter((p) => typeof p === \"string\" || typeof p === \"number\")\n    .join(\"\")\n    .trim();\n  const firstText = parts.findIndex((p) => typeof p === \"string\" || typeof p === \"number\");\n  const before = firstText === -1 ? parts : parts.slice(0, firstText).filter(isValidElement);\n  const after = firstText === -1 ? [] : parts.slice(firstText).filter(isValidElement);\n  const content = parts.some((p) => isValidElement<{ children?: ReactNode }>(p) && hasText(p.props.children));\n  return { text, before, after, content };\n}\n\n/**\n * The action control. Hover answers at once and lets go over --pui-motion-out; a press scales down at once, or swells\n * on the snappy spring where the look's --pui-press-scale is above 1 (glass), never on a trigger that anchors a\n * floating layer, which would measure a resized box; a changed label slides in while the\n * width follows on a critically damped spring; loading keeps the size and the focus.\n */\nexport function Button({\n  variant: variantProp,\n  size: sizeProp,\n  tone: toneProp,\n  loading = false,\n  look,\n  asChild = false,\n  iconOnly: iconOnlyProp,\n  className,\n  classNames,\n  children,\n  type,\n  onClick,\n  onPointerMove,\n  onPointerDown,\n  disabled,\n  ref,\n  ...props\n}: ButtonProps) {\n  const i18n = useMessages(\"button\", englishMessages);\n\n  const defaults = useComponentDefaults<ButtonDefaults>(\"button\", [\"variant\", \"size\", \"tone\"]);\n  const variant = variantProp ?? defaults.variant ?? \"primary\";\n  const size = sizeProp ?? defaults.size ?? \"md\";\n  const tone = toneProp ?? defaults.tone;\n  const host = useRef<HTMLButtonElement>(null);\n  const attach = useComposedRef(host, ref);\n  const motionTokens = useMotionTokens(host);\n  const requestedReduced = useReducedMotionSafe();\n  const reduced = requestedReduced || motionTokens.reduced;\n  // Reduced motion keeps a restrained fade; none removes it (APPEARANCE A06).\n  const fade = motionTokens.mode === \"none\" ? 0 : motionTokens.fade.in;\n  // a caller's aria-disabled keeps the button focusable (focus never drops after Save resolves) but inert to presses\n  const softDisabled = props[\"aria-disabled\"] === true || props[\"aria-disabled\"] === \"true\";\n  const { text, before, after, content } = split(children);\n  const prism = usePrismLight(host, variant === \"primary\" || variant === \"accent\", `${text}|${tone}|${variant}`);\n  // with asChild the text sits inside the child (the link), not among our own children\n  const inner = asChild && isValidElement<{ children?: ReactNode }>(children) ? split(children.props.children) : null;\n  const iconOnly = iconOnlyProp ?? (inner ? !inner.text && !inner.content : !text && !content);\n  const anchor =\n    (props[\"aria-haspopup\"] != null && props[\"aria-haspopup\"] !== false && props[\"aria-haspopup\"] !== \"false\") ||\n    \"data-state\" in props;\n  const shared = {\n    \"data-slot\": \"button\",\n    \"data-variant\": variant,\n    \"data-size\": size,\n    \"data-tone\": tone,\n    \"data-icon-only\": iconOnly ? \"\" : undefined,\n    \"data-loading\": loading ? \"\" : undefined,\n    \"data-look\": look,\n    className: buttonClassName({ variant, size, tone, className: cn(classNames?.root, className) }),\n  };\n\n  if (asChild)\n    return (\n      <Slot.Root {...shared} {...props} ref={attach}>\n        {children}\n      </Slot.Root>\n    );\n\n  const button = (\n    <m.button\n      ref={attach}\n      type={type ?? \"button\"}\n      {...shared}\n      {...props}\n      disabled={disabled}\n      aria-busy={loading || undefined}\n      aria-disabled={loading || softDisabled || undefined}\n      onPointerMove={(e) => {\n        prism.turn(e);\n        onPointerMove?.(e);\n      }}\n      onPointerDown={(e) => {\n        prism.press();\n        onPointerDown?.(e);\n      }}\n      onClick={(e) => {\n        if (loading || softDisabled) {\n          e.preventDefault();\n          return;\n        }\n        onClick?.(e);\n      }}\n      whileTap={\n        reduced || anchor || disabled || loading || softDisabled\n          ? undefined\n          : {\n              scale: motionTokens.pressScale,\n              // a press that sinks lands at once; one that swells toward the finger (glass) grows on the spring\n              transition: motionTokens.pressScale > 1 ? motionTokens.spring.snappy : { duration: 0 },\n            }\n      }\n      transition={{ scale: motionTokens.spring.snappy }}\n    >\n      <AnimatePresence initial={false}>\n        {loading && (\n          <m.span\n            key=\"loader\"\n            data-slot=\"button-loader\"\n            className={cn(styles.loader, classNames?.loader)}\n            initial={reduced ? { opacity: 0 } : { width: 0, opacity: 0 }}\n            animate={{ width: \"auto\", opacity: 1 }}\n            exit={reduced ? { opacity: 0 } : { width: 0, opacity: 0 }}\n            transition={{ duration: reduced ? fade : motionTokens.duration.standard, ease: motionTokens.ease.default }}\n          >\n            <Spinner size=\"sm\" label={i18n(\"working\")} className={styles.spinner} />\n          </m.span>\n        )}\n      </AnimatePresence>\n      {iconOnly ? !loading && before : before}\n      {!iconOnly && text && (\n        <Label text={text} reduced={!!reduced} fade={fade} motionTokens={motionTokens} className={classNames?.label} />\n      )}\n      {after}\n    </m.button>\n  );\n  return <LazyMotion features={loaded ?? features}>{button}</LazyMotion>;\n}\n\n/**\n * The main action's light inside the glass, in a look that draws one (prism): its own angle from the start, turned by\n * where the pointer is across the face, a new angle at every press, and a sweep of split light (data-shift) when the\n * button's word, tone or variant changes after it first appears. Writes two style properties and one attribute on the\n * button; nothing runs at rest. Looks without this light ignore all three.\n */\nfunction usePrismLight(host: RefObject<HTMLButtonElement | null>, solid: boolean, state: string) {\n  const frame = useRef(0);\n  const shown = useRef(state);\n  const ending = useRef(0);\n  useLayoutEffect(() => {\n    const el = host.current;\n    if (el && solid) el.style.setProperty(\"--m-prism-seed\", Math.random().toFixed(3));\n  }, [host, solid]);\n  useLayoutEffect(() => {\n    const el = host.current;\n    if (!el || !solid || shown.current === state) return;\n    shown.current = state;\n    // restart the sweep if it is still running\n    delete el.dataset.shift;\n    void el.offsetWidth;\n    el.dataset.shift = \"\";\n    clearTimeout(ending.current);\n    ending.current = window.setTimeout(() => delete el.dataset.shift, 1200);\n  }, [host, solid, state]);\n  useLayoutEffect(\n    () => () => {\n      clearTimeout(ending.current);\n      cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n  return {\n    turn: (e: PointerEvent<HTMLButtonElement>) => {\n      if (!solid || e.pointerType !== \"mouse\" || frame.current) return;\n      const el = e.currentTarget;\n      const x = e.clientX;\n      frame.current = requestAnimationFrame(() => {\n        frame.current = 0;\n        const r = el.getBoundingClientRect();\n        if (r.width) el.style.setProperty(\"--m-prism-x\", ((x - r.left) / r.width).toFixed(3));\n      });\n    },\n    press: () => {\n      if (solid) host.current?.style.setProperty(\"--m-prism-seed\", Math.random().toFixed(3));\n    },\n  };\n}\n\n/** The text run. When it changes, the old words leave upward and the new ones rise in, while the width moves from the\n *  old measure to the new one; no measuring happens while the label is unchanged. */\nfunction Label({\n  text,\n  reduced,\n  fade,\n  motionTokens,\n  className,\n}: {\n  text: string;\n  reduced: boolean;\n  /** Seconds of the label crossfade under reduced motion (0 under none). */\n  fade: number;\n  /** The button's own, resolved at the button: the label sits inside it, in the same look and motion. */\n  motionTokens: ReturnType<typeof useMotionTokens>;\n  className?: string;\n}) {\n  const slot = useRef<HTMLSpanElement>(null);\n  const latest = useRef(motionTokens);\n  useLayoutEffect(() => {\n    latest.current = motionTokens;\n  }, [motionTokens]);\n  const last = useRef<{ text: string; width: number } | null>(null);\n  useLayoutEffect(() => {\n    const el = slot.current;\n    if (!el) return;\n    const prev = last.current;\n    // first mount: read the width after paint, when layout is already clean, instead of forcing a layout per button\n    if (!prev) {\n      last.current = { text, width: Number.NaN };\n      const id = requestAnimationFrame(() => {\n        if (last.current?.text === text) last.current = { text, width: el.getBoundingClientRect().width };\n      });\n      return () => cancelAnimationFrame(id);\n    }\n    el.style.width = \"\";\n    const width = el.getBoundingClientRect().width;\n    last.current = { text, width };\n    // a label that is not rendered (a hidden toolbar on a phone) has nothing to animate\n    if (prev.text === text || reduced || Number.isNaN(prev.width) || !el.getClientRects().length) return;\n    // The same spring, played natively: motion/mini samples the generator into a linear() easing.\n    const { visualDuration, bounce } = latest.current.spring.morph;\n    // while the width eases the words are clipped, not cut short with an ellipsis (that is for a room too narrow)\n    el.dataset.morphing = \"\";\n    const run = animate(el, { width: [`${prev.width}px`, `${width}px`] }, { type: spring, visualDuration, bounce });\n    run.then(() => {\n      el.style.width = \"\";\n      delete el.dataset.morphing;\n    });\n    // cancel, not stop: stop commits the running value, which throws once the element stops being rendered, and the\n    // next change measures afresh anyway\n    return () => {\n      run.cancel();\n      el.style.width = \"\";\n      delete el.dataset.morphing;\n    };\n  }, [text, reduced]);\n  // A label that changes faster than its words can rise in (a count read live while a hand drags) shows each new\n  // value at once: otherwise every value would start invisible and the button would read blank for the whole drag.\n  const rapid = useRapid(text, motionTokens.duration.standard * 1000);\n  const enter = reduced ? { opacity: 0 } : { opacity: 0, y: \"0.45em\", filter: `blur(${motionTokens.blur.subtle}px)` };\n  const leave = reduced\n    ? { opacity: 0 }\n    : {\n        opacity: 0,\n        y: \"-0.45em\",\n        filter: `blur(${motionTokens.blur.subtle}px)`,\n        transition: { duration: motionTokens.duration.fast },\n      };\n  return (\n    <span ref={slot} data-slot=\"button-label\" className={cn(styles.label, className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={rapid}>\n        <Words\n          key={text}\n          className={styles.text}\n          custom={rapid}\n          variants={{\n            enter: (fast: boolean) => (fast ? { opacity: 1, y: \"0em\", filter: \"blur(0px)\" } : enter),\n            shown: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n            leave: (fast: boolean) => (fast ? { opacity: 0, transition: { duration: 0 } } : leave),\n          }}\n          initial=\"enter\"\n          animate=\"shown\"\n          exit=\"leave\"\n          transition={{ duration: reduced ? fade : motionTokens.duration.standard, ease: motionTokens.ease.default }}\n        >\n          {text}\n        </Words>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Whether `text` changed again within `ms` of its last change. Read in render: the answer belongs to this text. */\nfunction useRapid(text: string, ms: number) {\n  const seen = useRef({ text, at: Number.NEGATIVE_INFINITY, rapid: false });\n  if (seen.current.text !== text) {\n    const at = performance.now();\n    seen.current = { text, at, rapid: at - seen.current.at < ms };\n  }\n  return seen.current.rapid;\n}\n\n/** One label's words. Leaving, they are already gone for assistive tech: the button's name is the new label at once,\n *  not both while the old words animate away (or for good, where time stands still in a test). */\nfunction Words(props: ComponentProps<typeof m.span>) {\n  const present = useIsPresent();\n  return <m.span {...props} aria-hidden={present ? undefined : true} />;\n}\n\nexport default Button;\n"},{"path":"beamline/components/button/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/button/defaults.ts","content":"import type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-variants\";\n/** Scoped design choices only; no loading, events or domain state. */\nexport interface ButtonDefaults {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  tone?: ButtonTone;\n}\n"},{"path":"beamline/components/button/messages.ts","type":"registry:file","target":"src/ui/beamline/components/button/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  working: \"Working\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/button/motion-features.ts","type":"registry:file","target":"src/ui/beamline/components/button/motion-features.ts","content":"// REASON: Motion's DOM animation features (tween, spring, presence, press) are loaded after the first paint, so a\n// Button costs a small part of Motion on first load; LazyMotion in button.tsx renders until they arrive.\nimport { domAnimation } from \"motion/react\";\n\nexport default domAnimation;\n"},{"path":"beamline/components/rich-text-editor/messages.ts","type":"registry:file","target":"src/ui/beamline/components/rich-text-editor/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  emptyTask: \"empty task item\",\n  taskCheckbox: \"Task item checkbox for {text}\",\n  insertBlock: \"Insert block\",\n  basicBlocks: \"Basic blocks\",\n  noBlockMatches: \"No block matches “{query}”\",\n  formatting: \"Formatting\",\n  pasteOrTypeALink: \"Paste or type a link\",\n  link: \"Link\",\n  applyLink: \"Apply link\",\n  removeLink: \"Remove link\",\n  boldB: \"Bold (⌘B)\",\n  italicI: \"Italic (⌘I)\",\n  strikethroughS: \"Strikethrough (⌘⇧S)\",\n  inlineCodeE: \"Inline code (⌘E)\",\n  linkK: \"Link (⌘K)\",\n  colour: \"Colour\",\n  turnInto: \"Turn into\",\n  text: \"Text\",\n  text2: \"{name} text\",\n  a: \"A\",\n  background: \"Background\",\n  background2: \"{name} background\",\n  placeholder: \"Write, or type / for blocks…\",\n  blockHint: \"Type / for blocks\",\n  imageUploadFailed: \"Image upload failed\",\n  addABlockBelow: \"Add a block below\",\n  dragToMove: \"Drag to move\",\n  retryImageUpload: \"Retry image upload\",\n  ariaLabel: \"Editor\",\n  plainParagraph: \"Plain paragraph\",\n  heading1: \"Heading 1\",\n  bigSectionTitle: \"Big section title\",\n  heading2: \"Heading 2\",\n  mediumSectionTitle: \"Medium section title\",\n  heading3: \"Heading 3\",\n  smallSectionTitle: \"Small section title\",\n  bulletedList: \"Bulleted list\",\n  simpleList: \"Simple list\",\n  numberedList: \"Numbered list\",\n  listWithNumbers: \"List with numbers\",\n  toDoList: \"To-do list\",\n  trackTasks: \"Track tasks\",\n  quote: \"Quote\",\n  captureAQuote: \"Capture a quote\",\n  callout: \"Callout\",\n  makeItStandOut: \"Make it stand out\",\n  code: \"Code\",\n  codeWithHighlighting: \"Code with highlighting\",\n  divider: \"Divider\",\n  separateSections: \"Separate sections\",\n  image: \"Image\",\n  uploadOrEmbed: \"Upload or embed\",\n  default: \"Default\",\n  grey: \"Grey\",\n  red: \"Red\",\n  orange: \"Orange\",\n  green: \"Green\",\n  blue: \"Blue\",\n  violet: \"Violet\",\n  none: \"None\",\n  yellow: \"Yellow\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/rich-text-editor/rich-text-editor.module.css","type":"registry:file","target":"src/ui/beamline/components/rich-text-editor/rich-text-editor.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A writing surface in the manner of Notion: blocks with room between them, a clear type ladder, nested lists that\n   change marker by depth, checkable to-dos, callouts, highlighted code. Every colour is a token. */\n  .root {\n    --pui-gutter: 44px; /* room for the block handle: + and grip */\n    /* the editor answers to the room it is given (a side panel, a card, a page), not to the window */\n    container: rich-text / inline-size;\n    position: relative;\n    min-width: 0;\n    border-radius: var(--pui-radius-card);\n    color: var(--pui-ink);\n    /* §14.2 exception, pending a foundation step: DESIGN-LANGUAGE sets reading surfaces at 15px, between the\n       base (14) and lg (16) steps; V1 is asked for --pui-text-reading */\n    font-size: var(--pui-text-reading, 15px);\n    line-height: 1.7;\n  }\n  /* the writing surface shows keyboard focus like any field: a ring on the editor, never on the text itself */\n  .root:has(:global(.ProseMirror):focus-visible) {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: 2px;\n  }\n  .root :global(.ProseMirror) {\n    min-height: 8rem;\n    padding: var(--pui-space-1-5) var(--pui-gutter) var(--pui-space-6);\n    outline: none;\n    caret-color: var(--pui-accent);\n    word-break: break-word;\n    white-space: pre-wrap;\n    font-feature-settings: \"cv11\", \"ss01\";\n  }\n  .root :global(.ProseMirror) > * + * {\n    margin-top: 0.35em;\n  }\n  .root :global(.ProseMirror) p {\n    margin: 0;\n  }\n\n  /* headings: a ladder, with more room above than below so they belong to what follows */\n  .root :global(.ProseMirror) :is(h1, h2, h3) {\n    color: var(--pui-ink);\n    font-family: var(--pui-font-display);\n    font-stretch: var(--pui-heading-stretch);\n    letter-spacing: var(--pui-tracking-display);\n    line-height: 1.25;\n    text-wrap: balance;\n  }\n  .root :global(.ProseMirror) h1 {\n    margin: 1.3em 0 0.25em;\n    font-size: 1.875em;\n    font-weight: var(--pui-weight-title);\n  }\n  .root :global(.ProseMirror) h2 {\n    margin: 1.15em 0 0.2em;\n    font-size: 1.45em;\n    font-weight: var(--pui-weight-title);\n  }\n  .root :global(.ProseMirror) h3 {\n    margin: 1em 0 0.15em;\n    font-size: 1.18em;\n    font-weight: var(--pui-weight-semibold);\n  }\n  .root :global(.ProseMirror) > :first-child {\n    margin-top: 0;\n  }\n\n  /* inline marks */\n  .root :global(.ProseMirror) strong {\n    font-weight: var(--pui-weight-semibold);\n  }\n  .root :global(.ProseMirror) s {\n    color: var(--pui-ink-3);\n    text-decoration-color: currentColor;\n  }\n  .root :global(.ProseMirror) a {\n    color: var(--pui-accent-ink, var(--pui-accent));\n    text-decoration: underline;\n    text-decoration-color: color-mix(in oklab, currentColor 40%, transparent);\n    text-underline-offset: 3px;\n    cursor: text;\n  }\n  .root :global(.ProseMirror) :not(pre) > code {\n    padding: 0.12em 0.34em;\n    border-radius: var(--pui-radius-chip);\n    background: var(--pui-inset);\n    color: var(--pui-red);\n    font-family: var(--pui-font-code);\n    font-size: 0.86em;\n  }\n  .root :global(.ProseMirror) mark {\n    padding: 0.05em 0.1em;\n    border-radius: var(--pui-radius-chip);\n    background: color-mix(in oklab, var(--pui-orange) 26%, transparent);\n    color: inherit;\n  }\n\n  /* lists: markers change with depth, the way outlines do */\n  .root :global(.ProseMirror) :is(ul, ol) {\n    margin-top: 0.2em;\n    padding-inline-start: 1.55em;\n  }\n  .root :global(.ProseMirror) li {\n    margin: 0.15em 0;\n    padding-inline-start: 0.15em;\n  }\n  .root :global(.ProseMirror) li > p {\n    margin: 0;\n  }\n  .root :global(.ProseMirror) li::marker {\n    color: var(--pui-ink-3);\n  }\n  .root :global(.ProseMirror) ul {\n    list-style: disc;\n  }\n  .root :global(.ProseMirror) ul ul {\n    list-style: circle;\n  }\n  .root :global(.ProseMirror) ul ul ul {\n    list-style: square;\n  }\n  .root :global(.ProseMirror) ol {\n    list-style: decimal;\n  }\n  .root :global(.ProseMirror) ol ol {\n    list-style: lower-alpha;\n  }\n  .root :global(.ProseMirror) ol ol ol {\n    list-style: lower-roman;\n  }\n\n  /* to-dos */\n  .root :global(.ProseMirror) ul[data-type=\"taskList\"] {\n    padding-inline-start: 0.15em;\n    list-style: none;\n  }\n  .root :global(.ProseMirror) ul[data-type=\"taskList\"] li {\n    display: flex;\n    align-items: flex-start;\n    gap: 0.55em;\n  }\n  .root :global(.ProseMirror) ul[data-type=\"taskList\"] li > label {\n    flex: none;\n    margin-top: 0.3em;\n    user-select: none;\n  }\n  .root :global(.ProseMirror) ul[data-type=\"taskList\"] li > div {\n    flex: 1;\n    min-width: 0;\n  }\n  /* a to-do's box takes Checkbox's values: the field fill inside a 3:1 control edge, filled with the control colour\n     and its glyph when checked */\n  .root :global(.ProseMirror) ul[data-type=\"taskList\"] input[type=\"checkbox\"] {\n    appearance: none;\n    display: grid;\n    place-content: center;\n    width: 16px;\n    height: 16px;\n    margin: 0;\n    border-radius: calc(var(--pui-radius-chip) - 2px); /* as Checkbox */\n    background: var(--pui-field);\n    box-shadow: inset 0 0 0 1px var(--pui-edge-control);\n    cursor: pointer;\n    transition:\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      box-shadow var(--pui-motion-out) var(--pui-ease-default);\n  }\n  /* a finger gets a 24 px box: a transparent 4 px border around the same 16 px face (the inset edge and the fill stay\n     inside it), pulled back by the same 4 px so the line does not move */\n  @media (pointer: coarse) {\n    .root :global(.ProseMirror) ul[data-type=\"taskList\"] input[type=\"checkbox\"] {\n      width: 24px;\n      height: 24px;\n      margin: -4px;\n      border: 4px solid transparent;\n      border-radius: calc(var(--pui-radius-chip) + 2px);\n      background-clip: padding-box;\n    }\n  }\n  .root :global(.ProseMirror) ul[data-type=\"taskList\"] input[type=\"checkbox\"]::after {\n    content: \"\";\n    width: 9px;\n    height: 5px;\n    margin-top: -2px;\n    border: solid var(--pui-control-glyph);\n    border-width: 0 0 2px 2px;\n    transform: rotate(-45deg) scale(0);\n    transition: transform var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .root :global(.ProseMirror) ul[data-type=\"taskList\"] input[type=\"checkbox\"]:checked {\n    background: var(--pui-control-on);\n    box-shadow: none;\n    transition: none;\n  }\n  .root :global(.ProseMirror) ul[data-type=\"taskList\"] input[type=\"checkbox\"]:checked::after {\n    transform: rotate(-45deg) scale(1);\n  }\n  .root :global(.ProseMirror) li[data-checked=\"true\"] > div {\n    color: var(--pui-ink-3);\n    text-decoration: line-through;\n    text-decoration-color: color-mix(in oklab, currentColor 60%, transparent);\n  }\n  .root :global(.ProseMirror) ul[data-type=\"taskList\"] ul[data-type=\"taskList\"] {\n    margin-top: 0.1em;\n  }\n\n  /* quote, callout, divider */\n  .root :global(.ProseMirror) blockquote {\n    margin-inline: 0;\n    padding: 0.05em 0 0.05em 0.95em;\n    border-inline-start: 3px solid var(--pui-ink);\n    color: var(--pui-ink);\n  }\n  .root :global(.ProseMirror) [data-callout] {\n    display: flex;\n    gap: 0.7em;\n    margin-block: 0.5em;\n    padding: 0.85em 1em;\n    border-radius: var(--pui-radius-control);\n    background: var(--pui-surface-2);\n    box-shadow: inset 0 0 0 1px var(--pui-line-soft);\n  }\n  /* the callout's mark is drawn (a light bulb in the accent), never an emoji; an icon set on the block (data-icon)\n     still shows as written */\n  .root :global(.ProseMirror) [data-callout]::before {\n    content: \"\";\n    flex: none;\n    width: 1.1em;\n    height: 1lh;\n    background: var(--pui-accent);\n    mask: var(--_bulb) center / 1.1em no-repeat;\n  }\n  .root :global(.ProseMirror) [data-callout][data-icon]:not([data-icon=\"\"])::before {\n    content: attr(data-icon);\n    width: auto;\n    background: none;\n    mask: none;\n    font-size: 1.1em;\n    line-height: 1.55;\n  }\n  .root {\n    --_bulb: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5'/%3E%3Cpath d='M9 18h6'/%3E%3Cpath d='M10 22h4'/%3E%3C/svg%3E\");\n  }\n  .root :global(.ProseMirror) [data-callout] > div {\n    flex: 1;\n    min-width: 0;\n  }\n  .root :global(.ProseMirror) hr {\n    height: 1px;\n    margin: 1.1em 0;\n    border: 0;\n    background: var(--pui-line);\n  }\n  .root :global(.ProseMirror) hr:global(.ProseMirror-selectednode) {\n    background: var(--pui-accent);\n  }\n\n  /* images */\n  .root :global(.ProseMirror) img {\n    display: block;\n    max-width: 100%;\n    height: auto;\n    margin-block: 0.5em;\n    border-radius: var(--pui-radius-control);\n  }\n  .root :global(.ProseMirror) img:global(.ProseMirror-selectednode) {\n    outline: 2px solid var(--pui-accent);\n    outline-offset: 2px;\n  }\n\n  /* code, highlighted by lowlight with the system's syntax colours */\n  .root :global(.ProseMirror) pre {\n    margin-block: 0.5em;\n    padding: 0.9em 1.1em;\n    overflow-x: auto;\n    border-radius: var(--pui-radius-control);\n    background: var(--pui-inset);\n    box-shadow: inset 0 0 0 1px var(--pui-line-soft);\n    color: var(--pui-ink);\n    font-family: var(--pui-font-code);\n    font-size: var(--pui-text-sm);\n    line-height: 1.6;\n    white-space: pre !important;\n    tab-size: 2;\n  }\n  .root :global(.ProseMirror) pre code {\n    background: none;\n    padding: 0;\n    color: inherit;\n    font-size: inherit;\n  }\n  .root :global(.hljs-comment),\n  .root :global(.hljs-quote) {\n    color: var(--pui-ink-3);\n    font-style: italic;\n  }\n  .root :global(.hljs-keyword),\n  .root :global(.hljs-selector-tag),\n  .root :global(.hljs-built_in),\n  .root :global(.hljs-type) {\n    color: var(--pui-violet);\n  }\n  .root :global(.hljs-string),\n  .root :global(.hljs-attr),\n  .root :global(.hljs-template-tag) {\n    color: var(--pui-green);\n  }\n  /* orange is the lightest of these tones on a light page: a tenth of the ink keeps 13 px code at 4.5:1 there (and\n     lifts it a little on a dark one) */\n  .root :global(.hljs-number),\n  .root :global(.hljs-literal),\n  .root :global(.hljs-symbol) {\n    color: color-mix(in oklab, var(--pui-orange) 90%, var(--pui-ink));\n  }\n  .root :global(.hljs-title),\n  .root :global(.hljs-section),\n  .root :global(.hljs-function) {\n    color: var(--pui-blue);\n  }\n  .root :global(.hljs-variable),\n  .root :global(.hljs-params),\n  .root :global(.hljs-property) {\n    color: var(--pui-ink);\n  }\n  .root :global(.hljs-meta),\n  .root :global(.hljs-tag),\n  .root :global(.hljs-name) {\n    color: var(--pui-red);\n  }\n\n  /* placeholder on the empty line under the caret, and on an empty document */\n  .root :global(.ProseMirror) :global(.is-empty)::before {\n    content: attr(data-placeholder);\n    float: left;\n    height: 0;\n    color: var(--pui-ink-3);\n    pointer-events: none;\n  }\n  .root :global(.ProseMirror) ::selection {\n    background: color-mix(in oklab, var(--pui-accent) 34%, transparent);\n  }\n  .root[data-readonly] :global(.ProseMirror) {\n    padding-inline: 0;\n  }\n\n  /* the block handle beside the hovered block: add (+) and drag (grip) */\n  /* The handle appears beside the block under the pointer and fades in there; it does not travel from the last block\n     (it is a target: a grip that glides would move away from the hand reaching for it). */\n  .handle {\n    display: flex;\n    align-items: center;\n    gap: calc(var(--pui-space-0-5) / 2);\n    padding-inline-end: var(--pui-space-1);\n    animation: handle-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .handleWrap[style*=\"hidden\"] .handle {\n    animation: none;\n    opacity: 0;\n  }\n  @keyframes handle-in {\n    from {\n      opacity: 0;\n    }\n  }\n  .handleButton {\n    display: grid;\n    place-items: center;\n    width: 20px;\n    height: 24px;\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink-3);\n    cursor: pointer;\n    transition:\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      transform var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .handleButton:hover {\n    background: var(--pui-hover);\n    color: var(--pui-ink-2);\n    transition: none;\n  }\n  .handleButton:active {\n    transform: scale(var(--pui-press-scale));\n    transition: none;\n  }\n  .handleButton[data-grip] {\n    cursor: grab;\n  }\n  .handleButton[data-grip]:active {\n    cursor: grabbing;\n  }\n  .root :global(.ProseMirror-dropcursor),\n  :global(.ProseMirror) + :global(.ProseMirror-dropcursor) {\n    background: var(--pui-accent);\n  }\n\n  /* floating toolbar and slash menu share the overlay surface */\n  /* The toolbar arrives over --pui-motion-in each time a selection shows it (the plugin re-inserts it), rising a\n     little from the text it formats; its own width eases when it swaps to the link field (useWidthMorph). */\n  .bubble {\n    display: flex;\n    align-items: center;\n    gap: calc(var(--pui-space-0-5) / 2);\n    padding: var(--pui-space-1);\n    overflow: clip;\n    border-radius: var(--pui-radius-control);\n    color: var(--pui-ink);\n    white-space: nowrap;\n    transform-origin: 0 100%;\n    animation: layer-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  @keyframes layer-in {\n    from {\n      opacity: 0;\n      transform: translateY(calc(2px * var(--pui-motion-travel, 1))) scale(0.97);\n    }\n  }\n  .tool {\n    display: inline-flex;\n    flex: none;\n    align-items: center;\n    justify-content: center;\n    gap: var(--pui-space-1);\n    min-width: 28px;\n    height: 28px;\n    padding: 0 var(--pui-space-1-5);\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink-2);\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-medium);\n    white-space: nowrap;\n    transition:\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      transform var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .tool:hover {\n    background: var(--pui-hover);\n    color: var(--pui-ink);\n    transition: none;\n  }\n  .tool:active {\n    transform: scale(var(--pui-press-scale));\n    transition: none;\n  }\n  /* a mark that is on: filled, and its icon at full ink (the pressed state is also aria-pressed) */\n  .tool[data-active] {\n    background: var(--pui-hover-2);\n    color: var(--pui-ink);\n    transition: none;\n  }\n  .tool:focus-visible {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: -2px;\n  }\n  .tool svg {\n    width: 15px;\n    height: 15px;\n  }\n  .sep {\n    width: 1px;\n    height: 18px;\n    margin: 0 var(--pui-space-1);\n    background: var(--pui-line);\n  }\n  .linkField {\n    width: 220px;\n    height: 28px;\n    padding: 0 var(--pui-space-2);\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    outline: none;\n  }\n  .palette {\n    display: grid;\n    grid-template-columns: repeat(7, 22px);\n    gap: var(--pui-space-1);\n    padding: var(--pui-space-2);\n    border-radius: var(--pui-radius-control);\n    transform-origin: 0 0;\n    animation: layer-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .paletteLabel {\n    grid-column: 1 / -1;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-medium);\n  }\n  .swatch {\n    display: grid;\n    place-items: center;\n    width: 22px;\n    height: 22px;\n    border-radius: var(--pui-radius-chip);\n    box-shadow: inset 0 0 0 1px var(--pui-line);\n    font-size: var(--pui-text-xs);\n    font-weight: var(--pui-weight-semibold);\n    transition:\n      box-shadow var(--pui-motion-out) var(--pui-ease-default),\n      transform var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .swatch:hover {\n    box-shadow: inset 0 0 0 1.5px var(--pui-ink-3);\n    transition: none;\n  }\n  .swatch:active {\n    transform: scale(var(--pui-press-scale));\n    transition: none;\n  }\n  /* the colour in force carries a ring in the accent, so the choice reads without the colour itself */\n  .swatch[aria-pressed=\"true\"] {\n    box-shadow:\n      inset 0 0 0 1px var(--pui-line),\n      0 0 0 1.5px var(--pui-current-edge, var(--pui-accent));\n  }\n  .swatch:focus-visible {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: 2px;\n  }\n\n  /* The slash menu and the Turn-into menu: one plate (_shared/glide) travels to the active row under the arrow keys\n     and the pointer, and the menu opens out of the caret (or the toolbar) over --pui-motion-in. */\n  .menu {\n    position: relative;\n    width: min(280px, calc(100vw - var(--pui-space-4)));\n    max-height: 340px;\n    overflow-y: auto;\n    padding: var(--pui-space-1-5);\n    border-radius: var(--pui-radius-card);\n    overscroll-behavior: contain;\n    transform-origin: 0 0;\n    animation: layer-in var(--pui-motion-in) var(--pui-ease-default);\n    --_check: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\");\n  }\n  .menu[data-side=\"top\"] {\n    transform-origin: 0 100%;\n  }\n  .plate.plate {\n    inset-inline: var(--pui-space-1-5);\n    border-radius: var(--pui-radius-control);\n  }\n  .menuGroup {\n    padding: var(--pui-space-2) var(--pui-space-2) var(--pui-space-1);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-medium);\n  }\n  .menuItem {\n    position: relative;\n    z-index: 1;\n    display: flex;\n    width: 100%;\n    align-items: center;\n    gap: var(--pui-space-2-5);\n    padding: var(--pui-space-1-5) var(--pui-space-2);\n    border-radius: var(--pui-radius-control);\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    text-align: start;\n    outline: none;\n  }\n  /* the menu's own icon tile lights with the active row, so the row reads as chosen before the plate settles */\n  .menuItem[data-active] .menuIcon {\n    color: var(--pui-ink);\n    box-shadow: inset 0 0 0 1px var(--pui-line-strong);\n  }\n  .menuItem[aria-checked=\"true\"]::after {\n    content: \"\";\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    margin-inline-start: auto;\n    background: var(--pui-current-edge, var(--pui-accent));\n    mask: var(--_check) center / contain no-repeat;\n  }\n  .menuItem:focus-visible {\n    outline: var(--pui-focus-width) var(--pui-focus-style) var(--pui-focus-ring);\n    outline-offset: -2px;\n  }\n  .menuIcon {\n    display: grid;\n    place-items: center;\n    width: 30px;\n    height: 30px;\n    flex: none;\n    border-radius: var(--pui-radius-chip);\n    background: var(--pui-surface);\n    box-shadow: inset 0 0 0 1px var(--pui-line);\n    color: var(--pui-ink-2);\n    transition:\n      color var(--pui-motion-out) var(--pui-ease-default),\n      box-shadow var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .menuIcon svg {\n    width: 16px;\n    height: 16px;\n  }\n  .menuText {\n    display: flex;\n    min-width: 0;\n    flex-direction: column;\n    line-height: 1.3;\n  }\n  .menuHint {\n    overflow: hidden;\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .menuKey {\n    margin-inline-start: auto;\n    color: var(--pui-ink-3);\n    font-family: var(--pui-font-code);\n    font-size: var(--pui-text-2xs);\n  }\n  .menuEmpty {\n    padding: var(--pui-space-2-5);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-sm);\n  }\n  /* A narrow room (a side panel, a phone, a card): the + and grip gutter would take a fifth of every line, so the\n     text gets it back. The grip stays (blocks still move); \"+\" folds away (type / on any line instead), headings step\n     down one rung and lists indent less, so a heading still fits a line and a nested list keeps its words. */\n  @container rich-text (max-width: 28rem) {\n    .root :global(.ProseMirror) {\n      --pui-gutter: 24px;\n    }\n    .handleButton[data-add] {\n      display: none;\n    }\n    .root :global(.ProseMirror) h1 {\n      font-size: 1.55em;\n    }\n    .root :global(.ProseMirror) h2 {\n      font-size: 1.3em;\n    }\n    .root :global(.ProseMirror) h3 {\n      font-size: 1.12em;\n    }\n    .root :global(.ProseMirror) :is(ul, ol) {\n      padding-inline-start: 1.25em;\n    }\n    .root :global(.ProseMirror) pre {\n      padding: 0.75em 0.85em;\n    }\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.bubble {\n  composes: m-overlay from global;\n}\n.linkField {\n  composes: m-field from global;\n}\n.palette {\n  composes: m-overlay from global;\n}\n.menu {\n  composes: m-overlay from global;\n}\n"},{"path":"beamline/components/rich-text-editor/rich-text-editor.tsx","type":"registry:file","target":"src/ui/beamline/components/rich-text-editor/rich-text-editor.tsx","content":"\"use client\";\n\nimport { createTranslator } from \"../../runtime/messages\";\nimport { type Translator } from \"../../runtime/messages\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport {\n  forwardRef,\n  createContext,\n  useContext,\n  useCallback,\n  useId,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type {\n  CSSProperties,\n  KeyboardEvent as ReactKeyboardEvent,\n  MouseEvent as ReactMouseEvent,\n  ReactNode,\n  Ref,\n  RefObject,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EditorContent, useEditor, useEditorState, type Editor } from \"@tiptap/react\";\nimport { BubbleMenu } from \"@tiptap/react/menus\";\nimport { Extension, Node, mergeAttributes, type Range } from \"@tiptap/core\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport { TaskItem, TaskList } from \"@tiptap/extension-list\";\nimport { Placeholder } from \"@tiptap/extensions\";\nimport { Color, TextStyle } from \"@tiptap/extension-text-style\";\nimport Highlight from \"@tiptap/extension-highlight\";\nimport Typography from \"@tiptap/extension-typography\";\nimport Image from \"@tiptap/extension-image\";\nimport CodeBlockLowlight from \"@tiptap/extension-code-block-lowlight\";\nimport { Markdown } from \"@tiptap/markdown\";\nimport DragHandle from \"@tiptap/extension-drag-handle-react\";\nimport Suggestion, { type SuggestionKeyDownProps, type SuggestionProps } from \"@tiptap/suggestion\";\nimport {\n  Baseline,\n  Bold,\n  Check,\n  ChevronDown,\n  Code,\n  GripVertical,\n  Heading1,\n  Heading2,\n  Heading3,\n  ImageIcon,\n  Italic,\n  Link2,\n  List,\n  ListChecks,\n  ListOrdered,\n  Megaphone,\n  Minus,\n  Pilcrow,\n  Plus,\n  Quote,\n  SquareCode,\n  Strikethrough,\n  Unlink,\n} from \"lucide-react\";\nimport { Alert } from \"../alert/alert\";\nimport { Button } from \"../button/button\";\nimport { cn } from \"../../lib/utils\";\nimport { Tooltip } from \"../tooltip/tooltip\";\nimport { lowlight } from \"../../lib/highlight\";\nimport { GlidePlate, FollowPlate } from \"../_shared/glide\";\nimport { useWidthMorph } from \"../../hooks/use-width-morph\";\nimport styles from \"./rich-text-editor.module.css\";\nimport { usePortalContainer } from \"../../runtime/appearance\";\n\n/* ------------------------------------------------------------------------------------------------\n * Built on Tiptap / ProseMirror (MIT): the document model, schema, history, input rules and paste handling are\n * ProseMirror's; the blocks, menus, styles and behaviour below are ours.\n * ---------------------------------------------------------------------------------------------- */\n\nexport interface RichTextValue {\n  /** Editor contents encoded as HTML. Required. */\n  html: string;\n  /** Editor contents encoded as Markdown. Required. */\n  markdown: string;\n  /** Editor contents encoded as a document JSON object. Required. */\n  json: Record<string, unknown>;\n  /** Text content of this record. Required. */\n  text: string;\n}\n\nexport interface RichTextEditorHandle {\n  /** The underlying Tiptap editor, for commands beyond the props. */\n  editor: Editor | null;\n  /** Moves focus into the editor. Required. */\n  focus: () => void;\n  /** Returns the editor contents, or null before the editor is ready. Required. */\n  getValue: () => RichTextValue | null;\n}\n\nexport type RichTextEditorClassNames = Partial<\n  Record<\n    | \"menu\"\n    | \"menuGroup\"\n    | \"menuEmpty\"\n    | \"menuItem\"\n    | \"menuIcon\"\n    | \"menuText\"\n    | \"menuHint\"\n    | \"menuKey\"\n    | \"tool\"\n    | \"toolbarWrap\"\n    | \"toolbar\"\n    | \"link\"\n    | \"separator\"\n    | \"palette\"\n    | \"paletteLabel\"\n    | \"swatch\"\n    | \"root\"\n    | \"handle\"\n    | \"handleButton\"\n    | \"content\"\n    | \"editor\"\n    | \"uploadError\",\n    string\n  >\n>;\n\nconst PartsContext = createContext<RichTextEditorClassNames | undefined>(undefined);\n\nexport interface RichTextEditorProps {\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: RichTextEditorClassNames;\n  /** Receives the outer visual root; no ref by default. */\n  rootRef?: Ref<HTMLDivElement>;\n  /** Controlled HTML. The editor only rewrites its content when this differs from what it last emitted. */\n  value?: string;\n  /** Initial HTML. */\n  defaultValue?: string;\n  /** Initial content as Markdown, used when no HTML is given. */\n  defaultMarkdown?: string;\n  /** Every change, as HTML, Markdown, JSON (the lossless form) and plain text. */\n  onValueChange?: (value: RichTextValue) => void;\n  /** Reports whether undo and redo are available, for your own toolbar. */\n  onHistoryChange?: (state: { canUndo: boolean; canRedo: boolean }) => void;\n  /** Uploads a pasted, dropped or picked image and resolves to its URL. Without it images are embedded as data URLs. */\n  onImageUpload?: (file: File) => Promise<string>;\n  /** Shown on an empty document. */\n  placeholder?: string;\n  /** Shown on an empty line under the caret. */\n  blockHint?: string;\n  /** Displays the value while preventing edits. Defaults to false. */\n  readOnly?: boolean;\n  /** Moves focus to the field after it mounts. Defaults to false. */\n  autoFocus?: boolean;\n  /** Accessible name read by assistive technology. Defaults to \"Editor\". */\n  \"aria-label\"?: string;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n}\n\n/* ─────────────────────────────── callout: a highlighted block with an icon ─────────────────────────────── */\n\ndeclare module \"@tiptap/core\" {\n  interface Commands<ReturnType> {\n    callout: { setCallout: () => ReturnType; unsetCallout: () => ReturnType };\n  }\n}\n\nconst Callout = Node.create({\n  name: \"callout\",\n  group: \"block\",\n  content: \"block+\",\n  defining: true,\n  addAttributes() {\n    return {\n      // empty draws the system's own mark (a light bulb in the accent); a callout that names an icon keeps it\n      icon: {\n        default: \"\",\n        parseHTML: (el) => el.getAttribute(\"data-icon\") ?? \"\",\n        renderHTML: (a) => (a.icon ? { \"data-icon\": a.icon } : {}),\n      },\n    };\n  },\n  parseHTML() {\n    return [{ tag: \"div[data-callout]\" }];\n  },\n  renderHTML({ HTMLAttributes }) {\n    return [\"div\", mergeAttributes(HTMLAttributes, { \"data-callout\": \"\" }), [\"div\", 0]];\n  },\n  // Markdown: a GitHub-style note, which round-trips and reads well as plain text.\n  renderMarkdown: (node, h) =>\n    `> [!NOTE]\\n${h\n      .renderChildren(node.content ?? [])\n      .split(\"\\n\")\n      .map((l: string) => `> ${l}`)\n      .join(\"\\n\")}`,\n  addCommands() {\n    return {\n      setCallout:\n        () =>\n        ({ commands }) =>\n          commands.wrapIn(this.name),\n      unsetCallout:\n        () =>\n        ({ commands }) =>\n          commands.lift(this.name),\n    };\n  },\n});\n\n/* ─────────────────────────────── slash menu ─────────────────────────────── */\n\ntype BlockItem = {\n  id: string;\n  label: string;\n  hint: string;\n  keywords: string;\n  icon: ReactNode;\n  md?: string;\n  run: (e: Editor) => void;\n};\nfunction localizedBLOCKS(i18n: Translator<typeof englishMessages>): BlockItem[] {\n  return [\n    {\n      id: \"p\",\n      label: i18n(\"text\"),\n      hint: i18n(\"plainParagraph\"),\n      keywords: \"paragraph text plain\",\n      icon: <Pilcrow />,\n      run: (e) => e.chain().focus().setParagraph().run(),\n    },\n    {\n      id: \"h1\",\n      label: i18n(\"heading1\"),\n      hint: i18n(\"bigSectionTitle\"),\n      keywords: \"h1 title heading\",\n      icon: <Heading1 />,\n      md: \"#\",\n      run: (e) => e.chain().focus().setHeading({ level: 1 }).run(),\n    },\n    {\n      id: \"h2\",\n      label: i18n(\"heading2\"),\n      hint: i18n(\"mediumSectionTitle\"),\n      keywords: \"h2 subtitle heading\",\n      icon: <Heading2 />,\n      md: \"##\",\n      run: (e) => e.chain().focus().setHeading({ level: 2 }).run(),\n    },\n    {\n      id: \"h3\",\n      label: i18n(\"heading3\"),\n      hint: i18n(\"smallSectionTitle\"),\n      keywords: \"h3 heading\",\n      icon: <Heading3 />,\n      md: \"###\",\n      run: (e) => e.chain().focus().setHeading({ level: 3 }).run(),\n    },\n    {\n      id: \"ul\",\n      label: i18n(\"bulletedList\"),\n      hint: i18n(\"simpleList\"),\n      keywords: \"bullet unordered list ul\",\n      icon: <List />,\n      md: \"-\",\n      run: (e) => e.chain().focus().toggleBulletList().run(),\n    },\n    {\n      id: \"ol\",\n      label: i18n(\"numberedList\"),\n      hint: i18n(\"listWithNumbers\"),\n      keywords: \"numbered ordered list ol 1.\",\n      icon: <ListOrdered />,\n      md: \"1.\",\n      run: (e) => e.chain().focus().toggleOrderedList().run(),\n    },\n    {\n      id: \"todo\",\n      label: i18n(\"toDoList\"),\n      hint: i18n(\"trackTasks\"),\n      keywords: \"todo task checkbox check\",\n      icon: <ListChecks />,\n      md: \"[]\",\n      run: (e) => e.chain().focus().toggleTaskList().run(),\n    },\n    {\n      id: \"quote\",\n      label: i18n(\"quote\"),\n      hint: i18n(\"captureAQuote\"),\n      keywords: \"quote blockquote citation\",\n      icon: <Quote />,\n      md: \">\",\n      run: (e) => e.chain().focus().setBlockquote().run(),\n    },\n    {\n      id: \"callout\",\n      label: i18n(\"callout\"),\n      hint: i18n(\"makeItStandOut\"),\n      keywords: \"callout note info tip warning\",\n      icon: <Megaphone />,\n      run: (e) => e.chain().focus().setCallout().run(),\n    },\n    {\n      id: \"code\",\n      label: i18n(\"code\"),\n      hint: i18n(\"codeWithHighlighting\"),\n      keywords: \"code snippet pre block\",\n      icon: <SquareCode />,\n      md: \"```\",\n      run: (e) => e.chain().focus().setCodeBlock().run(),\n    },\n    {\n      id: \"hr\",\n      label: i18n(\"divider\"),\n      hint: i18n(\"separateSections\"),\n      keywords: \"divider rule hr line separator\",\n      icon: <Minus />,\n      md: \"---\",\n      run: (e) => e.chain().focus().setHorizontalRule().run(),\n    },\n    {\n      id: \"image\",\n      label: i18n(\"image\"),\n      hint: i18n(\"uploadOrEmbed\"),\n      keywords: \"image picture photo upload\",\n      icon: <ImageIcon />,\n      run: () => {\n        /* replaced per editor: opens the file picker */\n      },\n    },\n  ];\n}\n\ntype SlashState = { items: BlockItem[]; index: number; rect: DOMRect | null; range: Range; query: string } | null;\n\n/** A tiny store so the ProseMirror plugin can drive a React menu without re-rendering the editor. */\nfunction createSlashStore() {\n  let state: SlashState = null;\n  const subs = new Set<() => void>();\n  return {\n    get: () => state,\n    set: (next: SlashState) => {\n      state = next;\n      subs.forEach((f) => f());\n    },\n    subscribe: (f: () => void) => {\n      subs.add(f);\n      return () => subs.delete(f);\n    },\n  };\n}\ntype SlashStore = ReturnType<typeof createSlashStore>;\n\nfunction filterBlocks(\n  query: string,\n  i18n: Translator<typeof englishMessages> = createTranslator(\"rich-text-editor\", englishMessages),\n) {\n  const q = query.trim().toLowerCase();\n  if (!q) return localizedBLOCKS(i18n);\n  return localizedBLOCKS(i18n).filter((b) => b.label.toLowerCase().includes(q) || b.keywords.includes(q));\n}\nfunction localizedSlashCommand(getMessages: () => Translator<typeof englishMessages>) {\n  return Extension.create<{ store: SlashStore; pickImage: () => void }>({\n    name: \"slashCommand\",\n    addOptions() {\n      return { store: createSlashStore(), pickImage: () => {} };\n    },\n    addProseMirrorPlugins() {\n      const { store, pickImage } = this.options;\n      return [\n        Suggestion<BlockItem>({\n          editor: this.editor,\n          char: \"/\",\n          allowSpaces: false,\n          startOfLine: false,\n          // not inside code, where \"/\" is just a character\n          allow: ({ editor }) => !editor.isActive(\"codeBlock\"),\n          items: ({ query }) => filterBlocks(query, getMessages()),\n          command: ({ editor, range, props }) => {\n            editor.chain().focus().deleteRange(range).run();\n            if (props.id === \"image\") pickImage();\n            else props.run(editor);\n          },\n          render: () => {\n            let current: SuggestionProps<BlockItem> | null = null;\n            const push = (p: SuggestionProps<BlockItem>, index = 0) => {\n              current = p;\n              store.set({ items: p.items, index, rect: p.clientRect?.() ?? null, range: p.range, query: p.query });\n            };\n            return {\n              onStart: (p) => push(p),\n              onUpdate: (p) => push(p, 0),\n              onKeyDown: ({ event }: SuggestionKeyDownProps) => {\n                const s = store.get();\n                if (!s || !current) return false;\n                if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n                  const n = s.items.length || 1;\n                  store.set({ ...s, index: (s.index + (event.key === \"ArrowDown\" ? 1 : -1) + n) % n });\n                  return true;\n                }\n                if (event.key === \"Enter\" || event.key === \"Tab\") {\n                  const item = s.items[s.index];\n                  if (item) {\n                    current.command(item);\n                    return true;\n                  }\n                  return false;\n                }\n                if (event.key === \"Escape\") {\n                  store.set(null);\n                  return true;\n                }\n                return false;\n              },\n              onExit: () => {\n                current = null;\n                store.set(null);\n              },\n            };\n          },\n        }),\n      ];\n    },\n  });\n}\n\nconst MENU_WIDTH = 280;\n\nfunction SlashMenu({\n  store,\n  editor,\n  onPick,\n}: {\n  store: SlashStore;\n  editor: Editor;\n  onPick: (item: BlockItem, range: Range) => void;\n}) {\n  const i18n = useMessages(\"rich-text-editor\", englishMessages);\n\n  const classNames = useContext(PartsContext);\n  const container = usePortalContainer();\n  const state = useSyncExternalStore(store.subscribe, store.get, store.get);\n  const list = useRef<HTMLDivElement>(null);\n  const id = `pui-slash-${useId().replace(/:/g, \"\")}`;\n  const open = !!state?.rect;\n  const activeId = state && state.items.length ? `${id}-${state.index}` : undefined;\n  useEffect(() => {\n    list.current?.querySelector(\"[data-active]\")?.scrollIntoView({ block: \"nearest\" });\n  }, [state?.index]);\n  // the caret keeps focus in the document; the document names the menu it drives and the row a key would pick, so a\n  // screen reader follows ↑ ↓ through the blocks as it does in any combobox\n  useEffect(() => {\n    if (!open) return;\n    const dom = editor.view.dom;\n    dom.setAttribute(\"aria-haspopup\", \"listbox\");\n    dom.setAttribute(\"aria-expanded\", \"true\");\n    dom.setAttribute(\"aria-controls\", id);\n    if (activeId) dom.setAttribute(\"aria-activedescendant\", activeId);\n    else dom.removeAttribute(\"aria-activedescendant\");\n    return () => {\n      for (const a of [\"aria-haspopup\", \"aria-expanded\", \"aria-controls\", \"aria-activedescendant\"])\n        dom.removeAttribute(a);\n    };\n  }, [editor, open, id, activeId]);\n  if (!state || !state.rect || container === null || typeof document === \"undefined\") return null;\n  const below = state.rect.bottom + 348 < window.innerHeight;\n  const width = Math.min(MENU_WIDTH, window.innerWidth - 16);\n  return createPortal(\n    <div\n      ref={list}\n      id={id}\n      role=\"listbox\"\n      aria-label={i18n(\"insertBlock\")}\n      data-slot=\"rich-text-editor-menu\"\n      data-side={below ? \"bottom\" : \"top\"}\n      className={cn(styles.menu, classNames?.menu)}\n      style={{\n        position: \"fixed\",\n        left: Math.max(8, Math.min(state.rect.left, window.innerWidth - width - 8)),\n        top: below ? state.rect.bottom + 6 : undefined,\n        bottom: below ? undefined : window.innerHeight - state.rect.top + 6,\n        zIndex: \"var(--pui-z-popover)\",\n      }}\n      onMouseDown={(e) => e.preventDefault()}\n    >\n      <GlidePlate\n        list={list}\n        selector={state.items.length ? `#${CSS.escape(`${id}-${state.index}`)}` : null}\n        reset={state.query}\n        className={styles.plate}\n      />\n      {state.items.length ? (\n        <div data-slot=\"rich-text-editor-menuGroup\" className={cn(styles.menuGroup, classNames?.menuGroup)}>\n          {i18n(\"basicBlocks\")}\n        </div>\n      ) : (\n        <div data-slot=\"rich-text-editor-menuEmpty\" className={cn(styles.menuEmpty, classNames?.menuEmpty)}>\n          {i18n(\"noBlockMatches\", { query: state.query })}\n        </div>\n      )}\n      {state.items.map((item, i) => (\n        <button\n          data-slot=\"rich-text-editor-menuItem\"\n          key={item.id}\n          id={`${id}-${i}`}\n          type=\"button\"\n          tabIndex={-1}\n          role=\"option\"\n          aria-selected={i === state.index}\n          data-active={i === state.index || undefined}\n          className={cn(styles.menuItem, classNames?.menuItem)}\n          onPointerMove={() => {\n            if (i !== state.index) store.set({ ...state, index: i });\n          }}\n          onClick={() => onPick(item, state.range)}\n        >\n          <span\n            data-slot=\"rich-text-editor-menuIcon\"\n            className={cn(styles.menuIcon, classNames?.menuIcon)}\n            aria-hidden=\"true\"\n          >\n            {item.icon}\n          </span>\n          <span data-slot=\"rich-text-editor-menuText\" className={cn(styles.menuText, classNames?.menuText)}>\n            <span>{item.label}</span>\n            <span data-slot=\"rich-text-editor-menuHint\" className={cn(styles.menuHint, classNames?.menuHint)}>\n              {item.hint}\n            </span>\n          </span>\n          {item.md && (\n            <kbd data-slot=\"rich-text-editor-menuKey\" className={cn(styles.menuKey, classNames?.menuKey)}>\n              {item.md}\n            </kbd>\n          )}\n        </button>\n      ))}\n    </div>,\n    container ?? document.body,\n  );\n}\nfunction localizedTEXT_COLORS(i18n: Translator<typeof englishMessages>) {\n  return [\n    { name: i18n(\"default\"), value: null },\n    { name: i18n(\"grey\"), value: \"var(--pui-ink-3)\" },\n    { name: i18n(\"red\"), value: \"var(--pui-red)\" },\n    { name: i18n(\"orange\"), value: \"var(--pui-orange)\" },\n    { name: i18n(\"green\"), value: \"var(--pui-green)\" },\n    { name: i18n(\"blue\"), value: \"var(--pui-blue)\" },\n    { name: i18n(\"violet\"), value: \"var(--pui-violet)\" },\n  ];\n}\nfunction localizedHIGHLIGHTS(i18n: Translator<typeof englishMessages>) {\n  return [\n    { name: i18n(\"none\"), value: null },\n    { name: i18n(\"yellow\"), value: \"color-mix(in oklab, var(--pui-orange) 26%, transparent)\" },\n    { name: i18n(\"red\"), value: \"var(--pui-red-tint)\" },\n    { name: i18n(\"green\"), value: \"var(--pui-green-tint)\" },\n    { name: i18n(\"blue\"), value: \"var(--pui-blue-tint)\" },\n    { name: i18n(\"violet\"), value: \"var(--pui-violet-tint)\" },\n  ];\n}\nfunction localizedTURN_INTO(i18n: Translator<typeof englishMessages>) {\n  return localizedBLOCKS(i18n).filter((b) =>\n    [\"p\", \"h1\", \"h2\", \"h3\", \"ul\", \"ol\", \"todo\", \"quote\", \"code\"].includes(b.id),\n  );\n}\n\nfunction currentBlock(e: Editor) {\n  if (e.isActive(\"heading\", { level: 1 })) return \"h1\";\n  if (e.isActive(\"heading\", { level: 2 })) return \"h2\";\n  if (e.isActive(\"heading\", { level: 3 })) return \"h3\";\n  if (e.isActive(\"taskList\")) return \"todo\";\n  if (e.isActive(\"bulletList\")) return \"ul\";\n  if (e.isActive(\"orderedList\")) return \"ol\";\n  if (e.isActive(\"blockquote\")) return \"quote\";\n  if (e.isActive(\"codeBlock\")) return \"code\";\n  return \"p\";\n}\n\n/** Defined at module level so a re-render (every selection change) never remounts a button between press and click. */\nfunction T({\n  active,\n  expanded,\n  label,\n  onClick,\n  children,\n}: {\n  active?: boolean;\n  expanded?: boolean;\n  label: string;\n  onClick: (e: ReactMouseEvent<HTMLButtonElement>) => void;\n  children: ReactNode;\n}) {\n  const classNames = useContext(PartsContext);\n  return (\n    <Tooltip content={label}>\n      <button\n        data-slot=\"rich-text-editor-tool\"\n        type=\"button\"\n        className={cn(styles.tool, classNames?.tool)}\n        data-active={active || undefined}\n        aria-pressed={expanded === undefined ? active : undefined}\n        aria-expanded={expanded}\n        aria-label={label}\n        onClick={onClick}\n      >\n        {children}\n      </button>\n    </Tooltip>\n  );\n}\n\n/** Arrow keys between a toolbar's or a menu's buttons (focus moves; the buttons are a roving set). */\nfunction rove(e: ReactKeyboardEvent<HTMLElement>, keys: { next: string; prev: string }, selector: string) {\n  const items = [...e.currentTarget.querySelectorAll<HTMLElement>(selector)];\n  const i = items.indexOf(document.activeElement as HTMLElement);\n  const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\" && keys.next === \"ArrowRight\";\n  const step = e.key === (rtl ? keys.prev : keys.next) ? 1 : e.key === (rtl ? keys.next : keys.prev) ? -1 : 0;\n  let to = -1;\n  if (step) to = (i + step + items.length) % items.length;\n  else if (e.key === \"Home\") to = 0;\n  else if (e.key === \"End\") to = items.length - 1;\n  if (to < 0 || !items.length) return false;\n  e.preventDefault();\n  items[to].focus();\n  return true;\n}\n\nfunction Toolbar({ editor, toolbarRef }: { editor: Editor; toolbarRef: RefObject<HTMLDivElement | null> }) {\n  const i18n = useMessages(\"rich-text-editor\", englishMessages);\n\n  const classNames = useContext(PartsContext);\n  const turnMenu = useRef<HTMLDivElement>(null);\n  const turnButton = useRef<HTMLButtonElement>(null);\n  const viaKeys = useRef(false);\n  const s = useEditorState({\n    editor,\n    selector: ({ editor: e }) => ({\n      block: currentBlock(e),\n      bold: e.isActive(\"bold\"),\n      italic: e.isActive(\"italic\"),\n      strike: e.isActive(\"strike\"),\n      code: e.isActive(\"code\"),\n      link: (e.getAttributes(\"link\").href as string | undefined) ?? null,\n      color: (e.getAttributes(\"textStyle\").color as string | undefined) ?? null,\n      highlight: (e.getAttributes(\"highlight\").color as string | undefined) ?? null,\n      // the selection itself, so the toolbar renders when it moves and an open panel closes with it (without it, a\n      // panel opened after the selection had moved quietly was closed on its first render: the first press did nothing)\n      at: `${e.state.selection.from}:${e.state.selection.to}`,\n    }),\n  });\n  const [panel, setPanel] = useState<null | \"turn\" | \"link\" | \"color\">(null);\n  const [href, setHref] = useState(\"\");\n  // the toolbar keeps its place and eases to the link field's width and back, instead of jumping\n  useWidthMorph(toolbarRef, panel === \"link\");\n  // a panel opened from the keyboard takes focus (the chosen block, the first swatch); one opened by the pointer\n  // leaves it in the document, so typing carries on\n  useEffect(() => {\n    if (!viaKeys.current || !panel || panel === \"link\") return;\n    viaKeys.current = false;\n    const host = panel === \"turn\" ? turnMenu.current : toolbarRef.current?.parentElement;\n    host\n      ?.querySelector<HTMLElement>(panel === \"turn\" ? \"[aria-checked=true]\" : \"[data-slot=rich-text-editor-swatch]\")\n      ?.focus();\n  }, [panel, toolbarRef]);\n  // back to the text at once, the selection kept (the focus command waits a frame, and the toolbar kept focus)\n  const back = () => {\n    setPanel(null);\n    editor.view.focus();\n  };\n  // A moved selection closes the open panel. Done while rendering, not in an effect after it: the toolbar renders on\n  // every keystroke, and an effect's setState queued one more render behind each, until a fast typist chained 50 of\n  // them and React stopped the page (\"Maximum update depth exceeded\").\n  const at = s.at;\n  const [panelAt, setPanelAt] = useState(at);\n  if (panelAt !== at) {\n    setPanelAt(at);\n    if (panel) setPanel(null);\n  }\n  const label = localizedTURN_INTO(i18n).find((b) => b.id === s.block)?.label ?? i18n(\"text\");\n  const applyLink = () => {\n    const url = href.trim();\n    const chain = editor.chain().focus().extendMarkRange(\"link\");\n    if (!url) chain.unsetLink().run();\n    else\n      chain\n        .setLink({\n          href: /^[a-z][\\w+.-]*:|^[/#]/i.test(url)\n            ? url\n            : /@/.test(url) && !/\\//.test(url)\n              ? `mailto:${url}`\n              : `https://${url}`,\n        })\n        .run();\n    setPanel(null);\n  };\n  return (\n    <div\n      data-slot=\"rich-text-editor-toolbarWrap\"\n      className={cn(\"flex flex-col items-start gap-1.5\", classNames?.toolbarWrap)}\n      onMouseDown={(e) => {\n        if ((e.target as HTMLElement).tagName !== \"INPUT\") e.preventDefault();\n      }}\n      onKeyDown={(e) => {\n        if (e.key !== \"Escape\" || (e.target as HTMLElement).tagName === \"INPUT\") return;\n        e.preventDefault();\n        back();\n      }}\n    >\n      <div\n        ref={toolbarRef}\n        data-slot=\"rich-text-editor-bubble\"\n        className={cn(styles.bubble, classNames?.toolbar)}\n        role=\"toolbar\"\n        aria-label={i18n(\"formatting\")}\n        aria-orientation=\"horizontal\"\n        onKeyDown={(e) => {\n          if ((e.target as HTMLElement).tagName === \"INPUT\") return;\n          rove(e, { next: \"ArrowRight\", prev: \"ArrowLeft\" }, \"button\");\n        }}\n      >\n        {panel === \"link\" ? (\n          <>\n            <input\n              data-slot=\"rich-text-editor-link\"\n              autoFocus\n              className={cn(styles.linkField, classNames?.link)}\n              placeholder={i18n(\"pasteOrTypeALink\")}\n              value={href}\n              aria-label={i18n(\"link\")}\n              onChange={(e) => setHref(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") {\n                  e.preventDefault();\n                  applyLink();\n                }\n                if (e.key === \"Escape\") {\n                  e.preventDefault();\n                  setPanel(null);\n                  editor.commands.focus();\n                }\n              }}\n            />\n            <T label={i18n(\"applyLink\")} onClick={applyLink}>\n              <Check />\n            </T>\n            {s.link && (\n              <T\n                label={i18n(\"removeLink\")}\n                onClick={() => {\n                  editor.chain().focus().extendMarkRange(\"link\").unsetLink().run();\n                  setPanel(null);\n                }}\n              >\n                <Unlink />\n              </T>\n            )}\n          </>\n        ) : (\n          <>\n            <button\n              ref={turnButton}\n              data-slot=\"rich-text-editor-tool\"\n              type=\"button\"\n              className={cn(styles.tool, classNames?.tool)}\n              aria-haspopup=\"menu\"\n              aria-expanded={panel === \"turn\"}\n              onClick={(e) => {\n                viaKeys.current = e.detail === 0;\n                setPanel(panel === \"turn\" ? null : \"turn\");\n              }}\n              onKeyDown={(e) => {\n                if (e.key !== \"ArrowDown\") return;\n                e.preventDefault();\n                viaKeys.current = true;\n                setPanel(\"turn\");\n              }}\n            >\n              {label}\n              <ChevronDown className=\"!size-3\" />\n            </button>\n            <span data-slot=\"rich-text-editor-separator\" className={cn(styles.sep, classNames?.separator)} />\n            <T label={i18n(\"boldB\")} active={s.bold} onClick={() => editor.chain().focus().toggleBold().run()}>\n              <Bold />\n            </T>\n            <T label={i18n(\"italicI\")} active={s.italic} onClick={() => editor.chain().focus().toggleItalic().run()}>\n              <Italic />\n            </T>\n            <T\n              label={i18n(\"strikethroughS\")}\n              active={s.strike}\n              onClick={() => editor.chain().focus().toggleStrike().run()}\n            >\n              <Strikethrough />\n            </T>\n            <T label={i18n(\"inlineCodeE\")} active={s.code} onClick={() => editor.chain().focus().toggleCode().run()}>\n              <Code />\n            </T>\n            <T\n              label={i18n(\"linkK\")}\n              active={!!s.link}\n              onClick={() => {\n                setHref(s.link ?? \"\");\n                setPanel(\"link\");\n              }}\n            >\n              <Link2 />\n            </T>\n            <span data-slot=\"rich-text-editor-separator\" className={cn(styles.sep, classNames?.separator)} />\n            <T\n              label={i18n(\"colour\")}\n              active={panel === \"color\"}\n              expanded={panel === \"color\"}\n              onClick={(e) => {\n                viaKeys.current = e.detail === 0;\n                setPanel(panel === \"color\" ? null : \"color\");\n              }}\n            >\n              <Baseline style={s.color ? { color: s.color } : undefined} />\n            </T>\n          </>\n        )}\n      </div>\n      {panel === \"turn\" && (\n        <div\n          ref={turnMenu}\n          data-slot=\"rich-text-editor-menu\"\n          className={cn(styles.menu, classNames?.menu)}\n          role=\"menu\"\n          aria-label={i18n(\"turnInto\")}\n          style={{ width: 220 }}\n          onKeyDown={(e) => {\n            if (rove(e, { next: \"ArrowDown\", prev: \"ArrowUp\" }, \"[role=menuitemradio]\")) return;\n            if (e.key === \"Escape\" || e.key === \"Tab\") {\n              e.preventDefault();\n              e.stopPropagation();\n              setPanel(null);\n              turnButton.current?.focus();\n            }\n          }}\n        >\n          <FollowPlate list={turnMenu} rows=\"[role=menuitemradio]\" className={styles.plate} />\n          <div data-slot=\"rich-text-editor-menuGroup\" className={cn(styles.menuGroup, classNames?.menuGroup)}>\n            {i18n(\"turnInto\")}\n          </div>\n          {localizedTURN_INTO(i18n).map((b) => (\n            <button\n              data-slot=\"rich-text-editor-menuItem\"\n              key={b.id}\n              type=\"button\"\n              role=\"menuitemradio\"\n              tabIndex={-1}\n              aria-checked={s.block === b.id}\n              className={cn(styles.menuItem, classNames?.menuItem)}\n              onClick={() => {\n                b.run(editor);\n                setPanel(null);\n              }}\n            >\n              <span\n                data-slot=\"rich-text-editor-menuIcon\"\n                className={cn(styles.menuIcon, classNames?.menuIcon)}\n                aria-hidden=\"true\"\n              >\n                {b.icon}\n              </span>\n              <span>{b.label}</span>\n            </button>\n          ))}\n        </div>\n      )}\n      {panel === \"color\" && (\n        <div\n          data-slot=\"rich-text-editor-palette\"\n          className={cn(styles.palette, classNames?.palette)}\n          role=\"group\"\n          aria-label={i18n(\"colour\")}\n          onKeyDown={(e) => rove(e, { next: \"ArrowRight\", prev: \"ArrowLeft\" }, \"button\")}\n        >\n          <span data-slot=\"rich-text-editor-paletteLabel\" className={cn(styles.paletteLabel, classNames?.paletteLabel)}>\n            {i18n(\"text\")}\n          </span>\n          {localizedTEXT_COLORS(i18n).map((c) => (\n            <button\n              data-slot=\"rich-text-editor-swatch\"\n              key={c.name}\n              type=\"button\"\n              aria-pressed={s.color === c.value}\n              className={cn(styles.swatch, classNames?.swatch)}\n              aria-label={i18n(\"text2\", { name: c.name })}\n              title={c.name}\n              style={{ color: c.value ?? \"var(--pui-ink)\" }}\n              onClick={() => {\n                const ch = editor.chain().focus();\n                (c.value ? ch.setColor(c.value) : ch.unsetColor()).run();\n                setPanel(null);\n              }}\n            >\n              {i18n(\"a\")}\n            </button>\n          ))}\n          <span data-slot=\"rich-text-editor-paletteLabel\" className={cn(styles.paletteLabel, classNames?.paletteLabel)}>\n            {i18n(\"background\")}\n          </span>\n          {localizedHIGHLIGHTS(i18n).map((c) => (\n            <button\n              data-slot=\"rich-text-editor-swatch\"\n              key={c.name}\n              type=\"button\"\n              aria-pressed={s.highlight === c.value}\n              className={cn(styles.swatch, classNames?.swatch)}\n              aria-label={i18n(\"background2\", { name: c.name })}\n              title={c.name}\n              style={{ background: c.value ?? \"transparent\" }}\n              onClick={() => {\n                const ch = editor.chain().focus();\n                (c.value ? ch.setHighlight({ color: c.value }) : ch.unsetHighlight()).run();\n                setPanel(null);\n              }}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/* ─────────────────────────────── the editor ─────────────────────────────── */\n\nconst fileToDataUrl = (file: File) =>\n  new Promise<string>((resolve, reject) => {\n    const r = new FileReader();\n    r.onload = () => resolve(String(r.result));\n    r.onerror = reject;\n    r.readAsDataURL(file);\n  });\n\n// Module-level so they keep their identity: Tiptap's BubbleMenu dispatches an \"update options\" transaction whenever\n// either changes, and a new one on every render made each render a transaction (onHistoryChange → setState → render\n// → transaction … without end).\nconst BUBBLE_OPTIONS = { placement: \"top-start\", offset: 8 } as const;\nconst showBubble = ({ editor: e, state }: { editor: Editor; state: Editor[\"state\"] }) =>\n  !state.selection.empty &&\n  e.isEditable &&\n  !e.isActive(\"codeBlock\") &&\n  !e.isActive(\"image\") &&\n  state.selection.constructor.name !== \"NodeSelection\";\n\nfunction valueOf(e: Editor): RichTextValue {\n  return {\n    html: e.getHTML(),\n    markdown: e.getMarkdown(),\n    json: e.getJSON() as Record<string, unknown>,\n    text: e.getText(),\n  };\n}\n\n/**\n * A writing surface that behaves like Notion: type `/` for any block, select text for formatting, drag the handle to\n * move a block, Tab and Shift+Tab to nest, markdown shortcuts as you type (`#`, `-`, `1.`, `[]`, `>`, ``` ``` ```, `---`,\n * `**bold**`), paste from the web or as markdown. Outputs HTML, Markdown and JSON.\n */\nexport const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(function RichTextEditor(\n  {\n    value,\n    defaultValue,\n    defaultMarkdown,\n    onValueChange,\n    onHistoryChange,\n    onImageUpload,\n    placeholder: placeholderProp,\n    blockHint: blockHintProp,\n    readOnly = false,\n    autoFocus = false,\n    \"aria-label\": ariaLabelProp,\n    className,\n    classNames,\n    rootRef,\n    style,\n  },\n  ref,\n) {\n  const i18n = useMessages(\"rich-text-editor\", englishMessages);\n\n  const ariaLabel = ariaLabelProp === undefined ? i18n(\"ariaLabel\") : ariaLabelProp;\n  const placeholder = placeholderProp === undefined ? i18n(\"placeholder\") : placeholderProp;\n  const blockHint = blockHintProp === undefined ? i18n(\"blockHint\") : blockHintProp;\n\n  const container = usePortalContainer();\n  const store = useMemo(createSlashStore, []);\n  const fileInput = useRef<HTMLInputElement>(null);\n  const toolbar = useRef<HTMLDivElement>(null);\n  const emitted = useRef<string | null>(null);\n  const cb = useRef({ onValueChange, onHistoryChange, onImageUpload, i18n, placeholder, blockHint });\n  cb.current = { onValueChange, onHistoryChange, onImageUpload, i18n, placeholder, blockHint };\n\n  const [uploadFailure, setUploadFailure] = useState<{ message: string; files: File[]; pos?: number } | null>(null);\n  const [uploading, setUploading] = useState(false);\n  const insertFiles = useCallback(\n    async (editor: Editor, files: File[], pos?: number) => {\n      const images = files.filter((f) => f.type.startsWith(\"image/\"));\n      setUploadFailure(null);\n      setUploading(true);\n      try {\n        for (let i = 0; i < images.length; i++) {\n          const file = images[i];\n          try {\n            const src = await (cb.current.onImageUpload ?? fileToDataUrl)(file);\n            if (editor.isDestroyed) return;\n            const chain = editor.chain().focus();\n            (pos !== undefined\n              ? chain.insertContentAt(pos, { type: \"image\", attrs: { src, alt: file.name } })\n              : chain.setImage({ src, alt: file.name })\n            ).run();\n          } catch (error) {\n            if (!editor.isDestroyed)\n              setUploadFailure({\n                message: error instanceof Error ? error.message : i18n(\"imageUploadFailed\"),\n                files: images.slice(i),\n                pos,\n              });\n            break;\n          }\n        }\n      } finally {\n        if (!editor.isDestroyed) setUploading(false);\n      }\n    },\n    [i18n],\n  );\n\n  const editor = useEditor({\n    immediatelyRender: false,\n    editable: !readOnly,\n    autofocus: autoFocus ? \"end\" : false,\n    extensions: [\n      StarterKit.configure({\n        codeBlock: false,\n        heading: { levels: [1, 2, 3] },\n        link: { openOnClick: readOnly, autolink: true, defaultProtocol: \"https\" },\n      }),\n      TaskList,\n      TaskItem.configure({\n        nested: true,\n        a11y: {\n          checkboxLabel: (node) =>\n            cb.current.i18n(\"taskCheckbox\", { text: node.textContent || cb.current.i18n(\"emptyTask\") }),\n        },\n      }),\n      Callout,\n      CodeBlockLowlight.configure({ lowlight, defaultLanguage: \"plaintext\" }),\n      TextStyle,\n      Color,\n      Highlight.configure({ multicolor: true }),\n      Typography,\n      Image.configure({ allowBase64: true }),\n      Markdown,\n      Placeholder.configure({\n        placeholder: ({ editor: e, node }) =>\n          e.isEmpty ? cb.current.placeholder : node.type.name === \"paragraph\" ? cb.current.blockHint : \"\",\n        showOnlyCurrent: true,\n      }),\n      localizedSlashCommand(() => cb.current.i18n).configure({ store, pickImage: () => fileInput.current?.click() }),\n    ],\n    content: value ?? defaultValue ?? defaultMarkdown ?? \"\",\n    contentType: (value ?? defaultValue) ? \"html\" : defaultMarkdown ? \"markdown\" : \"html\",\n    editorProps: {\n      attributes: {\n        \"aria-label\": ariaLabel,\n        \"aria-multiline\": \"true\",\n        role: \"textbox\",\n        spellcheck: \"true\",\n        class: classNames?.editor ?? \"\",\n        // Alt+F10 reaches the formatting toolbar from the keyboard (the editors' convention); Esc comes back\n        \"aria-keyshortcuts\": \"Alt+F10\",\n      },\n      handleKeyDown: (_view, event) => {\n        if (!(event.altKey && event.key === \"F10\")) return false;\n        const first = toolbar.current?.querySelector<HTMLElement>(\"button\");\n        if (!first) return false;\n        event.preventDefault();\n        first.focus();\n        return true;\n      },\n      handlePaste: (_view, event) => {\n        const files = Array.from(event.clipboardData?.files ?? []);\n        if (files.some((f) => f.type.startsWith(\"image/\")) && editorRef.current) {\n          void insertFiles(editorRef.current, files);\n          return true;\n        }\n        // Plain text that looks like markdown becomes structure (headings, lists, code), the way Notion treats it.\n        const html = event.clipboardData?.getData(\"text/html\");\n        const text = event.clipboardData?.getData(\"text/plain\") ?? \"\";\n        if (\n          !html &&\n          editorRef.current &&\n          /(^|\\n)(#{1,3} |[-*+] |\\d+[.)] |> |```|- \\[[ x]\\] )|\\*\\*[^*]+\\*\\*/.test(text)\n        ) {\n          editorRef.current\n            .chain()\n            .focus()\n            .insertContent(editorRef.current.markdown?.parse(text) ?? text)\n            .run();\n          return true;\n        }\n        return false;\n      },\n      handleDrop: (view, event, _slice, moved) => {\n        const files = Array.from(event.dataTransfer?.files ?? []);\n        if (moved || !files.length || !editorRef.current) return false;\n        const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;\n        void insertFiles(editorRef.current, files, pos);\n        return true;\n      },\n    },\n    onUpdate: ({ editor: e }) => {\n      const v = valueOf(e);\n      emitted.current = v.html;\n      cb.current.onValueChange?.(v);\n    },\n    onTransaction: ({ editor: e }) =>\n      cb.current.onHistoryChange?.({ canUndo: e.can().undo(), canRedo: e.can().redo() }),\n  });\n  const editorRef = useRef<Editor | null>(null);\n  editorRef.current = editor;\n\n  // The plugin keeps the editor and its document; only its current copy changes.\n  useEffect(() => {\n    if (!editor || editor.isDestroyed) return;\n    const current = store.get();\n    if (current) {\n      const items = filterBlocks(current.query, i18n);\n      store.set({ ...current, items, index: Math.min(current.index, Math.max(0, items.length - 1)) });\n    }\n    editor.view.dispatch(editor.state.tr.setMeta(\"addToHistory\", false));\n    // Existing task-item node views do not update on a transaction without document changes.\n    editor.state.doc.descendants((node, pos) => {\n      if (node.type.name !== \"taskItem\") return;\n      const dom = editor.view.nodeDOM(pos);\n      if (!(dom instanceof HTMLElement)) return;\n      const label = i18n(\"taskCheckbox\", { text: node.textContent || i18n(\"emptyTask\") });\n      dom.querySelector(\":scope > label > input\")?.setAttribute(\"aria-label\", label);\n      const text = dom.querySelector(\":scope > label > span\");\n      if (text) text.textContent = label;\n    });\n  }, [editor, i18n, placeholder, blockHint, store]);\n\n  // Controlled value: apply only what did not come from the editor itself.\n  useEffect(() => {\n    if (!editor || value === undefined || value === emitted.current) return;\n    if (value !== editor.getHTML()) editor.commands.setContent(value, { emitUpdate: false });\n    emitted.current = value;\n  }, [editor, value]);\n  useEffect(() => {\n    editor?.setEditable(!readOnly);\n  }, [editor, readOnly]);\n\n  useImperativeHandle(\n    ref,\n    () => ({ editor, focus: () => editor?.commands.focus(), getValue: () => (editor ? valueOf(editor) : null) }),\n    [editor],\n  );\n\n  return (\n    <PartsContext.Provider value={classNames}>\n      <div\n        ref={rootRef}\n        data-slot=\"rich-text-editor\"\n        data-readonly={readOnly || undefined}\n        className={cn(styles.root, classNames?.root, className)}\n        style={style}\n      >\n        {editor && !readOnly && (\n          <DragHandle editor={editor} className={styles.handleWrap}>\n            <div data-slot=\"rich-text-editor-handle\" className={cn(styles.handle, classNames?.handle)}>\n              <Tooltip content={i18n(\"addABlockBelow\")}>\n                <button\n                  data-slot=\"rich-text-editor-handleButton\"\n                  data-add=\"\"\n                  type=\"button\"\n                  className={cn(styles.handleButton, classNames?.handleButton)}\n                  aria-label={i18n(\"addABlockBelow\")}\n                  onClick={() => {\n                    const { $from } = editor.state.selection;\n                    const end = $from.end($from.depth === 0 ? 0 : 1);\n                    editor\n                      .chain()\n                      .focus()\n                      .insertContentAt(Math.min(end + 1, editor.state.doc.content.size), {\n                        type: \"paragraph\",\n                        content: [{ type: \"text\", text: \"/\" }],\n                      })\n                      .run();\n                  }}\n                >\n                  <Plus className=\"size-4\" />\n                </button>\n              </Tooltip>\n              <Tooltip content={i18n(\"dragToMove\")}>\n                <span\n                  data-slot=\"rich-text-editor-handleButton\"\n                  className={cn(styles.handleButton, classNames?.handleButton)}\n                  data-grip=\"\"\n                  aria-label={i18n(\"dragToMove\")}\n                >\n                  <GripVertical className=\"size-4\" />\n                </span>\n              </Tooltip>\n            </div>\n          </DragHandle>\n        )}\n        <EditorContent editor={editor} className={classNames?.content} data-slot=\"rich-text-editor-content\" />\n        {uploadFailure && (\n          <Alert\n            className={classNames?.uploadError}\n            tone=\"danger\"\n            title={i18n(\"imageUploadFailed\")}\n            action={\n              <Button\n                size=\"sm\"\n                variant=\"secondary\"\n                loading={uploading}\n                onClick={() => {\n                  if (editor) void insertFiles(editor, uploadFailure.files, uploadFailure.pos);\n                }}\n              >\n                {i18n(\"retryImageUpload\")}\n              </Button>\n            }\n          >\n            {uploadFailure.message}\n          </Alert>\n        )}\n        {editor && !readOnly && container !== null && (\n          <BubbleMenu\n            editor={editor}\n            appendTo={container}\n            data-slot=\"rich-text-editor-toolbar\"\n            options={BUBBLE_OPTIONS}\n            shouldShow={showBubble}\n          >\n            <Toolbar editor={editor} toolbarRef={toolbar} />\n          </BubbleMenu>\n        )}\n        {editor && (\n          <SlashMenu\n            store={store}\n            editor={editor}\n            onPick={(item, range) => {\n              editor.chain().focus().deleteRange(range).run();\n              if (item.id === \"image\") fileInput.current?.click();\n              else item.run(editor);\n              store.set(null);\n            }}\n          />\n        )}\n        <input\n          ref={fileInput}\n          type=\"file\"\n          accept=\"image/*\"\n          hidden\n          onChange={(e) => {\n            const files = Array.from(e.target.files ?? []);\n            e.target.value = \"\";\n            if (editor) void insertFiles(editor, files);\n          }}\n        />\n      </div>\n    </PartsContext.Provider>\n  );\n});\n\nexport default RichTextEditor;\n"},{"path":"beamline/components/spinner/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/defaults.ts","content":"/** Pure allowed presentation defaults for Spinner; no component or runtime imports. */\nexport interface SpinnerDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/spinner/messages.ts","type":"registry:file","target":"src/ui/beamline/components/spinner/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  label: \"Loading\",\n  done: \"Done\",\n  error: \"Failed\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/spinner/spinner.module.css","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* With a delay the ring waits unseen and then fades in, so quick work never flashes it; the result (a check or a\n     cross) always shows at once. */\n  .root[data-delay] {\n    animation: appear var(--pui-fade-in) var(--pui-ease-default) var(--_delay) both;\n  }\n  @keyframes appear {\n    from {\n      opacity: 0;\n    }\n  }\n  /* The turn runs on the compositor (a CSS animation on the svg box) and costs no JS per frame. When the work ends the\n     arc closes into a full ring over the standard duration, so stopping the turn is invisible, and the check or cross\n     draws itself a beat later. All timing comes from the motion tokens; the loop period follows --pui-motion-scale. */\n  .svg[data-state=\"loading\"] {\n    animation: turn calc(0.8s * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1)) linear infinite;\n  }\n  .track {\n    stroke-opacity: 0.18;\n  }\n  .arc {\n    stroke-dasharray: 0.3 1;\n    transition: stroke-dasharray var(--pui-duration-standard) var(--pui-ease-enter);\n  }\n  .svg:not([data-state=\"loading\"]) .arc {\n    stroke-dasharray: 1 1;\n  }\n  .mark {\n    stroke-dasharray: 1 1;\n    animation: draw var(--pui-duration-standard) var(--pui-ease-enter) var(--pui-duration-fast) both;\n  }\n  @keyframes turn {\n    to {\n      transform: rotate(1turn);\n    }\n  }\n  @keyframes draw {\n    from {\n      stroke-dashoffset: 1;\n    }\n    to {\n      stroke-dashoffset: 0;\n    }\n  }\n  .svg[data-paused] {\n    animation-play-state: paused;\n  }\n  .svg[data-reduced],\n  .svg[data-reduced] .mark {\n    animation: none;\n  }\n  /* Reduction retains the loading/result shape without a decorative loop. */\n  @media (prefers-reduced-motion: reduce) {\n    .svg[data-state=\"loading\"],\n    .mark {\n      animation: none;\n    }\n  }\n}\n"},{"path":"beamline/components/spinner/spinner.tsx","type":"registry:file","target":"src/ui/beamline/components/spinner/spinner.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SpinnerDefaults } from \"./defaults\";\nexport type { SpinnerDefaults } from \"./defaults\";\n\nimport { useRef, type CSSProperties, type Ref } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useActive } from \"../../hooks/use-active\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./spinner.module.css\";\n\nexport interface SpinnerClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the svg part; no override by default. */\n  svg?: string;\n  /** Classes for the track part; no override by default. */\n  track?: string;\n  /** Classes for the arc part; no override by default. */\n  arc?: string;\n  /** Classes for the mark part; no override by default. */\n  mark?: string;\n}\n\nexport interface SpinnerProps {\n  /** `success` draws a check out of the ring, `error` a cross; both stop spinning. */\n  state?: \"loading\" | \"success\" | \"error\";\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Accessible name while loading. */\n  label?: string;\n  /** ms to wait before it shows: work that ends sooner never flashes a spinner (about 300 is a good start). Default 0. */\n  delay?: number;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLSpanElement>;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: SpinnerClassNames;\n}\n\n/** Icon box from the foundation icon steps (§14.2): sm 14, md 16, lg 20. */\nconst PX = { sm: \"var(--pui-icon-sm)\", md: \"var(--pui-icon-md)\", lg: \"var(--pui-icon-lg)\" } as const;\n\n/**\n * A ring that spins while work runs and closes into a drawn check (or cross) when it ends — the result is a shape as\n * well as a colour. With reduced motion the loading ring holds still.\n */\nexport function Spinner({\n  state = \"loading\",\n  size: sizeProp,\n  label: labelProp,\n  delay = 0,\n  className,\n  classNames,\n  style,\n  ref,\n}: SpinnerProps) {\n  const i18n = useMessages(\"spinner\", englishMessages);\n  const label = labelProp === undefined ? i18n(\"label\") : labelProp;\n\n  const scopeDefaults = useComponentDefaults<SpinnerDefaults>(\"spinner\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n  const host = useRef<HTMLSpanElement>(null);\n  const attach = useComposedRef(host, ref);\n  const active = useActive(host);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = tokens.reduced || managedReduced;\n\n  const box = PX[size];\n  const done = state !== \"loading\";\n  const tone = state === \"success\" ? \"text-green\" : state === \"error\" ? \"text-red\" : \"text-ink-2\";\n  return (\n    <span\n      ref={attach}\n      data-slot=\"spinner\"\n      data-state={state}\n      data-delay={delay > 0 && state === \"loading\" ? \"\" : undefined}\n      role={done ? \"img\" : \"status\"}\n      aria-label={state === \"success\" ? i18n(\"done\") : state === \"error\" ? i18n(\"error\") : label}\n      className={cn(styles.root, \"relative inline-grid shrink-0 place-items-center\", tone, classNames?.root, className)}\n      style={{ width: box, height: box, \"--_delay\": `${delay}ms`, ...style } as CSSProperties}\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        width=\"100%\"\n        height=\"100%\"\n        fill=\"none\"\n        aria-hidden=\"true\"\n        data-state={state}\n        data-slot=\"spinner-svg\"\n        data-reduced={reduced ? \"\" : undefined}\n        data-paused={active ? undefined : \"\"}\n        className={cn(styles.svg, classNames?.svg)}\n      >\n        <circle\n          data-slot=\"spinner-track\"\n          className={cn(styles.track, classNames?.track)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n        />\n        <circle\n          data-slot=\"spinner-arc\"\n          className={cn(styles.arc, classNames?.arc)}\n          cx=\"12\"\n          cy=\"12\"\n          r=\"9.5\"\n          pathLength={1}\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n          strokeLinecap=\"round\"\n          transform=\"rotate(-90 12 12)\"\n        />\n        {state === \"success\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M7.5 12.5l3 3 6-6.5\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        )}\n        {state === \"error\" && (\n          <path\n            data-slot=\"spinner-mark\"\n            className={cn(styles.mark, classNames?.mark)}\n            d=\"M8.5 8.5l7 7M15.5 8.5l-7 7\"\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n          />\n        )}\n      </svg>\n    </span>\n  );\n}\n\nexport default Spinner;\n"},{"path":"beamline/components/tooltip/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/tooltip/defaults.ts","content":"/** Pure allowed presentation defaults for Tooltip; no component or runtime imports. */\nexport interface TooltipDefaults {\n  /** Shared align setting; omit to inherit the surrounding scope or the component default. */\n  align?: \"start\" | \"center\" | \"end\";\n}\n"},{"path":"beamline/components/tooltip/tooltip.module.css","type":"registry:file","target":"src/ui/beamline/components/tooltip/tooltip.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A tooltip arrives from its control: it starts a step toward the control on the side it opens on and moves away\n     into place as it fades in (--pui-motion-in), so on any side it reads as coming out of what it explains. Inside a\n     group, the next one opens at once where the last one stood and travels to its own control (--pui-duration-fast),\n     while the last one goes in the arrival time: one tooltip moving along the toolbar, not two blinking. It leaves\n     over the departure fade. Reduced motion keeps the fades and drops the travel (--pui-motion-travel is 0). */\n  .tip {\n    --_dx: 0px;\n    --_dy: 0px;\n    --_tx: 0px;\n    --_ty: 0px;\n    z-index: var(--pui-z-tooltip);\n    transform-origin: var(--radix-tooltip-content-transform-origin);\n  }\n  .tip[data-side=\"top\"] {\n    --_dy: var(--pui-space-1);\n  }\n  .tip[data-side=\"bottom\"] {\n    --_dy: calc(-1 * var(--pui-space-1));\n  }\n  .tip[data-side=\"left\"] {\n    --_dx: var(--pui-space-1);\n  }\n  .tip[data-side=\"right\"] {\n    --_dx: calc(-1 * var(--pui-space-1));\n  }\n  .tip[data-state=\"delayed-open\"] {\n    animation: tip-in var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .tip[data-state=\"instant-open\"] {\n    animation: tip-fade var(--pui-fade-in) var(--pui-ease-default);\n  }\n  .tip[data-state=\"instant-open\"][data-travel] {\n    animation: tip-travel var(--pui-duration-fast) var(--pui-ease-standard);\n  }\n  .tip[data-state=\"closed\"] {\n    animation: tip-out var(--pui-fade-out) var(--pui-ease-default) forwards;\n  }\n  .tip[data-grouped][data-state=\"closed\"] {\n    animation-duration: var(--pui-fade-in);\n  }\n}\n@keyframes tip-in {\n  from {\n    opacity: 0;\n    translate: calc(var(--_dx) * var(--pui-motion-travel, 1)) calc(var(--_dy) * var(--pui-motion-travel, 1));\n  }\n}\n@keyframes tip-fade {\n  from {\n    opacity: 0;\n  }\n}\n@keyframes tip-travel {\n  from {\n    translate: calc(var(--_tx) * var(--pui-motion-travel, 1)) calc(var(--_ty) * var(--pui-motion-travel, 1));\n  }\n}\n@keyframes tip-out {\n  to {\n    opacity: 0;\n  }\n}\n"},{"path":"beamline/components/tooltip/tooltip.tsx","type":"registry:file","target":"src/ui/beamline/components/tooltip/tooltip.tsx","content":"\"use client\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { TooltipDefaults } from \"./defaults\";\nexport type { TooltipDefaults } from \"./defaults\";\n\nimport {\n  createContext,\n  useContext,\n  useRef,\n  useState,\n  type CSSProperties,\n  type Ref,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport * as RadixTooltip from \"@radix-ui/react-tooltip\";\nimport { usePortalContainer } from \"../../runtime/appearance\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./tooltip.module.css\";\n\n/** How long after one tooltip of a group closes the next opens at once (and travels from where the last one was). */\nconst SKIP = 400;\n/** The last control of a group whose tooltip showed: its centre and when its tooltip opened or closed. */\ntype Last = { el: Element; x: number; y: number; at: number };\nconst Grouped = createContext<RefObject<Last | null> | null>(null);\n\n/** Tooltips inside a group open instantly one after another (a toolbar): only the first waits, and each next one\n *  travels from where the last one stood. */\nexport function TooltipGroup({\n  children,\n  delay = 500,\n}: {\n  /** Content rendered inside the component. Required. */\n  children: ReactNode;\n  /** Milliseconds before the first tooltip in the group opens. Defaults to 500. */\n  delay?: number;\n}) {\n  const last = useRef<Last | null>(null);\n  return (\n    <RadixTooltip.Provider delayDuration={delay} skipDelayDuration={SKIP}>\n      <Grouped.Provider value={last}>{children}</Grouped.Provider>\n    </RadixTooltip.Provider>\n  );\n}\n\nexport interface TooltipClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the trigger part; no override by default. */\n  trigger?: string;\n  /** Classes for the shortcut part; no override by default. */\n  shortcut?: string;\n}\n\nexport interface TooltipProps {\n  /** What the control does; a few words. */\n  content: ReactNode;\n  /** A keyboard shortcut shown after the text, e.g. \"⌘B\". */\n  shortcut?: string;\n  /** The control: one element that can take a ref (a button). */\n  children: ReactElement;\n  /** Edge from which the panel or anchored content opens. Defaults to \"top\". */\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  /** Alignment within the available space. Defaults to \"center\". */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Open state, controlled or uncontrolled; the tooltip opens on hover and on keyboard focus either way. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Defaults to false. */\n  defaultOpen?: boolean;\n  /** Reports open state after a change; no callback by default. */\n  onOpenChange?: (open: boolean) => void;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: TooltipClassNames;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * Short supporting text for a control: it opens on hover after a moment and at once on keyboard focus, and closes on\n * Escape. It arrives out of its control on whichever side it opens. Inside a TooltipGroup the next one opens instantly\n * and travels from where the last one stood. Never put essential information or anything clickable in a tooltip.\n */\nexport function Tooltip({\n  content,\n  shortcut,\n  children,\n  side = \"top\",\n  align: alignProp,\n  open,\n  defaultOpen,\n  onOpenChange,\n  className,\n  classNames,\n  style,\n  ref,\n}: TooltipProps) {\n  const scopeDefaults = useComponentDefaults<TooltipDefaults>(\"tooltip\", [\"align\"]);\n  const align = alignProp ?? scopeDefaults.align ?? \"center\";\n\n  const group = useContext(Grouped);\n  const container = usePortalContainer();\n  const trigger = useRef<HTMLButtonElement>(null); // the control, whatever element it is (Radix types it as a button)\n  // in a group, how far this tooltip is from the last one, so it can start there and travel to its own control\n  const [travel, setTravel] = useState<{ x: number; y: number } | null>(null);\n  const change = (next: boolean) => {\n    const el = trigger.current;\n    if (group && el) {\n      const r = el.getBoundingClientRect();\n      const x = r.left + r.width / 2;\n      const y = r.top + r.height / 2;\n      const last = group.current;\n      if (next) {\n        const near = last && last.el !== el && performance.now() - last.at < SKIP;\n        setTravel(near ? { x: last.x - x, y: last.y - y } : null);\n        group.current = { el, x, y, at: Infinity };\n      } else if (last?.el === el) last.at = performance.now();\n    }\n    onOpenChange?.(next);\n  };\n  const tip = (\n    <RadixTooltip.Root open={open} defaultOpen={defaultOpen} onOpenChange={change}>\n      <RadixTooltip.Trigger ref={trigger} asChild data-slot=\"tooltip-trigger\" className={classNames?.trigger}>\n        {children}\n      </RadixTooltip.Trigger>\n      {container !== null && (\n        <RadixTooltip.Portal container={container}>\n          <RadixTooltip.Content\n            data-slot=\"tooltip\"\n            data-grouped={group ? \"\" : undefined}\n            data-travel={travel ? \"\" : undefined}\n            style={travel ? ({ \"--_tx\": `${travel.x}px`, \"--_ty\": `${travel.y}px`, ...style } as CSSProperties) : style}\n            ref={ref}\n            side={side}\n            align={align}\n            sideOffset={6}\n            collisionPadding={8}\n            className={cn(\n              \"flex max-w-[min(260px,calc(100vw-16px))] items-center gap-2 rounded-chip bg-tooltip px-2 py-1 text-xs leading-snug text-tooltip-fg shadow-overlay\",\n              styles.tip,\n              classNames?.root,\n              className,\n            )}\n          >\n            {content}\n            {shortcut && (\n              <kbd\n                data-slot=\"tooltip-shortcut\"\n                className={cn(\"font-mono text-2xs text-[var(--pui-tooltip-muted)]\", classNames?.shortcut)}\n              >\n                {shortcut}\n              </kbd>\n            )}\n          </RadixTooltip.Content>\n        </RadixTooltip.Portal>\n      )}\n    </RadixTooltip.Root>\n  );\n  return group ? tip : <RadixTooltip.Provider delayDuration={500}>{tip}</RadixTooltip.Provider>;\n}\n\nexport default Tooltip;\n"},{"path":"beamline/hooks/use-active.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-active.ts","content":"\"use client\";\n\nimport { useContext, useEffect, useState, type RefObject } from \"react\";\n\nimport { ActivityContext } from \"../runtime/activity\";\n\n/**\n * Whether an animated element should be doing work: it is (nearly) on screen and the tab is visible. Every\n * continuously animated component gates its loop, shader or CSS animation on this, so an effect scrolled out of view,\n * behind another tab, or in a collapsed panel costs nothing. One IntersectionObserver per element; no polling.\n */\nexport function useActive(\n  ref: RefObject<Element | null>,\n  {\n    margin = \"120px\",\n    key,\n    idle = true,\n  }: {\n    margin?: string;\n    /** Re-attach when this changes (an element that mounts later). */\n    key?: unknown;\n    /** Pause continuous work at the host idle boundary. Finite sequences set false so they finish. */\n    idle?: boolean;\n  } = {},\n) {\n  const active = useContext(ActivityContext);\n  const [inView, setInView] = useState(false);\n  const [visible, setVisible] = useState(\n    () => typeof document === \"undefined\" || document.visibilityState === \"visible\",\n  );\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { rootMargin: margin });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, margin, key]);\n  useEffect(() => {\n    const on = () => setVisible(document.visibilityState === \"visible\");\n    document.addEventListener(\"visibilitychange\", on);\n    return () => document.removeEventListener(\"visibilitychange\", on);\n  }, []);\n  return (!idle || active) && inView && visible;\n}\n"},{"path":"beamline/hooks/use-composed-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-composed-ref.ts","content":"\"use client\";\n\nimport { useCallback, type Ref, type RefObject } from \"react\";\n\n/** Keep a control's measurement/focus ref and the caller's ref, including React 19 callback cleanup. */\nexport function useComposedRef<T>(internal: RefObject<T | null>, external?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      internal.current = node;\n      if (typeof external === \"function\") {\n        const cleanup = external(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            internal.current = null;\n            cleanup();\n          };\n      } else if (external) external.current = node;\n    },\n    [internal, external],\n  );\n}\n"},{"path":"beamline/hooks/use-content-width.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-content-width.ts","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState, type RefObject } from \"react\";\n\nconst same = (width: number) => width;\n\n/**\n * The width of an element's content box, kept current with a ResizeObserver. It is first read in a layout effect, so\n * a layout chosen by width (a narrow mode, a chart's scale) is right in the first frame instead of jumping a frame\n * later. `fallback` stands in on the server and until the element exists; `key` re-measures when the element is\n * replaced (one that exists only in one view).\n */\nexport function useContentWidth(ref: RefObject<HTMLElement | null>, fallback: number, key?: unknown): number {\n  return useWidthAs(ref, same, fallback, key);\n}\n\n/**\n * The same width, kept as what a part needs from it (`(w) => w < 640` for a narrow mode): the part renders again only\n * when that changes, not at every pixel.\n */\nexport function useWidthAs<T>(\n  ref: RefObject<HTMLElement | null>,\n  read: (width: number) => T,\n  fallback: T,\n  key?: unknown,\n): T {\n  const [value, setValue] = useState(fallback);\n  const latest = useRef(read);\n  latest.current = read;\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    // the computed width is fractional and ignores transforms, like the observer's content box\n    const s = getComputedStyle(el);\n    const edges = [\"paddingLeft\", \"paddingRight\", \"borderLeftWidth\", \"borderRightWidth\"] as const;\n    const box = parseFloat(s.width);\n    const outer = Number.isFinite(box) ? box : el.offsetWidth;\n    const inner =\n      Number.isFinite(box) && s.boxSizing !== \"border-box\" ? 0 : edges.reduce((w, k) => w + parseFloat(s[k]), 0);\n    setValue(latest.current(outer - inner));\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([e]) => setValue(latest.current(e.contentRect.width)));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref, key]);\n  return value;\n}\n\n/**\n * The height of an element's content box, kept current like `useContentWidth`: for parts that lay out at whatever\n * height their parent gives them (a chart, a map or a book that fills its panel). Read in a layout effect, so the first\n * frame is right; with `enabled` false nothing is measured and the fallback stands.\n */\nexport function useContentHeight(\n  ref: RefObject<HTMLElement | null>,\n  fallback: number,\n  key?: unknown,\n  enabled = true,\n): number {\n  const [value, setValue] = useState(fallback);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!enabled || !el) return;\n    const s = getComputedStyle(el);\n    setValue(el.clientHeight - (parseFloat(s.paddingTop) || 0) - (parseFloat(s.paddingBottom) || 0));\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([e]) => setValue(e.contentRect.height));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref, key, enabled]);\n  return enabled ? value : fallback;\n}\n"},{"path":"beamline/hooks/use-controllable.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-controllable.ts","content":"\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * One value in both forms (rules/CONVENTIONS.md): the prop while it is passed, otherwise the element's own copy that\n * starts at `initial` (a value, or a function that makes it once). The setter takes a value or an updater; an updater\n * reads the latest value even twice in one event, and every change reaches `onChange`, controlled or not.\n * `Next` is what a change can carry when it is narrower than the state: a range that starts empty (null) but is only\n * ever changed to a range.\n */\nexport function useControllable<T, Next extends T = T>(\n  value: T | undefined,\n  initial: T | (() => T),\n  onChange?: (next: Next) => void,\n) {\n  const [own, setOwn] = useState<T>(initial);\n  const current = value !== undefined ? value : own;\n  const latest = useRef(current);\n  latest.current = current;\n  const set = useCallback(\n    (next: Next | ((was: T) => Next)) => {\n      const resolved = typeof next === \"function\" ? (next as (was: T) => Next)(latest.current) : next;\n      latest.current = resolved;\n      if (value === undefined) setOwn(resolved);\n      onChange?.(resolved);\n    },\n    [value, onChange],\n  );\n  return [current, set] as const;\n}\n"},{"path":"beamline/hooks/use-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/focus.ts","type":"registry:file","target":"src/ui/beamline/lib/focus.ts","content":"/**\n * Keeping a keyboard user's place when something disappears. Removing the element that has focus (a dismissed alert,\n * a removed chip or file, a deleted row) drops focus on <body>, and the next Tab starts at the top of the page.\n */\n\nconst TABBABLE = \"a[href], button, input:not([type=hidden]), select, textarea, [tabindex], [contenteditable=true]\";\n\nfunction tabbables(scope: Element) {\n  // a modal layer (menu, dialog) marks the rest of the page aria-hidden while it is open (data-aria-hidden): that\n  // content is still where focus should land once the layer closes\n  return [...scope.querySelectorAll<HTMLElement>(TABBABLE)].filter(\n    (e) =>\n      e.tabIndex >= 0 &&\n      !e.matches(\":disabled\") &&\n      !e.closest(\"[inert], [aria-hidden=true]:not([data-aria-hidden])\") &&\n      e.getClientRects().length > 0,\n  );\n}\n\n/** The control after `item` in reading order (outside it), or the one before it. */\nfunction neighbour(item: Element, scope: Element) {\n  const all = tabbables(scope).filter((e) => !item.contains(e));\n  return (\n    all.find((e) => item.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_FOLLOWING) ??\n    all.reverse().find((e) => item.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_PRECEDING) ??\n    null\n  );\n}\n\n/**\n * Call just before `item` is removed or hidden: if focus is inside it, focus moves to the next control after it (or\n * the one before). `scope` keeps the search inside a widget (a toolbar, a field); `fallback` is used when the scope has\n * nothing else to focus. Does nothing when focus is elsewhere, so a removal the user did not start never steals focus.\n */\nexport function focusAfterRemoval(\n  item: Element | null | undefined,\n  { scope, fallback }: { scope?: Element | null; fallback?: HTMLElement | null } = {},\n) {\n  if (!item || !item.contains(document.activeElement)) return;\n  (neighbour(item, scope ?? document.body) ?? fallback)?.focus();\n}\n\n/**\n * For an action that may or may not remove its item (the app decides, as with a row's Delete): call before running it.\n * If the item is gone afterwards and focus fell to the page, focus moves to the control that followed it.\n */\nexport function keepPlaceIfRemoved(item: Element | null | undefined, { scope }: { scope?: Element | null } = {}) {\n  if (!item) return;\n  const target = neighbour(item, scope ?? document.body);\n  const check = () => {\n    if (\n      !item.isConnected &&\n      (!document.activeElement || document.activeElement === document.body) &&\n      target?.isConnected\n    )\n      target.focus();\n  };\n  setTimeout(check, 0);\n  setTimeout(check, 400); // after an exit animation\n}\n\n/**\n * A press on a part's surface that hands focus to its control (a slider's well, a mark under its track): call from the\n * pointerdown. Focus goes to `control` without the keyboard's ring (`focusVisible: false`, so the part does not switch\n * to its keyboard look after a click). A mouse press is also followed by the browser's own focus step, which moves\n * focus to the nearest focusable ancestor or the page and leaves the arrow keys dead after the click; that step is\n * cancelled for this one press (and the press selects no text). Cancelling the pointerdown instead would also hide the\n * click from the browser, which then reads the focus as the keyboard's. A control a keyboard user already focused keeps\n * its ring.\n */\nexport function focusFromPress(\n  control: HTMLElement | null | undefined,\n  event: PointerEvent | { nativeEvent: PointerEvent },\n) {\n  if (!control) return;\n  const pointer = \"nativeEvent\" in event ? event.nativeEvent : event;\n  // asserted, not declared globally: shipped source must type-check without this repo's globals.d.ts, on the\n  // TypeScript an app already has (create-next-app installs 5.9, whose DOM lib lacks focusVisible)\n  control.focus({ preventScroll: true, focusVisible: false } as FocusOptions);\n  // the mouse's compatibility events follow the pointerdown in the same task; a finger's arrive only after a tap\n  if (pointer.pointerType !== \"mouse\") return;\n  const cancel = (e: MouseEvent) => e.preventDefault();\n  window.addEventListener(\"mousedown\", cancel, { capture: true, once: true });\n  setTimeout(() => window.removeEventListener(\"mousedown\", cancel, { capture: true }), 0);\n}\n"},{"path":"beamline/lib/highlight.ts","type":"registry:file","target":"src/ui/beamline/lib/highlight.ts","content":"import { common, createLowlight } from \"lowlight\";\n\n/** One syntax highlighter for the whole system (editor code blocks and model answers), coloured from tokens. */\nexport const lowlight: ReturnType<typeof createLowlight> = createLowlight(common);\n\nconst PALETTE: [RegExp, string][] = [\n  [/comment|quote/, \"var(--pui-ink-3)\"],\n  [/keyword|selector-tag|built_in|type|doctag/, \"var(--pui-violet)\"],\n  [/string|attr|template-tag|regexp|addition/, \"var(--pui-green)\"],\n  [/number|literal|symbol|bullet/, \"var(--pui-orange)\"],\n  [/title|section|function/, \"var(--pui-blue)\"],\n  [/meta|tag|name|deletion/, \"var(--pui-red)\"],\n];\nconst colorOf = (classes: string[]) => {\n  const c = classes.join(\" \");\n  return PALETTE.find(([re]) => re.test(c))?.[1];\n};\n\nexport type HastNode = { type: string; value?: string; properties?: { className?: string[] }; children?: HastNode[] };\ntype Token = { content: string; htmlStyle?: Record<string, string> };\n\n/** Flattens lowlight's tree into lines of coloured tokens (the shape Streamdown renders). */\nexport function toLines(root: HastNode) {\n  const lines: Token[][] = [[]];\n  const walk = (node: HastNode, classes: string[]) => {\n    if (node.type === \"text\") {\n      const color = colorOf(classes);\n      node.value!.split(\"\\n\").forEach((part, i) => {\n        if (i) lines.push([]);\n        if (part) lines[lines.length - 1].push(color ? { content: part, htmlStyle: { color } } : { content: part });\n      });\n      return;\n    }\n    const next = node.properties?.className ? [...classes, ...node.properties.className] : classes;\n    node.children?.forEach((c) => walk(c, next));\n  };\n  walk(root, []);\n  return lines;\n}\n\n/** Lines of coloured tokens for a code view; plain text when the language is unknown. */\nexport function highlightLines(code: string, language?: string): Token[][] {\n  const tree =\n    language && lowlight.registered(language)\n      ? lowlight.highlight(language, code)\n      : { type: \"root\", children: [{ type: \"text\", value: code }] };\n  return toLines(tree as HastNode);\n}\nexport type { Token as HighlightToken };\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":"Rich text editor is in src/ui/beamline/components/rich-text-editor/rich-text-editor.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/rich-text-editor"}}