{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"navigation-menu","type":"registry:component","title":"Navigation menu","description":"A site or docs header whose sections open into one panel that resizes and slides between them; NavigationMenuCard is the standard link row.","categories":["navigation"],"dependencies":["@radix-ui/react-direction@1.1.4","@radix-ui/react-navigation-menu@1.2.22","clsx@^2.1.1","lucide-react@^1.48.0","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/_shared/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/navigation-menu/messages.ts","type":"registry:file","target":"src/ui/beamline/components/navigation-menu/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English defaults; instance labels take precedence. */\nexport const english = {\n  main: \"Main\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/navigation-menu/navigation-menu.module.css","type":"registry:file","target":"src/ui/beamline/components/navigation-menu/navigation-menu.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* It takes its row's free width (the sections stay at its start) and is a size container: its panel's anchoring\n     answers to that room, not to the window. */\n  .root {\n    position: relative;\n    z-index: var(--pui-z-popover);\n    display: flex;\n    flex: 1 1 0;\n    min-width: 0;\n    container-type: inline-size;\n  }\n  /* The row of sections. Too narrow for all of them, it scrolls sideways with no bar and fades at the edge that has\n     more (a scroll-driven mask, so a row that fits has no fade at all). The padding keeps focus rings inside. */\n  .list {\n    position: relative;\n    display: flex;\n    align-items: center;\n    gap: var(--pui-space-0-5);\n    margin: calc(-1 * var(--pui-space-1));\n    padding: var(--pui-space-1);\n    overflow-x: auto;\n    overscroll-behavior-x: contain;\n    list-style: none;\n  }\n  /* only where the scroll timeline exists: elsewhere a plain scrolling row (an unsupported timeline would leave the\n     last keyframe stuck and fade the first letters for good) */\n  @supports (animation-timeline: scroll()) {\n    .list {\n      animation: edge-fade linear both;\n      animation-timeline: scroll(self inline);\n    }\n    .list:dir(rtl) {\n      animation-name: edge-fade-rtl;\n    }\n  }\n  @keyframes edge-fade {\n    from {\n      mask-image: linear-gradient(to right, currentColor calc(100% - 28px), transparent);\n    }\n    50% {\n      mask-image: linear-gradient(\n        to right,\n        transparent,\n        currentColor 28px,\n        currentColor calc(100% - 28px),\n        transparent\n      );\n    }\n    to {\n      mask-image: linear-gradient(to right, transparent, currentColor 28px);\n    }\n  }\n  @keyframes edge-fade-rtl {\n    from {\n      mask-image: linear-gradient(to left, currentColor calc(100% - 28px), transparent);\n    }\n    50% {\n      mask-image: linear-gradient(to left, transparent, currentColor 28px, currentColor calc(100% - 28px), transparent);\n    }\n    to {\n      mask-image: linear-gradient(to left, transparent, currentColor 28px);\n    }\n  }\n  /* Radix wraps the list in its own box: let it shrink so the list can scroll */\n  .root > div {\n    min-width: 0;\n  }\n  .anchor {\n    position: absolute;\n    inset-inline-start: 0;\n    top: 100%;\n    display: flex;\n  }\n  /* in a narrow room (a phone, a side panel: under 30rem) the panel hangs from the header's end, where a scrolling row has room,\n     and never passes the screen */\n  @container (width < 30rem) {\n    .anchor {\n      inset-inline: auto 0;\n      justify-content: flex-end;\n    }\n    .viewport {\n      max-width: calc(100vw - 3 * var(--pui-space-4));\n      transform-origin: top right;\n    }\n    .content {\n      max-width: calc(100vw - 3 * var(--pui-space-4));\n    }\n  }\n  /* the travelling plate under the section that is pointed at, focused or open; it lives in the row (an li with no\n     box of its own) so it scrolls with it */\n  .plateHost {\n    display: contents;\n  }\n  .plate {\n    border-radius: var(--pui-radius-chip);\n    background: var(--pui-hover);\n  }\n  /* the current page's rule for the row: one line under the current section that travels when another becomes\n     current (glide: the edge on its way leads, the other follows); it keeps the label's inset */\n  .current {\n    position: absolute;\n    bottom: calc(var(--pui-space-1) + var(--pui-space-0-5));\n    z-index: 2;\n    height: 2px;\n    border-radius: var(--pui-radius-pill);\n    background: var(--pui-current-edge, var(--pui-accent-ink));\n    clip-path: inset(0 var(--pui-space-3) round var(--pui-radius-pill));\n    pointer-events: none;\n  }\n  .list[data-indicated] > li > .item[data-active]::after {\n    content: none;\n  }\n  /* The navigation menu's panel: one surface that resizes to each section while the sections slide in from the side\n   the pointer came from. Reduced motion keeps the resize and drops the slide (durations become 1ms). */\n  .viewport {\n    position: relative;\n    overflow: hidden;\n    width: var(--radix-navigation-menu-viewport-width);\n    max-width: calc(100vw - 2 * var(--pui-space-4));\n    height: var(--radix-navigation-menu-viewport-height);\n    margin-top: var(--pui-space-1-5);\n    transform-origin: top left;\n    border-radius: var(--pui-radius-card);\n    transition:\n      width var(--pui-duration-standard) var(--pui-ease-standard),\n      height var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  .viewport[data-state=\"open\"] {\n    animation: grow var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .viewport[data-state=\"closed\"] {\n    animation: shrink var(--pui-motion-out) var(--pui-ease-default) forwards;\n  }\n  @keyframes grow {\n    from {\n      opacity: 0;\n      transform: scale(0.97);\n    }\n  }\n  @keyframes shrink {\n    to {\n      opacity: 0;\n      transform: scale(0.98);\n    }\n  }\n\n  .content {\n    position: absolute;\n    top: 0;\n    left: 0;\n    max-width: calc(100vw - 2 * var(--pui-space-4));\n    padding: var(--pui-space-2);\n  }\n  .content[data-motion=\"from-start\"] {\n    animation: from-start var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  .content[data-motion=\"from-end\"] {\n    animation: from-end var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  .content[data-motion=\"to-start\"] {\n    animation: to-start var(--pui-duration-standard) var(--pui-ease-standard) forwards;\n  }\n  .content[data-motion=\"to-end\"] {\n    animation: to-end var(--pui-duration-standard) var(--pui-ease-standard) forwards;\n  }\n  @keyframes from-start {\n    from {\n      opacity: 0;\n      transform: translateX(-24px);\n    }\n  }\n  @keyframes from-end {\n    from {\n      opacity: 0;\n      transform: translateX(24px);\n    }\n  }\n  @keyframes to-start {\n    to {\n      opacity: 0;\n      transform: translateX(-24px);\n    }\n  }\n  @keyframes to-end {\n    to {\n      opacity: 0;\n      transform: translateX(24px);\n    }\n  }\n\n  /* A top-level trigger or link: quiet text that brightens under the pointer and while its panel is open. The current\n     section also carries a short accent rule under its label, so \"current\" is never colour alone. */\n  .item {\n    position: relative;\n    z-index: 1;\n    display: inline-flex;\n    flex-shrink: 0;\n    height: var(--pui-size-8);\n    align-items: center;\n    gap: var(--pui-space-1);\n    padding-inline: var(--pui-space-3);\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    text-decoration: none;\n    transition:\n      color var(--pui-motion-out) var(--pui-ease-default),\n      scale var(--pui-motion-out) var(--pui-ease-default);\n  }\n  /* the row's plate paints hover and open; a link in a panel paints its own */\n  .item:hover,\n  .item[data-state=\"open\"] {\n    color: var(--pui-ink);\n    transition-duration: 0ms;\n  }\n  .item:active {\n    scale: var(--pui-press-scale);\n    transition-duration: 0ms;\n  }\n  .content .item:hover {\n    background: var(--pui-hover);\n  }\n  .item[data-active] {\n    color: var(--pui-ink);\n  }\n  .item[data-active]::after {\n    content: \"\";\n    position: absolute;\n    inset-inline: var(--pui-space-3);\n    bottom: var(--pui-space-0-5);\n    height: 2px;\n    border-radius: var(--pui-radius-pill);\n    background: var(--pui-current-edge, var(--pui-accent-ink));\n  }\n  .chevron {\n    width: var(--pui-icon-sm);\n    height: var(--pui-icon-sm);\n    color: var(--pui-ink-3);\n    transition: transform var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  .item[data-state=\"open\"] .chevron {\n    transform: rotate(180deg);\n  }\n\n  /* a link row in a panel */\n  .card {\n    display: flex;\n    gap: var(--pui-space-3);\n    padding: var(--pui-space-2-5);\n    /* concentric with the panel: its radius less the panel's inset, never under the chip radius */\n    --_r: max(var(--pui-radius-chip), calc(var(--pui-radius-card) - var(--pui-space-2)));\n    border-radius: var(--_r);\n    text-decoration: none;\n    transition: background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .card:hover,\n  .card:focus-visible {\n    background: var(--pui-hover);\n    transition-duration: 0ms;\n  }\n  .card[data-active] {\n    background: var(--pui-current-tint, var(--pui-accent-tint));\n    box-shadow: inset calc(2 * var(--pui-border-width)) 0 0 var(--pui-current-edge, var(--pui-accent-ink));\n  }\n  .card[data-active]:dir(rtl) {\n    box-shadow: inset calc(-2 * var(--pui-border-width)) 0 0 var(--pui-current-edge, var(--pui-accent-ink));\n  }\n  .tile {\n    display: grid;\n    flex-shrink: 0;\n    place-items: center;\n    width: var(--pui-size-8);\n    height: var(--pui-size-8);\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink-2);\n  }\n  .tile :where(svg) {\n    width: var(--pui-icon-md);\n    height: var(--pui-icon-md);\n  }\n  .cardText {\n    display: grid;\n    gap: var(--pui-space-0-5);\n    min-width: 0;\n  }\n  .cardTitle {\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-medium);\n  }\n  .cardDescription {\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-xs);\n    line-height: 1.35;\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.viewport {\n  composes: m-overlay from global;\n}\n.list {\n  composes: scrollbar-none from global;\n}\n"},{"path":"beamline/components/navigation-menu/navigation-menu.tsx","type":"registry:file","target":"src/ui/beamline/components/navigation-menu/navigation-menu.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english } from \"./messages\";\nimport { useRef, useState, type ComponentProps, type ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport * as RadixNav from \"@radix-ui/react-navigation-menu\";\nimport { GlidePlate } from \"../_shared/glide\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { glideProps, useIndicator } from \"../../hooks/use-indicator\";\nimport { useScopeDirection } from \"../../hooks/use-scope-direction\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./navigation-menu.module.css\";\n\nexport interface NavigationMenuClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the anchor part; no override by default. */\n  anchor?: string;\n  /** Classes for the viewport part; no override by default. */\n  viewport?: string;\n}\nexport type NavigationMenuProps = ComponentProps<typeof RadixNav.Root> & {\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: NavigationMenuClassNames;\n};\n\n/**\n * A site or docs header whose sections open into one panel that resizes and slides between them. ← → move between\n * sections, ↓ enters a panel, Escape closes it; pointing moves between open sections without a delay, and one plate\n * travels under the pointer from section to section. It takes its row's free width and answers to it: when it is\n * narrower than its sections the row scrolls sideways (every link stays reachable), and in a narrow room the panel\n * hangs from its end; the panel never grows wider than the screen.\n */\nexport function NavigationMenu({ className, classNames, children, dir, ref, ...props }: NavigationMenuProps) {\n  const i18n = useMessages(\"navigation-menu\", english);\n  const [attach, direction] = useScopeDirection<HTMLElement>(ref, dir);\n  return (\n    <RadixNav.Root\n      ref={attach}\n      dir={direction}\n      data-slot=\"navigation-menu\"\n      aria-label={i18n(\"main\")}\n      delayDuration={120}\n      className={cn(styles.root, classNames?.root, className)}\n      {...props}\n    >\n      {children}\n      <div data-slot=\"navigation-menu-anchor\" className={cn(styles.anchor, classNames?.anchor)}>\n        <RadixNav.Viewport data-slot=\"navigation-menu-viewport\" className={cn(styles.viewport, classNames?.viewport)} />\n      </div>\n    </RadixNav.Root>\n  );\n}\n\n/** The row of sections, with the plate that lights the one under the pointer or holding focus and glides to the next\n *  (the open section's trigger keeps it while the pointer is in its panel). */\nexport function NavigationMenuList({\n  className,\n  children,\n  ref,\n  onPointerOver,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: ComponentProps<typeof RadixNav.List>) {\n  // the current page's rule is one line that travels to the next current section (the shared glide), like the tabs'\n  const [list, current] = useIndicator<HTMLUListElement>(\":scope > li > [data-active]\");\n  const attach = useComposedRef(list, ref);\n  const [hot, setHot] = useState<string | null>(null);\n  const seen = useRef(0);\n  // a section of this row (not a link in a panel): its trigger or link, keyed once so the plate can find it again\n  const mark = (target: EventTarget | null) => {\n    const item = (target as HTMLElement | null)?.closest?.<HTMLElement>(\n      \"[data-slot=navigation-menu-trigger], [data-slot=navigation-menu-link]\",\n    );\n    if (!item || item.parentElement?.parentElement !== list.current) return;\n    item.dataset.glideKey ??= String(++seen.current);\n    setHot(item.dataset.glideKey);\n  };\n  return (\n    <RadixNav.List\n      ref={attach}\n      data-slot=\"navigation-menu-list\"\n      data-indicated={current ? \"\" : undefined}\n      className={cn(styles.list, className)}\n      onPointerOver={(e) => {\n        onPointerOver?.(e);\n        mark(e.target);\n      }}\n      onPointerLeave={(e) => {\n        onPointerLeave?.(e);\n        // into the panel or away: the plate goes back under the open section, or fades\n        setHot(null);\n      }}\n      onFocus={(e) => {\n        onFocus?.(e);\n        mark(e.target);\n      }}\n      onBlur={(e) => {\n        onBlur?.(e);\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHot(null);\n      }}\n      {...props}\n    >\n      <li role=\"none\" aria-hidden className={styles.plateHost}>\n        <GlidePlate\n          list={list}\n          selector={hot ? `[data-glide-key=\"${hot}\"]` : \":scope > li > [data-state=open]\"}\n          axis=\"xy\"\n          className={styles.plate}\n        />\n        {current && (\n          <span\n            data-slot=\"navigation-menu-indicator\"\n            className={cn(\"glide\", styles.current)}\n            {...glideProps(current)}\n          />\n        )}\n      </li>\n      {children}\n    </RadixNav.List>\n  );\n}\n\nexport const NavigationMenuItem = RadixNav.Item;\n\nexport interface NavigationMenuTriggerClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the chevron part; no override by default. */\n  chevron?: string;\n}\nexport function NavigationMenuTrigger({\n  className,\n  classNames,\n  children,\n  ...props\n}: ComponentProps<typeof RadixNav.Trigger> & {\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: NavigationMenuTriggerClassNames;\n}) {\n  return (\n    <RadixNav.Trigger\n      data-slot=\"navigation-menu-trigger\"\n      className={cn(styles.item, classNames?.root, className)}\n      {...props}\n    >\n      {children}\n      <ChevronDown\n        data-slot=\"navigation-menu-chevron\"\n        aria-hidden\n        className={cn(styles.chevron, classNames?.chevron)}\n      />\n    </RadixNav.Trigger>\n  );\n}\n\nexport function NavigationMenuContent({ className, ...props }: ComponentProps<typeof RadixNav.Content>) {\n  return <RadixNav.Content data-slot=\"navigation-menu-content\" className={cn(styles.content, className)} {...props} />;\n}\n\n/** A plain top-level link, or any link inside a panel. Marks the current page with `active`. */\nexport function NavigationMenuLink({ className, ...props }: ComponentProps<typeof RadixNav.Link>) {\n  return <RadixNav.Link data-slot=\"navigation-menu-link\" className={cn(styles.item, className)} {...props} />;\n}\n\nexport interface NavigationMenuCardClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the icon part; no override by default. */\n  icon?: string;\n  /** Classes for the text part; no override by default. */\n  text?: 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}\nexport interface NavigationMenuCardProps extends Omit<ComponentProps<typeof RadixNav.Link>, \"title\"> {\n  /** URL opened when the item is activated. Required. */\n  href: string;\n  /** Heading shown above the content. Required. */\n  title: ReactNode;\n  /** Supporting text beneath the label or heading. Defaults to no supporting text. */\n  description?: ReactNode;\n  /** Icon element shown beside the label. Defaults to no leading icon. */\n  icon?: ReactNode;\n  /** Whether this navigation item is the current page. Defaults to false. */\n  active?: boolean;\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?: NavigationMenuCardClassNames;\n}\n\n/** The standard link row inside a panel: icon on a tag tile, title and one line. The current page is tinted and edged. */\nexport function NavigationMenuCard({\n  href,\n  title,\n  description,\n  icon,\n  active,\n  className,\n  classNames,\n  ...props\n}: NavigationMenuCardProps) {\n  return (\n    <RadixNav.Link\n      href={href}\n      active={active}\n      data-slot=\"navigation-menu-card\"\n      className={cn(styles.card, classNames?.root, className)}\n      {...props}\n    >\n      {icon && (\n        <span data-slot=\"navigation-menu-card-icon\" className={cn(\"m-tag\", styles.tile, classNames?.icon)}>\n          {icon}\n        </span>\n      )}\n      <span data-slot=\"navigation-menu-card-text\" className={cn(styles.cardText, classNames?.text)}>\n        <span data-slot=\"navigation-menu-card-title\" className={cn(styles.cardTitle, classNames?.title)}>\n          {title}\n        </span>\n        {description && (\n          <span\n            data-slot=\"navigation-menu-card-description\"\n            className={cn(styles.cardDescription, classNames?.description)}\n          >\n            {description}\n          </span>\n        )}\n      </span>\n    </RadixNav.Link>\n  );\n}\n\nexport default NavigationMenu;\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-indicator.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-indicator.ts","content":"\"use client\";\n\nimport { APPEARANCE_EVENT } from \"../lib/motion-tokens\";\nimport { useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from \"react\";\n\nexport interface IndicatorBox {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n  /** False on the first placement, so the indicator appears in place instead of sliding in from the corner. */\n  ready: boolean;\n  /** Which way the last move went on each axis (-1 left or up, 1 right or down, 0 still), for the edge that leads. */\n  dx: -1 | 0 | 1;\n  dy: -1 | 0 | 1;\n  /** choice: a selection changed; layout: the same choice moved because the page reflowed (a resize, a font, a\n   *  type scale). A surface may travel for a choice and should simply be there after a reflow. */\n  cause: \"choice\" | \"layout\";\n}\n\ntype Box = Pick<IndicatorBox, \"x\" | \"y\" | \"w\" | \"h\">;\nconst same = (a: IndicatorBox | null, b: Box) => a !== null && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;\n\n/**\n * Follows the chosen item inside a container: the thumb of a segmented control, the pill or line of tabs, the ring of\n * radio cards. Returns a ref for the container and the box of the first element matching `selector` in it, relative\n * to the container (null when nothing matches). It measures before paint whenever `key` changes, and again when the\n * container resizes or a selection attribute changes inside it (for parts whose choice lives in a library, like tabs).\n */\nexport function useIndicator<T extends HTMLElement>(\n  selector: string,\n  key?: unknown,\n  onPlace?: (item: HTMLElement, container: T) => void,\n  /** offset (default): the item's offset box, for items whose offset parent is the container. rect: measured on screen\n   *  relative to the container, for items nested in positioned cells (a calendar day in its table cell). */\n  measure: \"offset\" | \"rect\" = \"offset\",\n): [RefObject<T | null>, IndicatorBox | null] {\n  const ref = useRef<T>(null);\n  const [box, setBox] = useState<IndicatorBox | null>(null);\n  const placed = useRef(onPlace);\n  useLayoutEffect(() => {\n    placed.current = onPlace;\n  });\n  useLayoutEffect(() => {\n    const container = ref.current;\n    if (!container) return;\n    const place = (cause: IndicatorBox[\"cause\"] = \"choice\") => {\n      const item = container.querySelector<HTMLElement>(selector);\n      if (!item) {\n        setBox(null);\n        return;\n      }\n      let next = { x: item.offsetLeft, y: item.offsetTop, w: item.offsetWidth, h: item.offsetHeight };\n      if (measure === \"rect\") {\n        const a = item.getBoundingClientRect();\n        const c = container.getBoundingClientRect();\n        next = {\n          x: Math.round(a.left - c.left - container.clientLeft + container.scrollLeft),\n          y: Math.round(a.top - c.top - container.clientTop + container.scrollTop),\n          w: item.offsetWidth,\n          h: item.offsetHeight,\n        };\n      }\n      setBox((b) =>\n        same(b, next)\n          ? b\n          : {\n              ...next,\n              cause,\n              ready: b !== null,\n              dx: b ? (Math.sign(next.x - b.x) as IndicatorBox[\"dx\"]) : 0,\n              dy: b ? (Math.sign(next.y - b.y) as IndicatorBox[\"dy\"]) : 0,\n            },\n      );\n      placed.current?.(item, container);\n    };\n    const resizes = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(() => place(\"layout\"));\n    resizes?.observe(container);\n    const observed = new Set<Element>();\n    const observeItems = () => {\n      // A full-width fixed-height list can stay the same size while its tabs/radio choices grow with type or labels.\n      // Ignore the decorative indicator itself, whose own dimensions are the output of this measurement.\n      const items = new Set([...container.children].filter((child) => child.getAttribute(\"aria-hidden\") !== \"true\"));\n      for (const old of observed)\n        if (!items.has(old)) {\n          resizes?.unobserve(old);\n          observed.delete(old);\n        }\n      for (const item of items)\n        if (!observed.has(item)) {\n          resizes?.observe(item);\n          observed.add(item);\n        }\n    };\n    observeItems();\n    place();\n    const mutations = new MutationObserver(() => {\n      observeItems();\n      place(\"choice\");\n    });\n    mutations.observe(container, {\n      subtree: true,\n      childList: true,\n      attributes: true,\n      attributeFilter: [\"data-state\", \"aria-checked\", \"aria-selected\", \"aria-current\", \"data-active\", \"data-open\"],\n    });\n    const reflow = () => place(\"layout\");\n    container.ownerDocument.addEventListener(APPEARANCE_EVENT, reflow);\n    container.ownerDocument.fonts?.addEventListener(\"loadingdone\", reflow);\n    return () => {\n      mutations.disconnect();\n      resizes?.disconnect();\n      container.ownerDocument.removeEventListener(APPEARANCE_EVENT, reflow);\n      container.ownerDocument.fonts?.removeEventListener(\"loadingdone\", reflow);\n    };\n  }, [selector, key, measure]);\n  return [ref, box];\n}\n\n/** The box as custom properties, for a stylesheet to place the indicator with: --pui-indicator-x/-y/-w/-h, and its\n *  four edges --pui-glide-l/-r/-t/-b, which the `glide` class moves one at a time. */\nexport function indicatorStyle(box: IndicatorBox): CSSProperties {\n  return {\n    \"--pui-indicator-x\": `${box.x}px`,\n    \"--pui-indicator-y\": `${box.y}px`,\n    \"--pui-indicator-w\": `${box.w}px`,\n    \"--pui-indicator-h\": `${box.h}px`,\n    \"--pui-glide-l\": `${box.x}px`,\n    \"--pui-glide-r\": `${box.x + box.w}px`,\n    \"--pui-glide-t\": `${box.y}px`,\n    \"--pui-glide-b\": `${box.y + box.h}px`,\n  } as CSSProperties;\n}\n\nconst AXIS = { x: [\"left\", undefined, \"right\"], y: [\"up\", undefined, \"down\"] } as const;\n\n/** Everything an indicator wearing the `glide` class needs (utilities.css): its place, whether it may move yet, and\n *  which way it is going, so the edge on that side leads and the other follows: it stretches toward the new choice and\n *  gathers there. */\nexport function glideProps(box: IndicatorBox) {\n  return {\n    style: indicatorStyle(box),\n    \"data-ready\": box.ready ? \"\" : undefined,\n    \"data-glide-x\": AXIS.x[box.dx + 1],\n    \"data-glide-y\": AXIS.y[box.dy + 1],\n  };\n}\n"},{"path":"beamline/hooks/use-scope-direction.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-scope-direction.ts","content":"\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState, type Ref } from \"react\";\nimport { useDirection } from \"@radix-ui/react-direction\";\nimport { useAppearance, useAppearanceRoot } from \"../runtime/appearance\";\nimport { APPEARANCE_EVENT } from \"../lib/motion-tokens\";\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n/** Preserve Radix direction in managed scopes and read the actual parent in static scopes before Radix stamps dir. */\nexport function useScopeDirection<T extends HTMLElement>(ref?: Ref<T>, explicit?: \"ltr\" | \"rtl\") {\n  const root = useRef<T | null>(null);\n  const provided = useDirection(explicit);\n  const appearance = useAppearance();\n  const managedRoot = useAppearanceRoot();\n  const [inherited, setInherited] = useState<\"ltr\" | \"rtl\">();\n  const read = useCallback(() => {\n    const around = root.current?.parentElement;\n    if (around)\n      setInherited(around.ownerDocument.defaultView?.getComputedStyle(around).direction === \"rtl\" ? \"rtl\" : \"ltr\");\n  }, []);\n  useIsoLayoutEffect(read, [read, provided, appearance]);\n  useEffect(() => {\n    const doc = root.current?.ownerDocument;\n    doc?.addEventListener(APPEARANCE_EVENT, read);\n    return () => doc?.removeEventListener(APPEARANCE_EVENT, read);\n  }, [read]);\n  const attach = useCallback(\n    (node: T | null) => {\n      root.current = node;\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            root.current = null;\n            cleanup();\n          };\n      } else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n  return [\n    attach,\n    explicit ?? (managedRoot ? provided : provided === \"rtl\" ? provided : (inherited ?? provided)),\n  ] as const;\n}\n"},{"path":"beamline/lib/utils.ts","type":"registry:file","target":"src/ui/beamline/lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\n/* The type roles (utilities.css) and the 2xs step are sizes, not text colours: without this, tailwind-merge reads\n   `text-overline` as a colour and drops it next to `text-ink-3`. A role and a size step still replace each other.\n   Likewise `font-title` is the look's title weight, not a family: read as a family it dropped `font-display` beside it,\n   and every merged page title lost the look's display face. */\nconst twMerge = extendTailwindMerge({\n  extend: {\n    classGroups: {\n      \"font-weight\": [{ font: [\"title\"] }],\n      \"font-family\": [{ font: [\"display\", \"numeric\", \"figure\"] }],\n      \"font-size\": [\n        {\n          text: [\n            \"2xs\",\n            \"display\",\n            \"title\",\n            \"heading\",\n            \"subheading\",\n            \"body\",\n            \"body-sm\",\n            \"reading\",\n            \"label\",\n            \"caption\",\n            \"overline\",\n            \"numeric\",\n            \"code\",\n          ],\n        },\n      ],\n    },\n  },\n});\n\n/** Merge class names, letting later Tailwind utilities win over earlier ones. */\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/** A small seeded random generator (LCG): the same seed gives the same sequence, for stable sample data and jitter. */\nexport function rng(seed = 1) {\n  let s = seed >>> 0 || 1;\n  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32;\n}\n\nexport const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n"}],"docs":"Navigation menu is in src/ui/beamline/components/navigation-menu/navigation-menu.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/navigation-menu"}}