{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"announcement-bar","type":"registry:component","title":"Announcement bar","description":"A banner across the top for news and offers: messages take turns, a countdown ticks, and dismissing folds it away and can remember it.","categories":["feedback"],"dependencies":["@radix-ui/react-direction@1.1.4","@radix-ui/react-slot@1.3.3","clsx@^2.1.1","lucide-react@^1.48.0","motion@^13.4.4","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/_shared/icon-action.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/icon-action.module.css","content":"@layer properties, theme, base, components, utilities;\n/* The small square action that sits inside another element: a field's clear and open buttons, a chip's remove, a\n   toast's close, a carousel's previous and next. One recipe so every one of them answers alike: quiet ink at rest,\n   the hover plate at once, the press at once, both let go over --pui-motion-out. Its corner is the chip radius: under\n   the nested-corner rule (.nest-corner) a part whose own corner is the chip never changes inside a rounded frame. */\n@layer components {\n  .root {\n    position: relative;\n    display: grid;\n    flex-shrink: 0;\n    place-items: center;\n    inline-size: var(--_size);\n    block-size: var(--_size);\n    border-radius: var(--pui-radius-chip);\n    color: var(--pui-ink-3);\n    /* opacity and translate stay in the list so a .reveal action keeps its own arrival whichever rule wins */\n    transition:\n      background-color var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      transform var(--pui-motion-out) var(--pui-ease-default),\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .root:hover:not(:disabled),\n  .root[data-pressed] {\n    background-color: var(--pui-hover);\n    color: var(--pui-ink);\n  }\n  .root:hover {\n    transition-duration: 0ms;\n  }\n  .root:active:not(:disabled) {\n    transform: scale(var(--pui-press-scale));\n    transition-duration: 0ms;\n  }\n  .root:disabled {\n    cursor: default;\n    opacity: 0.4;\n  }\n  .root[data-tone=\"ink-2\"] {\n    color: var(--pui-ink-2);\n  }\n  .root[data-tone=\"inherit\"] {\n    color: inherit;\n  }\n  .root[data-tone=\"inherit\"]:hover:not(:disabled) {\n    color: inherit;\n  }\n  /* on an inverted (ink) surface: inverse ink, a faint inverse plate */\n  .root[data-tone=\"inverse\"] {\n    color: color-mix(in oklab, var(--pui-ink-inverse) 72%, transparent);\n  }\n  .root[data-tone=\"inverse\"]:hover:not(:disabled),\n  .root[data-tone=\"inverse\"][data-pressed] {\n    background-color: color-mix(in oklab, var(--pui-ink-inverse) 12%, transparent);\n    color: var(--pui-ink-inverse);\n  }\n\n  .xs {\n    --_size: 1.25rem;\n  }\n  /* a 20px action still takes a 24px hit (WCAG 2.5.8): the bleed is outside the drawn square */\n  .xs::after {\n    position: absolute;\n    inset: calc((var(--pui-hit-min) - 1.25rem) / -2);\n    content: \"\";\n  }\n  .sm {\n    --_size: 1.5rem;\n  }\n  .md {\n    --_size: 1.75rem;\n  }\n  .lg {\n    --_size: 2rem;\n  }\n}\n"},{"path":"beamline/components/_shared/icon-action.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/icon-action.tsx","content":"\"use client\";\n// organism-allow-file: raw-control -- the shared part every element draws this control from\n\nimport type { ButtonHTMLAttributes, Ref } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./icon-action.module.css\";\n\n/** 20 · 24 · 28 · 32 px. A 20px action keeps a 24px hit outside its square. */\nexport type IconActionSize = \"xs\" | \"sm\" | \"md\" | \"lg\";\n\nexport interface IconActionProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n  /** The action's name: the button holds only an icon. */\n  \"aria-label\": string;\n  size?: IconActionSize;\n  /** Rest ink: ink-3 (the default, an action beside content), ink-2 (an action that is the row's point), inverse (on\n   *  an inverted ink surface) or inherit (on a painted surface that brings its own colours). */\n  tone?: \"ink-3\" | \"ink-2\" | \"inverse\" | \"inherit\";\n  /** Holds the hover plate while what it opens is open, or while it is switched on. */\n  pressed?: boolean;\n  ref?: Ref<HTMLButtonElement>;\n}\n\n/**\n * The small square icon action inside another element (clear, remove, dismiss, previous, next, retry). One recipe for\n * all of them; a full-size action with words is a Button, and Button with iconOnly is the standalone square.\n */\nexport function IconAction({\n  size = \"sm\",\n  tone = \"ink-3\",\n  pressed,\n  type = \"button\",\n  className,\n  ref,\n  ...props\n}: IconActionProps) {\n  return (\n    <button\n      ref={ref}\n      type={type}\n      data-tone={tone === \"ink-3\" ? undefined : tone}\n      data-pressed={pressed ? \"\" : undefined}\n      className={cn(styles.root, styles[size], className)}\n      {...props}\n    />\n  );\n}\n"},{"path":"beamline/components/_shared/text-action.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/text-action.module.css","content":"@layer properties, theme, base, components, utilities;\n/* An action that reads as words in the flow of a form or a sentence: \"Use another account\", \"Resend code\", \"Select\n   all\", \"Show fewer\". One recipe so every one of them answers alike; a framed action is a Button. */\n@layer components {\n  .root {\n    border-radius: var(--pui-radius-chip);\n    text-underline-offset: 0.25em;\n    transition: color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .root:hover {\n    transition-duration: 0ms;\n  }\n  .root:disabled {\n    pointer-events: none;\n    opacity: var(--pui-opacity-disabled);\n  }\n  /* accent: the way forward out of the form (a link's colour, underlined on hover) */\n  .root[data-tone=\"accent\"] {\n    color: var(--pui-accent-ink);\n    font-weight: var(--pui-weight-medium);\n  }\n  .root[data-tone=\"accent\"]:hover {\n    text-decoration-line: underline;\n  }\n  /* quiet: a side action in the caller's ink that takes full ink on hover */\n  .root[data-tone=\"quiet\"]:hover:not(:disabled) {\n    color: var(--pui-ink);\n  }\n  /* inline: inside a sentence, underlined at rest so it reads as a link without colour */\n  .root[data-tone=\"inline\"] {\n    text-decoration-line: underline;\n  }\n}\n"},{"path":"beamline/components/_shared/text-action.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/text-action.tsx","content":"\"use client\";\n// organism-allow-file: raw-control -- the shared part every element draws this control from\n\nimport type { ButtonHTMLAttributes, Ref } from \"react\";\nimport * as Slot from \"@radix-ui/react-slot\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./text-action.module.css\";\n\nexport interface TextActionProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n  /** accent: the way forward (a link's colour, underlined on hover); quiet: a side action in the surrounding ink that\n   *  takes full ink on hover; inline: inside a sentence, underlined at rest. */\n  tone?: \"accent\" | \"quiet\" | \"inline\";\n  /** Render the single child (a link) with the action's look instead of a <button>. */\n  asChild?: boolean;\n  ref?: Ref<HTMLButtonElement>;\n}\n\n/** An action that reads as words, not a framed control: \"Resend code\", \"Select all\", \"Show fewer\". */\nexport function TextAction({ tone = \"accent\", asChild, type = \"button\", className, ref, ...props }: TextActionProps) {\n  const shared = { \"data-tone\": tone, className: cn(styles.root, className) };\n  if (asChild) return <Slot.Root ref={ref} {...shared} {...props} />;\n  return <button ref={ref} type={type} {...shared} {...props} />;\n}\n"},{"path":"beamline/components/announcement-bar/announcement-bar.module.css","type":"registry:file","target":"src/ui/beamline/components/announcement-bar/announcement-bar.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* The bar is a size container: in a narrow room (under 28rem) the arrows fold away (a swipe, the rotation and the\n     dots of time still move it) and the message may take two lines; from there they sit at its ends. */\n  .bar {\n    container-type: inline-size;\n    position: relative;\n  }\n  .message {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    justify-content: center;\n    gap: var(--pui-space-0-5) var(--pui-space-2);\n    padding-block: var(--pui-space-2);\n    text-align: center;\n  }\n  .text {\n    display: -webkit-box;\n    -webkit-box-orient: vertical;\n    -webkit-line-clamp: 2;\n    overflow: hidden;\n  }\n  @container (width >= 28rem) {\n    .text {\n      -webkit-line-clamp: 1;\n    }\n  }\n  /* The wait before the next message, drawn: a hairline along the bottom that fills over the interval while the bar\n     rotates on its own, and starts again with each message. It is not there while held (pointed at or focused),\n     paused off screen or under reduced motion: only while time is really passing. */\n  .timer {\n    position: absolute;\n    inset-inline: 0;\n    bottom: 0;\n    height: 1px;\n    background: currentColor;\n    opacity: 0.28;\n    transform-origin: left center;\n    animation: wait var(--_interval) linear both;\n    pointer-events: none;\n  }\n  .timer:dir(rtl) {\n    transform-origin: right center;\n  }\n  @keyframes wait {\n    from {\n      transform: scaleX(0);\n    }\n  }\n}\n"},{"path":"beamline/components/announcement-bar/announcement-bar.tsx","type":"registry:file","target":"src/ui/beamline/components/announcement-bar/announcement-bar.tsx","content":"\"use client\";\n\nimport { useMessages, type Translator } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useDeprecatedProp } from \"../../hooks/use-deprecated-prop\";\n\nimport type { CSSProperties, Ref } from \"react\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { AnnouncementBarDefaults } from \"./defaults\";\nexport type { AnnouncementBarDefaults } from \"./defaults\";\n\nimport { useEffect, useRef } from \"react\";\nimport { ArrowRight, ChevronLeft, ChevronRight, X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useActive } from \"../../hooks/use-active\";\nimport { useHold } from \"../../hooks/use-hold\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport { TextAction } from \"../_shared/text-action\";\nimport { IconAction } from \"../_shared/icon-action\";\nimport { useNow } from \"../../lib/now\";\nimport { focusAfterRemoval } from \"../../lib/focus\";\nimport { SlotText } from \"../slot-text/slot-text\";\nimport styles from \"./announcement-bar.module.css\";\n\nexport interface Announcement {\n  /** Stable identifier used to match this record across updates. Required. */\n  id: string;\n  /** Message associated with this record. Required. */\n  message: string;\n  /** Action rendered with this group. Omitted by default. */\n  action?: { label: string; href?: string; onClick?: () => void };\n  /** A live countdown to a moment: { to: ms, label: \"ends in\" }. */\n  countdown?: { to: number; label?: string };\n}\n\nexport type AnnouncementBarClassNames = Partial<\n  Record<\n    \"wrap\" | \"root\" | \"body\" | \"previous\" | \"viewport\" | \"message\" | \"countdown\" | \"action\" | \"next\" | \"dismiss\",\n    string\n  >\n>;\n\nexport interface AnnouncementBarProps {\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: AnnouncementBarClassNames;\n  /** Announcements rotated through the banner in the supplied order. Required. */\n  messages: Announcement[];\n  /** Remembers a dismissal in this browser under this key. */\n  id?: string;\n  /** Controlled open state; omit to use defaultOpen. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Defaults to true. */\n  defaultOpen?: boolean;\n  /** Reports open state after a change; no callback by default. */\n  onOpenChange?: (open: boolean) => void;\n  /** Controlled zero-based index of the displayed item; omit to use defaultIndex. */\n  index?: number;\n  /** Initial visible item index when uncontrolled. Defaults to 0. */\n  defaultIndex?: number;\n  /** Reports visible item index after a change; no callback by default. */\n  onIndexChange?: (index: number) => void;\n  /** ms per message. Default 6000. */\n  interval?: number;\n  /** Rotate on its own; held by hover, keyboard focus or a tap, and still with reduced motion. Default true. */\n  autoPlay?: boolean;\n  /** Previous and next arrows. Default true with more than one message. */\n  controls?: boolean;\n  /** Offers a close control for the notice. Defaults to true. */\n  dismissible?: boolean;\n  /** Page-coloured or inverted banner treatment; defaults to neutral. */\n  appearance?: \"neutral\" | \"inverted\";\n  /** Legacy appearance setting; omitted by default. @deprecated Use appearance instead. */\n  tone?: \"neutral\" | \"inverted\";\n  /** Runs the action attached to the current announcement; no handler by default. */\n  onAction?: (announcement: Announcement) => void;\n  /** Runs when the current announcement countdown reaches zero; no handler by default. */\n  onCountdownEnd?: (announcement: Announcement) => void;\n  /** The region's name. Default \"Announcements\". */\n  label?: string;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLElement>;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n}\n\nconst left = (ms: number, i18n: Translator<typeof englishMessages>) => {\n  const s = Math.max(0, Math.floor(ms / 1000));\n  const d = Math.floor(s / 86400),\n    h = Math.floor((s % 86400) / 3600),\n    m = Math.floor((s % 3600) / 60);\n  return d\n    ? i18n(\"daysLeft\", { days: d, hours: h, minutes: i18n.number(m, { minimumIntegerDigits: 2, useGrouping: false }) })\n    : i18n(\"hoursLeft\", {\n        hours: h,\n        minutes: i18n.number(m, { minimumIntegerDigits: 2, useGrouping: false }),\n        seconds: i18n.number(s % 60, { minimumIntegerDigits: 2, useGrouping: false }),\n      });\n};\n\n/** A banner across the top for news and offers: messages take turns, a countdown ticks, and dismissing folds it away\n *  (and remembers, with an id). */\nexport function AnnouncementBar({\n  messages,\n  id,\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  interval = 6000,\n  autoPlay = true,\n  controls,\n  dismissible = true,\n  appearance: appearanceProp,\n  tone: toneProp,\n  onAction,\n  onCountdownEnd,\n  label: labelProp,\n  className,\n  classNames,\n  style,\n  ref,\n}: AnnouncementBarProps) {\n  const i18n = useMessages(\"announcement-bar\", englishMessages);\n  const label = labelProp === undefined ? i18n(\"label\") : labelProp;\n  const scopeDefaults = useComponentDefaults<AnnouncementBarDefaults>(\"announcement-bar\", [\"tone\"]);\n  useDeprecatedProp(\n    \"announcement-bar\",\n    \"tone\",\n    \"appearance\",\n    toneProp !== undefined || (appearanceProp === undefined && scopeDefaults.tone !== undefined),\n  );\n  const tone = appearanceProp ?? toneProp ?? scopeDefaults.tone ?? \"neutral\";\n\n  const key = id ? `pui-announcement:${id}` : null;\n  const [open, setOpen] = useControllable(\n    openProp,\n    () => {\n      if (key && typeof window !== \"undefined\")\n        try {\n          if (localStorage.getItem(key) === \"dismissed\") return false;\n        } catch {\n          /* storage blocked */\n        }\n      return defaultOpen;\n    },\n    onOpenChange,\n  );\n  const [indexValue, setIndexValue] = useControllable(indexProp, defaultIndex, onIndexChange);\n  const index = indexValue % Math.max(1, messages.length);\n  const host = useRef<HTMLElement>(null);\n  // the shared hold (WCAG 2.2.2): the pointer over it, keyboard focus, or a tap on touch until the next tap; a tap on\n  // its link or arrows holds it until a touch elsewhere, so the message chosen stays to be read\n  const hold = useHold(host);\n  const held = hold.held;\n  const attach = useComposedRef(host, ref);\n  const active = useActive(host);\n  const motionTokens = useMotionTokens(host);\n  const reduced = motionTokens.reduced;\n  const current = messages[index];\n  const go = (i: number) => {\n    const n = (i + messages.length) % messages.length;\n    setIndexValue(n);\n  };\n  const close = () => {\n    setOpen(false);\n    if (key)\n      try {\n        localStorage.setItem(key, \"dismissed\");\n      } catch {\n        /* storage blocked */\n      }\n  };\n\n  useEffect(() => {\n    if (!active || !autoPlay || held || reduced || messages.length < 2 || !open) return;\n    const t = setTimeout(() => go(index + 1), interval);\n    return () => clearTimeout(t);\n  }, [active, autoPlay, held, reduced, index, messages.length, open, interval]); // eslint-disable-line react-hooks/exhaustive-deps -- go is per render\n  // the clock is read in the browser only (lib/now): a server render shows no countdown yet\n  const now = useNow(!!current?.countdown && open, 1000, current?.countdown?.to);\n  const remaining = current?.countdown && now !== null ? current.countdown.to - now : null;\n  useEffect(() => {\n    if (remaining !== null && remaining <= 0 && current) onCountdownEnd?.(current);\n  }, [remaining !== null && remaining <= 0]); // eslint-disable-line react-hooks/exhaustive-deps -- once at zero\n\n  const inv = tone === \"inverted\";\n  const arrows = (controls ?? messages.length > 1) && messages.length > 1;\n  // time is passing toward the next message only while the bar rotates on its own and nothing holds it\n  const waiting = active && autoPlay && !held && !reduced && messages.length > 1 && open;\n  // a swipe on touch moves between messages like the arrows\n  const swipe = useRef<number | null>(null);\n  return (\n    <div\n      data-slot=\"announcement-bar\"\n      data-state={open ? \"open\" : \"closed\"}\n      className={cn(\n        \"grid transition-[grid-template-rows] duration-[var(--pui-duration-considered)] ease-[var(--pui-ease-standard)]\",\n        classNames?.wrap,\n      )}\n      style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\" }}\n    >\n      <section\n        data-slot=\"announcement-bar-content\"\n        ref={attach}\n        aria-label={label}\n        aria-roledescription={i18n(\"announcement\")}\n        inert={!open}\n        className={cn(\n          styles.bar,\n          \"min-h-0 overflow-hidden\",\n          inv ? \"bg-ink text-ink-inverse\" : \"border-b border-line-soft bg-surface text-ink\",\n          classNames?.root,\n          className,\n        )}\n        style={{ \"--_interval\": `${interval}ms`, ...style } as CSSProperties}\n        {...hold.handlers}\n        onPointerDown={(e) => {\n          hold.handlers.onPointerDown(e);\n          if (e.pointerType !== \"mouse\") swipe.current = e.clientX;\n        }}\n        onPointerUp={(e) => {\n          hold.handlers.onPointerUp(e);\n          const from = swipe.current;\n          swipe.current = null;\n          if (from === null || messages.length < 2) return;\n          const dx = (e.clientX - from) * (getComputedStyle(e.currentTarget).direction === \"rtl\" ? -1 : 1);\n          if (Math.abs(dx) > 40) go(index + (dx < 0 ? 1 : -1));\n        }}\n      >\n        <div\n          data-slot=\"announcement-bar-body\"\n          className={cn(\"flex min-h-10 items-center gap-2 px-3 text-sm\", classNames?.body)}\n        >\n          {arrows && (\n            <IconAction\n              data-slot=\"announcement-bar-previous\"\n              size=\"md\"\n              tone={inv ? \"inverse\" : \"ink-3\"}\n              aria-label={i18n(\"previousAnnouncement\")}\n              onClick={() => go(index - 1)}\n              // the arrows show where the bar has room for them\n              className={cn(\"hidden @[28rem]:grid\", classNames?.previous)}\n            >\n              <ChevronLeft className=\"size-4 rtl:rotate-180\" />\n            </IconAction>\n          )}\n          <div\n            data-slot=\"announcement-bar-viewport\"\n            className={cn(\"relative min-w-0 flex-1 overflow-hidden\", classNames?.viewport)}\n            aria-live={autoPlay && !held ? \"off\" : \"polite\"}\n          >\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {current && (\n                <motion.p\n                  data-slot=\"announcement-bar-message\"\n                  key={current.id}\n                  className={cn(styles.message, classNames?.message)}\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, y: 12 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={reduced ? { opacity: 0 } : { opacity: 0, y: -12 }}\n                  transition={{\n                    duration: reduced ? 0 : motionTokens.duration.considered,\n                    ease: motionTokens.ease.standard,\n                  }}\n                >\n                  <span className={styles.text}>{current.message}</span>\n                  {remaining !== null && (\n                    <span\n                      data-slot=\"announcement-bar-countdown\"\n                      className={cn(\n                        \"rounded-pill px-2 font-numeric text-xs tabular-nums\",\n                        inv ? \"bg-[color-mix(in_oklab,var(--pui-ink-inverse)_14%,transparent)]\" : \"bg-hover-2\",\n                        classNames?.countdown,\n                      )}\n                    >\n                      {remaining > 0 ? (\n                        <>\n                          {current.countdown?.label ?? i18n(\"endsIn\")}{\" \"}\n                          <SlotText\n                            value={left(remaining, i18n)}\n                            align=\"start\"\n                            duration={motionTokens.duration.considered * 1000}\n                            stagger={0}\n                          />\n                        </>\n                      ) : (\n                        i18n(\"ended\")\n                      )}\n                    </span>\n                  )}\n                  {current.action &&\n                    (current.action.href ? (\n                      <TextAction\n                        asChild\n                        className={cn(\"inline-flex items-center gap-1\", inv && \"text-ink-inverse\", classNames?.action)}\n                      >\n                        <a\n                          data-slot=\"announcement-bar-action\"\n                          href={current.action.href}\n                          onClick={() => onAction?.(current)}\n                        >\n                          {current.action.label}\n                          <ArrowRight className=\"size-3.5 rtl:rotate-180\" />\n                        </a>\n                      </TextAction>\n                    ) : (\n                      <TextAction\n                        data-slot=\"announcement-bar-action\"\n                        onClick={() => {\n                          current.action?.onClick?.();\n                          onAction?.(current);\n                        }}\n                        className={cn(\"inline-flex items-center gap-1\", inv && \"text-ink-inverse\", classNames?.action)}\n                      >\n                        {current.action.label}\n                        <ArrowRight className=\"size-3.5 rtl:rotate-180\" />\n                      </TextAction>\n                    ))}\n                </motion.p>\n              )}\n            </AnimatePresence>\n          </div>\n          {arrows && (\n            <IconAction\n              data-slot=\"announcement-bar-next\"\n              size=\"md\"\n              tone={inv ? \"inverse\" : \"ink-3\"}\n              aria-label={i18n(\"nextAnnouncement\")}\n              onClick={() => go(index + 1)}\n              // the arrows show where the bar has room for them\n              className={cn(\"hidden @[28rem]:grid\", classNames?.next)}\n            >\n              <ChevronRight className=\"size-4 rtl:rotate-180\" />\n            </IconAction>\n          )}\n          {dismissible && (\n            <IconAction\n              data-slot=\"announcement-bar-dismiss\"\n              size=\"md\"\n              tone={inv ? \"inverse\" : \"ink-3\"}\n              aria-label={i18n(\"dismissAnnouncements\")}\n              onClick={(e) => {\n                focusAfterRemoval(e.currentTarget.closest(\"[data-slot=announcement-bar]\"));\n                close();\n              }}\n              className={classNames?.dismiss}\n            >\n              <X className=\"size-4\" />\n            </IconAction>\n          )}\n        </div>\n        {waiting && <span key={index} aria-hidden data-slot=\"announcement-bar-timer\" className={styles.timer} />}\n      </section>\n    </div>\n  );\n}\n\nexport default AnnouncementBar;\n"},{"path":"beamline/components/announcement-bar/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/announcement-bar/defaults.ts","content":"/** Pure allowed presentation defaults for AnnouncementBar; no component or runtime imports. */\nexport interface AnnouncementBarDefaults {\n  /** Shared appearance setting; omit to inherit the component default. Scoped defaults retain the legacy key tone. */\n  tone?: \"neutral\" | \"inverted\";\n}\n"},{"path":"beamline/components/announcement-bar/messages.ts","type":"registry:file","target":"src/ui/beamline/components/announcement-bar/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: \"Announcements\",\n  previousAnnouncement: \"Previous announcement\",\n  endsIn: \"ends in\",\n  ended: \"ended\",\n  nextAnnouncement: \"Next announcement\",\n  dismissAnnouncements: \"Dismiss announcements\",\n  daysLeft: \"{days}d {hours}h {minutes}m\",\n  hoursLeft: \"{hours}h {minutes}m {seconds}s\",\n  announcement: \"announcement\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/slot-text/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/slot-text/defaults.ts","content":"/** Pure allowed presentation defaults for SlotText; no component or runtime imports. */\nexport interface SlotTextDefaults {\n  /** Shared align setting; omit to inherit the surrounding scope or the component default. */\n  align?: \"start\" | \"end\";\n}\n"},{"path":"beamline/components/slot-text/slot-text.module.css","type":"registry:file","target":"src/ui/beamline/components/slot-text/slot-text.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A reel stands on the run of digits it is rolling to; once it lands it is quietly reset to the same digit on its\n     middle run (no motion), so the next roll can turn either way without running out of reel. A roll is an animation\n     from the numeral it showed to the one it lands on, in whole numerals (the staircase timing in slot-text.tsx), so it\n     always starts from a whole digit, even when a new value arrives mid-roll; the two names take turns so each change\n     starts afresh. */\n  .reel {\n    position: absolute;\n    inset-inline: 0;\n    top: 0;\n    display: flex;\n    flex-direction: column;\n    animation-fill-mode: both;\n  }\n  .rollA {\n    animation-name: slot-text-roll-a;\n  }\n  .rollB {\n    animation-name: slot-text-roll-b;\n  }\n  /* the inline transform names the target; a landed reel is reset over it */\n  .reel[data-settled] {\n    transform: translateY(calc(var(--_rest) * -1lh)) !important;\n    animation: none !important;\n  }\n\n  /* a new place or a changed sign waits, unseen, for its turn in the ripple (its delay is the animation's length), then\n     is there whole: never half faded over its neighbour */\n  .arrive {\n    animation-name: slot-text-wait;\n    animation-timing-function: step-end;\n  }\n  @keyframes slot-text-roll-a {\n    from {\n      transform: translateY(calc(var(--_from) * -1lh));\n    }\n    to {\n      transform: translateY(calc(var(--_to) * -1lh));\n    }\n  }\n  @keyframes slot-text-roll-b {\n    from {\n      transform: translateY(calc(var(--_from) * -1lh));\n    }\n    to {\n      transform: translateY(calc(var(--_to) * -1lh));\n    }\n  }\n  @keyframes slot-text-wait {\n    from,\n    to {\n      visibility: hidden;\n    }\n  }\n}\n"},{"path":"beamline/components/slot-text/slot-text.tsx","type":"registry:file","target":"src/ui/beamline/components/slot-text/slot-text.tsx","content":"\"use client\";\n\nimport { type AnimationEvent, type Ref } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\n\nimport { useLocale, numberFormatter } from \"../../runtime/localization\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SlotTextDefaults } from \"./defaults\";\nexport type { SlotTextDefaults } from \"./defaults\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./slot-text.module.css\";\n\nexport interface SlotTextClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the spoken part; no override by default. */\n  spoken?: string;\n  /** Classes for the visual part; no override by default. */\n  visual?: string;\n  /** Classes for the digit part; no override by default. */\n  digit?: string;\n  /** Classes for the measure part; no override by default. */\n  measure?: string;\n  /** Classes for the reel part; no override by default. */\n  reel?: string;\n  /** Classes for the numeral part; no override by default. */\n  numeral?: string;\n  /** Classes for the character part; no override by default. */\n  character?: string;\n}\n\nexport interface SlotTextProps {\n  /** Text or number shown on the animated reels. Required. */\n  value: string | number;\n  /** Turns a number into its text: currency, thousands. */\n  format?: (value: number) => string;\n  /** ms per reel. Default: twice the considered duration (about 0.7 s), following the motion scale. */\n  duration?: number;\n  /** ms between reels, right to left. Default: the word stagger (about 30 ms), following the motion scale. */\n  stagger?: number;\n  /** Extra full turns a changing digit makes. Default 0. */\n  spins?: number;\n  /** Which side stays put when the length changes. Default end (numbers). */\n  align?: \"start\" | \"end\";\n  /** Read the new value aloud when it changes. Default false (most counters are glanceable). */\n  announce?: boolean;\n  /** Content direction. Numbers stay LTR; other text resolves its own direction instead of inheriting page layout. */\n  dir?: \"ltr\" | \"rtl\" | \"auto\";\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: SlotTextClassNames;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLSpanElement>;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n}\n\nconst DIGITS = \"0123456789\";\n\n/** A roll of `n` numerals as a staircase on the system's ease-out: each numeral is shown whole and held until the eased\n *  travel reaches the next, so the reel slows into place like an odometer and no frame ever shows two digits split by\n *  the window (the owner rejected digits drawn over each other mid-roll, 27 Oct). */\nconst stairs = new Map<number, string>();\nfunction staircase(n: number) {\n  let s = stairs.get(n);\n  if (!s) {\n    // ease-out cubic: travel p at time t = 1 - (1 - p)^(1/3); numeral k shows once the travel passes the middle of k\n    const at = (k: number) => `${((1 - Math.cbrt(1 - (k - 0.5) / n)) * 100).toFixed(2)}%`;\n    const points = [\"0 0%\"];\n    for (let k = 1; k <= n; k++) points.push(`${(k - 1) / n} ${at(k)}`, `${k / n} ${at(k)}`);\n    points.push(\"1 100%\");\n    s = `linear(${points.join(\", \")})`;\n    stairs.set(n, s);\n  }\n  return s;\n}\n\n/** A caller's ms, or the token default, on the system's clock. */\nconst clock = (ms: number | undefined, token: string) =>\n  ms === undefined ? token : `calc(${ms}ms * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1))`;\n\n/**\n * Text and numbers that roll into their new value on reels, digit by digit from the right. A number that goes up rolls\n * every reel up and one that goes down rolls them down (9 → 0 on the way up passes through the wrap, like an odometer),\n * so the direction of the change reads before the digits do. A reel ticks through whole numerals, slowing into place, so\n * any frame taken mid-roll reads clean digits; a place the new value no longer has goes at once, and a new place or a\n * changed sign appears whole when its turn in the ripple comes. Reduced motion swaps the value at once. The reels are\n * hidden from assistive tech, which reads the value as text.\n */\nexport function SlotText({\n  value,\n  format,\n  duration,\n  stagger,\n  spins = 0,\n  align: alignProp,\n  announce = false,\n  dir,\n  className,\n  classNames,\n  ref,\n  style,\n}: SlotTextProps) {\n  const locale = useLocale();\n  const scopeDefaults = useComponentDefaults<SlotTextDefaults>(\"slot-text\", [\"align\"]);\n  const align = alignProp ?? scopeDefaults.align ?? \"end\";\n\n  const text =\n    typeof value === \"number\"\n      ? format\n        ? format(value)\n        : numberFormatter(locale, { maximumSignificantDigits: 21 }).format(value)\n      : value;\n  const direction = dir ?? (typeof value === \"number\" || !/\\p{L}/u.test(text) ? \"ltr\" : \"auto\");\n  const host = useRef<HTMLSpanElement>(null);\n  const attach = useComposedRef(host, ref);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = tokens.reduced || managedReduced;\n  // the change is known in the same render it arrives in (no effect-delayed second pass): what it was, which way a\n  // number went, and a run counter that restarts the character fades\n  const [seen, setSeen] = useState({ text, value });\n  const [from, setFrom] = useState(text);\n  const [trend, setTrend] = useState(0);\n  const [run, setRun] = useState(0);\n  if (text !== seen.text) {\n    setSeen({ text, value });\n    setFrom(seen.text);\n    setTrend(typeof value === \"number\" && typeof seen.value === \"number\" ? Math.sign(value - seen.value) : 0);\n    setRun((r) => r + 1);\n  }\n  // when the roll has landed, the columns the old value needed and the new one does not are let go\n  const settle = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  useEffect(() => {\n    if (from === text) return;\n    const s = tokens.duration.considered * 2 + tokens.stagger.word * Math.max(text.length, from.length);\n    const ms = Math.max((duration ?? s * 1000) + (stagger ?? 0) * Math.max(text.length, from.length), 0) + 50;\n    settle.current = setTimeout(() => setFrom(text), reduced ? 0 : ms);\n    return () => clearTimeout(settle.current);\n  }, [from, text, run, duration, stagger, reduced, tokens.duration.considered, tokens.stagger.word]);\n  // a new roll starts from where each reel rests: the resting mark from the last roll is lifted before paint\n  useLayoutEffect(() => {\n    host.current?.querySelectorAll(\"[data-settled]\").forEach((r) => r.removeAttribute(\"data-settled\"));\n  }, [run]);\n  const len = Math.max(text.length, from.length);\n  const pad = (s: string) => (align === \"end\" ? s.padStart(len, \" \") : s.padEnd(len, \" \"));\n  const now = pad(text),\n    was = pad(from);\n  // a reel holds the digits 2·spins + 3 times; it rests on the middle run, and a roll that passes the wrap lands on\n  // the run beside it, from which the reel is quietly reset to the middle once it stops\n  const base = 10 * (spins + 1);\n  const reel = Array.from({ length: 10 * (2 * spins + 3) }, (_, k) => DIGITS[k % 10]);\n  const durationCss = clock(duration, \"calc(var(--pui-duration-considered) * 2)\");\n  const staggerCss = clock(stagger, \"var(--pui-stagger-word)\");\n  const landed = (e: AnimationEvent<HTMLSpanElement>) => {\n    const target = e.target as HTMLElement;\n    if (target.dataset.slot === \"slot-text-reel\") target.setAttribute(\"data-settled\", \"\");\n  };\n  return (\n    <span\n      ref={attach}\n      data-slot=\"slot-text\"\n      data-trend={trend > 0 ? \"up\" : trend < 0 ? \"down\" : undefined}\n      dir={direction}\n      className={cn(\"relative inline-flex tabular-nums [unicode-bidi:isolate]\", classNames?.root, className)}\n      style={style}\n    >\n      <span\n        data-slot=\"slot-text-spoken\"\n        className={cn(\"sr-only\", classNames?.spoken)}\n        aria-live={announce ? \"polite\" : undefined}\n      >\n        {text}\n      </span>\n      <span\n        aria-hidden\n        data-slot=\"slot-text-visual\"\n        className={cn(\"inline-flex overflow-hidden\", classNames?.visual)}\n        onAnimationEnd={landed}\n      >\n        {[...now].map((ch, i) => {\n          const order = align === \"end\" ? len - 1 - i : i;\n          // a column keeps its element while it stays the same distance from the aligned side, so it can roll\n          const key = align === \"end\" ? `e${order}` : `s${order}`;\n          const delay = `calc(${order} * ${staggerCss})`;\n          const old = was[i];\n          // a place the new value no longer has (1,284 → 488: the \"1\" and the \",\"): gone at once, so the number takes\n          // its new width with the change itself and no half-faded digit sits over its neighbour while the reels turn\n          const padding = align === \"end\" ? i < len - text.length : i >= text.length;\n          if (padding) return null;\n          if (DIGITS.includes(ch)) {\n            const d = Number(ch);\n            const before = Number(old);\n            const changed = old !== ch && DIGITS.includes(old) && run > 0;\n            const turns = changed ? spins * 10 : 0;\n            // the way the number went decides the way every reel turns; a text value turns each the short way\n            const to =\n              !changed || reduced\n                ? base + d\n                : trend > 0\n                  ? base + d + (d > before ? 0 : 10) + turns\n                  : trend < 0\n                    ? base + d - (d < before ? 0 : 10) - turns\n                    : base + d + turns;\n            const fresh = !DIGITS.includes(old) && run > 0 && !reduced;\n            const start = changed && !reduced ? base + before : to;\n            const steps = Math.abs(to - start);\n            return (\n              <span\n                key={key}\n                data-slot=\"slot-text-digit\"\n                className={cn(\n                  \"relative inline-block h-[1lh] overflow-hidden\",\n                  fresh && styles.arrive,\n                  classNames?.digit,\n                )}\n                style={fresh ? { animationDuration: delay } : undefined}\n              >\n                <span data-slot=\"slot-text-measure\" className={cn(\"invisible\", classNames?.measure)}>\n                  0\n                </span>\n                <span\n                  data-slot=\"slot-text-reel\"\n                  className={cn(styles.reel, steps > 0 && (run % 2 ? styles.rollA : styles.rollB), classNames?.reel)}\n                  style={\n                    {\n                      \"--_from\": start,\n                      \"--_to\": to,\n                      \"--_rest\": base + d,\n                      transform: \"translateY(calc(var(--_to) * -1lh))\",\n                      ...(run > 0 &&\n                        !reduced && {\n                          animationDuration: durationCss,\n                          animationDelay: delay,\n                          animationTimingFunction: staircase(Math.max(steps, 1)),\n                        }),\n                    } as CSSProperties\n                  }\n                >\n                  {reel.map((n, k) => (\n                    <span key={k} data-slot=\"slot-text-numeral\" className={cn(\"h-[1lh]\", classNames?.numeral)}>\n                      {n}\n                    </span>\n                  ))}\n                </span>\n              </span>\n            );\n          }\n          return (\n            <span\n              key={`${key}-${ch}-${run}`}\n              data-slot=\"slot-text-character\"\n              className={cn(\n                \"inline-block whitespace-pre\",\n                !reduced && old !== ch && run > 0 && styles.arrive,\n                classNames?.character,\n              )}\n              style={{ animationDuration: delay }}\n            >\n              {ch}\n            </span>\n          );\n        })}\n      </span>\n    </span>\n  );\n}\n\nexport default SlotText;\n"},{"path":"beamline/hooks/use-active.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-active.ts","content":"\"use client\";\n\nimport { useContext, useEffect, useState, type RefObject } from \"react\";\n\nimport { ActivityContext } from \"../runtime/activity\";\n\n/**\n * Whether an animated element should be doing work: it is (nearly) on screen and the tab is visible. Every\n * continuously animated component gates its loop, shader or CSS animation on this, so an effect scrolled out of view,\n * behind another tab, or in a collapsed panel costs nothing. One IntersectionObserver per element; no polling.\n */\nexport function useActive(\n  ref: RefObject<Element | null>,\n  {\n    margin = \"120px\",\n    key,\n    idle = true,\n  }: {\n    margin?: string;\n    /** Re-attach when this changes (an element that mounts later). */\n    key?: unknown;\n    /** Pause continuous work at the host idle boundary. Finite sequences set false so they finish. */\n    idle?: boolean;\n  } = {},\n) {\n  const active = useContext(ActivityContext);\n  const [inView, setInView] = useState(false);\n  const [visible, setVisible] = useState(\n    () => typeof document === \"undefined\" || document.visibilityState === \"visible\",\n  );\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { rootMargin: margin });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, margin, key]);\n  useEffect(() => {\n    const on = () => setVisible(document.visibilityState === \"visible\");\n    document.addEventListener(\"visibilitychange\", on);\n    return () => document.removeEventListener(\"visibilitychange\", on);\n  }, []);\n  return (!idle || active) && inView && visible;\n}\n"},{"path":"beamline/hooks/use-composed-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-composed-ref.ts","content":"\"use client\";\n\nimport { useCallback, type Ref, type RefObject } from \"react\";\n\n/** Keep a control's measurement/focus ref and the caller's ref, including React 19 callback cleanup. */\nexport function useComposedRef<T>(internal: RefObject<T | null>, external?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      internal.current = node;\n      if (typeof external === \"function\") {\n        const cleanup = external(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            internal.current = null;\n            cleanup();\n          };\n      } else if (external) external.current = node;\n    },\n    [internal, external],\n  );\n}\n"},{"path":"beamline/hooks/use-controllable.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-controllable.ts","content":"\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * One value in both forms (rules/CONVENTIONS.md): the prop while it is passed, otherwise the element's own copy that\n * starts at `initial` (a value, or a function that makes it once). The setter takes a value or an updater; an updater\n * reads the latest value even twice in one event, and every change reaches `onChange`, controlled or not.\n * `Next` is what a change can carry when it is narrower than the state: a range that starts empty (null) but is only\n * ever changed to a range.\n */\nexport function useControllable<T, Next extends T = T>(\n  value: T | undefined,\n  initial: T | (() => T),\n  onChange?: (next: Next) => void,\n) {\n  const [own, setOwn] = useState<T>(initial);\n  const current = value !== undefined ? value : own;\n  const latest = useRef(current);\n  latest.current = current;\n  const set = useCallback(\n    (next: Next | ((was: T) => Next)) => {\n      const resolved = typeof next === \"function\" ? (next as (was: T) => Next)(latest.current) : next;\n      latest.current = resolved;\n      if (value === undefined) setOwn(resolved);\n      onChange?.(resolved);\n    },\n    [value, onChange],\n  );\n  return [current, set] as const;\n}\n"},{"path":"beamline/hooks/use-deprecated-prop.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-deprecated-prop.ts","content":"\"use client\";\n\nimport { useEffect } from \"react\";\n\n// Bundlers replace NODE_ENV for browser builds; this declaration emits no Node dependency.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nconst warned = new Set<string>();\n\n/** Report a legacy prop once after mount in development; production and server renders stay silent. */\nexport function useDeprecatedProp(component: string, prop: string, replacement: string, supplied: boolean) {\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"development\" || !supplied) return;\n    const key = `${component}.${prop}`;\n    if (warned.has(key)) return;\n    warned.add(key);\n    console.warn(`[Beamline] ${key} is deprecated. Use ${replacement} instead; the legacy prop still works.`);\n  }, [component, prop, replacement, supplied]);\n}\n"},{"path":"beamline/hooks/use-hold.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-hold.ts","content":"\"use client\";\n\nimport { useState, type FocusEvent, type PointerEvent, type RefObject } from \"react\";\nimport { useTap, useTouchAway } from \"./use-tap\";\n\n/**\n * Holds still what moves on its own (WCAG 2.2.2, pause what moves for more than five seconds), the same way for\n * everyone: a mouse over it, keyboard focus inside it (a moving element with nothing focusable inside is a tab stop\n * while it moves, so a keyboard can reach it), or on touch, where there is no hover, a finger on it and then a\n * tap, which holds it until the next tap on it. A tap that chooses something inside it (a button, a link) holds it\n * the way focus does: until the next touch somewhere else, so what was chosen does not run away. A touch that scrolls\n * the page is not a tap (the shared `useTap`). Spread `handlers` on the moving element's root (call them from your own when the root has\n * its own pointer handlers) and stop the motion while `held`.\n */\nexport function useHold(ref: RefObject<HTMLElement | null>) {\n  const [over, setOver] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [pressing, setPressing] = useState(false);\n  // a touch hold: \"tap\" lasts until the next tap on it, \"choice\" until a touch elsewhere\n  const [pin, setPin] = useState<\"tap\" | \"choice\" | null>(null);\n  useTouchAway(ref, pin === \"choice\", () => setPin(null));\n  const tap = useTap((e) => {\n    const root = e.currentTarget;\n    const hit = (e.target as Element).closest?.(\"button, a[href], input, select, textarea, [role=button]\");\n    const chose = !!hit && hit !== root && root.contains(hit);\n    setPin((p) => (chose ? \"choice\" : p ? null : \"tap\"));\n  });\n\n  const handlers = {\n    onPointerEnter: (e: PointerEvent<HTMLElement>) => {\n      if (e.pointerType === \"mouse\") setOver(true);\n    },\n    onPointerLeave: (e: PointerEvent<HTMLElement>) => {\n      if (e.pointerType === \"mouse\") setOver(false);\n    },\n    // a finger on it holds it while it is down\n    onPointerDown: (e: PointerEvent<HTMLElement>) => {\n      tap.onPointerDown(e);\n      if (e.pointerType !== \"mouse\") setPressing(true);\n    },\n    onPointerUp: (e: PointerEvent<HTMLElement>) => {\n      setPressing(false);\n      tap.onPointerUp(e);\n    },\n    onPointerCancel: () => {\n      setPressing(false);\n      tap.onPointerCancel();\n    },\n    // keyboard focus holds it; the focus a tap or click leaves behind does not, or the tap that lets it go again would\n    // leave it held (the touch rules above already hold what a tap chose)\n    onFocus: (e: FocusEvent<HTMLElement>) => {\n      if ((e.target as Element).matches(\":focus-visible\")) setFocused(true);\n    },\n    onBlur: (e: FocusEvent<HTMLElement>) => {\n      if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);\n    },\n  };\n\n  return { held: over || focused || pressing || pin !== null, handlers };\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-tap.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-tap.ts","content":"\"use client\";\n\nimport { useEffect, useRef, type PointerEvent, type RefObject } from \"react\";\n\n/**\n * A tap on touch or pen: down and up within 10 px of each other, so a touch that scrolls the page or swipes is not one\n * (a scroll ends in pointercancel, a swipe travels). The mouse is left to hover and click. Spread `handlers` on the\n * element; `onTap` gets the lifting event, so `e.target` says what was tapped.\n */\nexport function useTap(onTap: (e: PointerEvent<HTMLElement>) => void) {\n  const down = useRef<{ id: number; x: number; y: number } | null>(null);\n  return {\n    onPointerDown: (e: PointerEvent<HTMLElement>) => {\n      down.current = e.pointerType === \"mouse\" ? null : { id: e.pointerId, x: e.clientX, y: e.clientY };\n    },\n    onPointerUp: (e: PointerEvent<HTMLElement>) => {\n      const from = down.current;\n      down.current = null;\n      if (from && from.id === e.pointerId && Math.hypot(e.clientX - from.x, e.clientY - from.y) <= 10) onTap(e);\n    },\n    onPointerCancel: () => {\n      down.current = null;\n    },\n  };\n}\n\n/** While `on`, a touch anywhere outside `ref` calls `away`: what a tap held lasts until a touch somewhere else. */\nexport function useTouchAway(ref: RefObject<Element | null>, on: boolean, away: () => void) {\n  const latest = useRef(away);\n  useEffect(() => {\n    latest.current = away;\n  });\n  useEffect(() => {\n    if (!on) return;\n    const down = (e: globalThis.PointerEvent) => {\n      if (!ref.current?.contains(e.target as Node)) latest.current();\n    };\n    document.addEventListener(\"pointerdown\", down, true);\n    return () => document.removeEventListener(\"pointerdown\", down, true);\n  }, [on, ref]);\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/now.ts","type":"registry:file","target":"src/ui/beamline/lib/now.ts","content":"import { useContext, useLayoutEffect, useMemo, useState, useSyncExternalStore } from \"react\";\nimport { ActivityContext } from \"../runtime/activity\";\n\n/*\n * The time, read only in the browser. A component that reads the clock while it renders breaks server rendering:\n * Next.js refuses to prerender a page that does (\"encountered the unstable value `new Date()` in a Client Component\",\n * create-next-app's default since 16), and any server's time differs from the browser's at hydration. These hooks give\n * null on the server and while hydrating, and the real value in the browser before the first paint.\n */\n\nconst still = () => () => {};\nconst startOfToday = () => {\n  const d = new Date();\n  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n};\nconst onServer = () => null;\n\n/** Today at 00:00 local time; null on the server and while hydrating. */\nexport function useToday(): Date | null {\n  const day = useSyncExternalStore(still, startOfToday, onServer);\n  return useMemo(() => (day === null ? null : new Date(day)), [day]);\n}\n\n/** The time in ms, read before the first paint and again every `every` ms while `ticking`; null on the server. */\nexport function useNow(ticking = false, every = 1000, until?: number): number | null {\n  const active = useContext(ActivityContext);\n  const [now, setNow] = useState<number | null>(null);\n  // A finite deadline must reach its completion callback even when the host is idle, then stop ticking.\n  const running = ticking && (until === undefined ? active : now === null || now < until);\n  useLayoutEffect(() => {\n    setNow(Date.now());\n    if (!running) return;\n    const id = setInterval(() => setNow(Date.now()), every);\n    return () => clearInterval(id);\n  }, [running, every, until]);\n  return now;\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":"Announcement bar is in src/ui/beamline/components/announcement-bar/announcement-bar.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/announcement-bar"}}