{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"live-cursors","type":"registry:component","title":"Live cursors","description":"Who else is working on this, and where: over any shared surface, each person's pointer in the colour their name always gets, with a name flag, gliding between the positions your realtime service sends; their selections outlined in the same colour, a short message beside the pointer when they type one (press / for your own), quieter when they go idle and fading when they leave. Positions are fractions of the surface, so every screen size agrees.","categories":["display"],"dependencies":["@radix-ui/react-direction@1.1.4","clsx@^2.1.1","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/avatar/avatar.module.css","type":"registry:file","target":"src/ui/beamline/components/avatar/avatar.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Avatar: a face (initials on the name's hue, or a photo) and an optional presence mark. The mark is cut out of the\n     face with a mask, so the gap around it is truly empty and reads on any ground: a card, a row hover, glass, a photo. */\n  .root {\n    --_s: 36px;\n    --_d: 10px;\n    --_g: 2px;\n    position: relative;\n    display: inline-grid;\n    flex: none;\n    width: var(--_s);\n    height: var(--_s);\n    place-items: center;\n    /* initials scale with the face (geometry), never below 80% of the smallest type step */\n    font-size: max(calc(var(--pui-text-2xs) * 0.8), calc(var(--_s) * 0.36));\n    font-weight: var(--pui-weight-medium);\n    line-height: 1;\n    user-select: none;\n  }\n  .xs {\n    --_s: 20px;\n    --_d: 6px;\n    --_g: 1.5px;\n  }\n  .sm {\n    --_s: 28px;\n    --_d: 8px;\n  }\n  .lg {\n    --_s: 44px;\n    --_d: 12px;\n  }\n  .xl {\n    --_s: 56px;\n    --_d: 14px;\n    --_g: 2.5px;\n  }\n  .face {\n    position: absolute;\n    inset: 0;\n    display: grid;\n    place-items: center;\n    overflow: hidden;\n    border-radius: 50%;\n    background: color-mix(in oklab, var(--pui-avatar-hue) 24%, var(--pui-surface-2));\n    color: color-mix(in oklab, var(--pui-avatar-hue) 55%, var(--pui-ink));\n  }\n  /* the initials keep their tint but mix it into black or white, whichever the face's fill reads better against (its\n     luminance at WCAG's 0.179 crossover): mixed into the scope's ink they fell to 3.5–4.5 on mid fills, a brand-toned\n     sidebar's above all; this keeps every face at 5.5:1 or more in every look */\n  @supports (color: color(from red xyz-d65 x y z)) {\n    .face {\n      --_ink: color(\n        from color-mix(in oklab, var(--pui-avatar-hue) 24%, var(--pui-surface-2)) xyz-d65\n          calc(0.9505 * round(0.679 - y)) round(0.679 - y) calc(1.089 * round(0.679 - y))\n      );\n      color: color-mix(in oklab, var(--pui-avatar-hue) 55%, var(--_ink));\n    }\n  }\n  /* a company tile takes the look's card corner, never more than a third of the face, so it stays a square at xs */\n  .root[data-shape=\"square\"] .face {\n    /* nesting input (O1 §14.1, as Button): a parent that holds this near its rounded corner sets --pui-nest-radius and\n       --pui-nest-inset; the corner becomes radius − inset, clamped between a floor (chip) and the part's own role radius (or --pui-nest-ceiling: a pill container sets it to the pill radius). Unset, the declaration is invalid and the own corner\n       applies. --pui-avatar-radius wins over both. */\n    --_avatar-nest: clamp(\n      var(--pui-radius-chip),\n      calc(var(--pui-nest-radius) - var(--pui-nest-inset, 0px)),\n      var(--pui-nest-ceiling, min(var(--pui-radius-card), calc(var(--_s) / 3)))\n    );\n    --_r: var(--pui-avatar-radius, var(--_avatar-nest, min(var(--pui-radius-card), calc(var(--_s) / 3))));\n    border-radius: var(--_r);\n  }\n  .photo {\n    position: absolute;\n    inset: 0;\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    transition: opacity var(--pui-fade-in) var(--pui-ease-default);\n  }\n  /* still on its way: the initials hold the place, and the photo fades in over them when it lands */\n  .photo[data-pending] {\n    opacity: 0;\n  }\n  /* The mark sits on the bottom inline-end corner; the face loses a disc one gap wider than the mark under it. */\n  .root[data-status] .face {\n    --_at: calc(var(--_d) / 2 - 1px);\n    mask: radial-gradient(\n      circle at calc(100% - var(--_at)) calc(100% - var(--_at)),\n      transparent calc(var(--_d) / 2 + var(--_g)),\n      black calc(var(--_d) / 2 + var(--_g) + 0.5px)\n    );\n  }\n  .root[data-status]:dir(rtl) .face {\n    mask: radial-gradient(\n      circle at var(--_at) calc(100% - var(--_at)),\n      transparent calc(var(--_d) / 2 + var(--_g)),\n      black calc(var(--_d) / 2 + var(--_g) + 0.5px)\n    );\n  }\n  .mark {\n    position: absolute;\n    inset-inline-end: -1px;\n    bottom: -1px;\n  }\n  /* a presence that changed settles into its new mark: the colour and shape arrive at once, the mark grows from a\n     little smaller (reduced motion keeps only the fade) */\n  .mark[data-changed] {\n    animation: presence-settle var(--pui-duration-feedback) var(--pui-ease-standard) both;\n  }\n\n  /* Presence: colour and shape together, so it reads in greyscale (DESIGN-LANGUAGE: colour is never the only signal).\n     online a full disc · away a crescent · busy a disc with a bar through it · offline a hollow ring. */\n  .presence {\n    --_d: 10px;\n    display: inline-block;\n    flex: none;\n    width: var(--_d);\n    height: var(--_d);\n    border-radius: 50%;\n    background: var(--_c);\n  }\n  .presence[data-size=\"xs\"] {\n    --_d: 6px;\n  }\n  .presence[data-size=\"sm\"] {\n    --_d: 8px;\n  }\n  .presence[data-size=\"lg\"] {\n    --_d: 12px;\n  }\n  .presence[data-size=\"xl\"] {\n    --_d: 14px;\n  }\n  .presence[data-status=\"online\"] {\n    --_c: var(--pui-green);\n  }\n  .presence[data-status=\"away\"] {\n    --_c: var(--pui-orange);\n    mask: radial-gradient(circle at 18% 18%, transparent calc(var(--_d) * 0.36), black calc(var(--_d) * 0.36 + 0.5px));\n  }\n  .presence[data-status=\"away\"]:dir(rtl) {\n    mask: radial-gradient(circle at 82% 18%, transparent calc(var(--_d) * 0.36), black calc(var(--_d) * 0.36 + 0.5px));\n  }\n  .presence[data-status=\"busy\"] {\n    --_c: var(--pui-red);\n    mask:\n      linear-gradient(black 0 0) center / 56% max(1.5px, 22%) no-repeat,\n      linear-gradient(black 0 0);\n    mask-composite: exclude;\n  }\n  .presence[data-status=\"offline\"] {\n    --_c: var(--pui-ink-3);\n    mask: radial-gradient(circle, transparent calc(var(--_d) * 0.24), black calc(var(--_d) * 0.24 + 0.5px));\n  }\n}\n@keyframes presence-settle {\n  from {\n    opacity: 0.4;\n    scale: calc(1 - 0.45 * var(--pui-motion-travel, 1));\n  }\n}\n"},{"path":"beamline/components/avatar/avatar.tsx","type":"registry:file","target":"src/ui/beamline/components/avatar/avatar.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { AvatarDefaults } from \"./defaults\";\nexport type { AvatarDefaults } from \"./defaults\";\n\nimport { useCallback, useState, type CSSProperties, type HTMLAttributes, type Ref } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./avatar.module.css\";\n\nexport type AvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\nexport type PresenceStatus = \"online\" | \"away\" | \"busy\" | \"offline\";\n\nconst SIZE: Record<AvatarSize, string | undefined> = {\n  xs: styles.xs,\n  sm: styles.sm,\n  md: undefined,\n  lg: styles.lg,\n  xl: styles.xl,\n};\n\n/** A person's (or account's) series hue, the same everywhere their name appears. */\nexport function hueOf(name: string) {\n  let h = 0;\n  for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) | 0;\n  return `var(--pui-series-${(Math.abs(h) % 8) + 1})`;\n}\nexport const initialsOf = (name: string) =>\n  name\n    .trim()\n    .split(/\\s+/)\n    .filter((w) => /\\p{L}|\\d/u.test(w[0] ?? \"\"))\n    .slice(0, 2)\n    .map((w) => w[0].toUpperCase())\n    .join(\"\") || \"?\";\n\n/** A presence mark for avatars and lists. Colour and shape together: online a disc, away a crescent, busy a disc with\n *  a bar, offline a hollow ring, so the status survives greyscale and colour-blindness. */\nexport function PresenceDot({\n  status,\n  size = \"md\",\n  className,\n  style,\n  ref,\n  ...props\n}: {\n  status: PresenceStatus;\n  size?: AvatarSize;\n  className?: string;\n  style?: CSSProperties;\n  ref?: Ref<HTMLSpanElement>;\n} & Omit<HTMLAttributes<HTMLSpanElement>, \"children\">) {\n  const i18n = useMessages(\"avatar\", englishMessages);\n  return (\n    <span\n      {...props}\n      style={style}\n      ref={ref}\n      role=\"img\"\n      aria-label={props[\"aria-label\"] ?? i18n(status)}\n      data-slot=\"presence-dot\"\n      data-status={status}\n      data-size={size}\n      className={cn(styles.presence, className)}\n    />\n  );\n}\n\nexport interface AvatarClassNames {\n  root?: string;\n  face?: string;\n  photo?: string;\n  presence?: string;\n}\n\nexport interface AvatarProps extends HTMLAttributes<HTMLSpanElement> {\n  name: string;\n  /** A photo; initials show while it loads and if it fails. */\n  src?: string;\n  size?: AvatarSize;\n  /** circle for people, square for companies and workspaces. */\n  shape?: \"circle\" | \"square\";\n  status?: PresenceStatus;\n  ref?: Ref<HTMLSpanElement>;\n  classNames?: AvatarClassNames;\n}\n\n/** A person or account in a small space: a photo, or initials on the colour their name always gets. A photo still on\n *  its way fades in over the initials instead of popping; a presence that changes settles into its new mark. */\nexport function Avatar({\n  name,\n  src,\n  size: sizeProp,\n  shape = \"circle\",\n  status,\n  className,\n  classNames,\n  style,\n  ref,\n  ...props\n}: AvatarProps) {\n  const i18n = useMessages(\"avatar\", englishMessages);\n  const scopeDefaults = useComponentDefaults<AvatarDefaults>(\"avatar\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n\n  const [failed, setFailed] = useState<string | null>(null);\n  const photo = src && failed !== src;\n  // a photo that is not decoded yet when it mounts waits unseen and fades in over the initials when it lands; one that\n  // is already there (cached, or loaded before the page hydrated) shows at once, so a server render never hides it\n  const [pending, setPending] = useState<string | null>(null);\n  const watch = useCallback((img: HTMLImageElement | null) => {\n    if (!img) return;\n    if (!img.complete) setPending(img.getAttribute(\"src\"));\n    // A cached/decode failure can finish before React attaches onError during hydration.\n    else if (img.naturalWidth === 0) setFailed(img.getAttribute(\"src\"));\n  }, []);\n  // a presence that changes (online → away) settles into its new mark; the first render does not play it\n  const [seen, setSeen] = useState(status);\n  const [changes, setChanges] = useState(0);\n  if (seen !== status) {\n    setSeen(status);\n    setChanges((n) => n + 1);\n  }\n  return (\n    <span\n      ref={ref}\n      data-slot=\"avatar\"\n      data-shape={shape}\n      data-status={status}\n      role=\"img\"\n      aria-label={status ? i18n(\"presence\", { name, status: i18n(status) }) : name}\n      {...props}\n      className={cn(styles.root, SIZE[size], classNames?.root, className)}\n      style={{ \"--pui-avatar-hue\": hueOf(name), ...style } as CSSProperties}\n    >\n      <span aria-hidden data-slot=\"avatar-face\" className={cn(styles.face, classNames?.face)}>\n        {initialsOf(name)}\n        {photo && (\n          <img\n            key={src}\n            ref={watch}\n            data-slot=\"avatar-photo\"\n            data-pending={pending === src || undefined}\n            src={src}\n            alt=\"\"\n            draggable={false}\n            onLoad={() => setPending(null)}\n            onError={() => setFailed(src)}\n            className={cn(styles.photo, classNames?.photo)}\n          />\n        )}\n      </span>\n      {status && (\n        <PresenceDot\n          key={changes}\n          status={status}\n          size={size}\n          data-changed={changes > 0 || undefined}\n          className={cn(styles.mark, classNames?.presence)}\n        />\n      )}\n    </span>\n  );\n}\n\nexport default Avatar;\n"},{"path":"beamline/components/avatar/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/avatar/defaults.ts","content":"/** Pure allowed presentation defaults for Avatar; no component or runtime imports. */\nexport interface AvatarDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"xs\" | \"lg\" | \"xl\";\n}\n"},{"path":"beamline/components/avatar/messages.ts","type":"registry:file","target":"src/ui/beamline/components/avatar/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  online: \"Online\",\n  away: \"Away\",\n  busy: \"Do not disturb\",\n  offline: \"Offline\",\n  presence: \"{name}, {status}\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/live-cursors/live-cursors.module.css","type":"registry:file","target":"src/ui/beamline/components/live-cursors/live-cursors.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  .root {\n    position: relative;\n    isolation: isolate;\n  }\n  .root:focus-visible {\n    outline-offset: calc(-1 * var(--pui-focus-width));\n  }\n  .layer {\n    position: absolute;\n    z-index: 2;\n    inset: 0;\n    overflow: hidden;\n    pointer-events: none;\n  }\n\n  /* the text on a person's colour: dark on a light hue, light on a dark one */\n  .cursor,\n  .selection {\n    --_on: oklch(from var(--_c) clamp(0.16, (0.66 - l) * 1000, 0.99) 0.01 h);\n  }\n\n  /* the cursor is a point; its arrow and label hang from it, the label turning inward near an edge */\n  .cursor {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 0;\n    height: 0;\n    animation: join var(--pui-duration-standard) var(--pui-ease-overshoot) both;\n    transform-origin: 0 0;\n    transition: opacity var(--pui-duration-standard) var(--pui-ease-default);\n    will-change: transform;\n  }\n  .cursor[data-idle] {\n    opacity: 0.45;\n  }\n  .cursor[data-leaving] {\n    opacity: 0;\n  }\n  .arrow {\n    position: absolute;\n    top: -0.0625rem;\n    left: -0.0625rem;\n    width: 1rem;\n    height: 1.25rem;\n    fill: var(--_c);\n    stroke: var(--pui-surface-base);\n    stroke-linejoin: round;\n    stroke-width: 1.25;\n    filter: drop-shadow(0 1px 1.5px oklch(from var(--pui-scrim) l c h / 0.35));\n  }\n  .flag,\n  .message {\n    position: absolute;\n    top: 1.0625rem;\n    left: 0.625rem;\n    /* the cursor is zero wide: size the label by its words, not by the point it hangs from */\n    width: max-content;\n    border-radius: var(--pui-radius-chip, 0.375rem);\n    background: var(--_c);\n    box-shadow: 0 1px 2px oklch(from var(--pui-scrim) l c h / 0.25);\n    color: var(--_on);\n    font-size: var(--pui-text-xs);\n    font-weight: var(--pui-weight-medium);\n    line-height: 1.25rem;\n    white-space: nowrap;\n  }\n  .flag {\n    display: inline-flex;\n    gap: var(--pui-space-1);\n    padding: 0 var(--pui-space-1-5);\n    transform-origin: 0 0;\n    transition:\n      opacity var(--pui-duration-standard) var(--pui-ease-default),\n      scale var(--pui-duration-standard) var(--pui-ease-spring);\n  }\n  .note {\n    opacity: 0.75;\n    font-weight: var(--pui-weight-regular, 400);\n  }\n  .root[data-flags=\"moving\"] .cursor:not([data-moving]) .flag {\n    opacity: 0;\n    scale: 0.6;\n  }\n  .message {\n    display: grid;\n    max-width: 16rem;\n    padding: var(--pui-space-1) var(--pui-space-2);\n    border-radius: var(--pui-radius-control);\n    border-start-start-radius: 0.125rem;\n    font-size: var(--pui-text-sm);\n    font-weight: var(--pui-weight-regular, 400);\n    line-height: 1.35;\n    white-space: normal;\n    animation: speak var(--pui-duration-standard) var(--pui-ease-overshoot) both;\n    transform-origin: 0 0;\n  }\n  .cursor[data-edge-x=\"end\"] :is(.flag, .message) {\n    right: 0.25rem;\n    left: auto;\n    transform-origin: 100% 0;\n  }\n  .cursor[data-edge-y=\"end\"] :is(.flag, .message) {\n    top: auto;\n    bottom: 0.375rem;\n  }\n  .cursor[data-edge-y=\"end\"]:not([data-edge-x=\"end\"]) :is(.flag, .message) {\n    left: 1rem;\n  }\n  .messageName {\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-medium);\n    opacity: 0.8;\n  }\n\n  .selection {\n    position: absolute;\n    border: 1.5px solid var(--_c);\n    border-radius: var(--pui-radius-control);\n    background: oklch(from var(--_c) l c h / 0.1);\n    transition:\n      left var(--pui-duration-standard) var(--pui-ease-default),\n      top var(--pui-duration-standard) var(--pui-ease-default),\n      width var(--pui-duration-standard) var(--pui-ease-default),\n      height var(--pui-duration-standard) var(--pui-ease-default),\n      opacity var(--pui-duration-standard) var(--pui-ease-default);\n    animation: arrive var(--pui-duration-standard) var(--pui-ease-enter) both;\n  }\n  .selection[data-leaving] {\n    opacity: 0;\n  }\n  .selectionName {\n    position: absolute;\n    bottom: 100%;\n    inset-inline-start: -1.5px;\n    padding: 0 var(--pui-space-1-5);\n    border-radius: 0.25rem 0.25rem 0 0;\n    background: var(--_c);\n    color: var(--_on);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-medium);\n    line-height: 1rem;\n    white-space: nowrap;\n  }\n\n  .compose {\n    position: absolute;\n    z-index: 3;\n    margin: 0.875rem 0 0 0.75rem;\n    animation: speak var(--pui-duration-standard) var(--pui-ease-overshoot) both;\n    transform-origin: 0 0;\n  }\n  .composeInput {\n    width: 14rem;\n    height: 2rem;\n    padding: 0 var(--pui-space-2);\n    border: 1px solid var(--pui-accent);\n    border-radius: var(--pui-radius-control);\n    border-start-start-radius: 0.125rem;\n    background: var(--pui-surface-raised);\n    box-shadow: 0 2px 8px oklch(from var(--pui-scrim) l c h / 0.25);\n    color: var(--pui-ink);\n    font-size: var(--pui-text-sm);\n    outline: none;\n  }\n\n  @keyframes join {\n    from {\n      opacity: 0;\n      scale: 0.4;\n    }\n  }\n  @keyframes speak {\n    from {\n      opacity: 0;\n      scale: 0.85;\n    }\n  }\n  @keyframes arrive {\n    from {\n      opacity: 0;\n    }\n  }\n}\n"},{"path":"beamline/components/live-cursors/live-cursors.tsx","type":"registry:file","target":"src/ui/beamline/components/live-cursors/live-cursors.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { hueOf } from \"../avatar/avatar\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./live-cursors.module.css\";\n\nexport interface LivePerson {\n  id: string;\n  name: string;\n  /** Fractions (0–1) of the surface; null when their pointer has left it. */\n  x: number | null;\n  y: number | null;\n  /** Any CSS colour; default the person's series hue, the same as their Avatar. */\n  color?: string;\n  /** A short word after the name: \"Viewing\", \"Editing\". */\n  note?: string;\n  /** What they have selected, as fractions of the surface. */\n  selection?: { x: number; y: number; width: number; height: number } | null;\n  /** What they are saying beside their pointer. */\n  message?: string | null;\n  idle?: boolean;\n}\n\nexport type LiveCursorsClassNames = Partial<\n  Record<\"root\" | \"cursor\" | \"flag\" | \"selection\" | \"message\" | \"compose\", string>\n>;\n\nexport interface LiveCursorsProps {\n  people: LivePerson[];\n  /** The shared surface. */\n  children?: ReactNode;\n  /** Your pointer as fractions of the surface (null when it leaves), at most once a frame. */\n  onCursorMove?: (at: { x: number; y: number } | null) => void;\n  /** With it, / over the surface opens your cursor message. */\n  onMessage?: (text: string) => void;\n  /** When names show. Default \"always\". */\n  flags?: \"always\" | \"moving\";\n  /** What the surface is, for assistive tech. Default \"Shared canvas\". */\n  label?: string;\n  className?: string;\n  classNames?: LiveCursorsClassNames;\n  style?: CSSProperties;\n  ref?: Ref<HTMLDivElement>;\n}\n\ntype Track = { x: number; y: number; vx: number; vy: number; tx: number; ty: number; moved: number };\nconst OMEGA = 22; // spring stiffness (rad/s): critically damped, settles in about a quarter second\nconst REST = 1.2; // seconds a \"moving\" flag stays after the cursor rests\n\n/**\n * Who else is working here and where: each person's pointer in their colour with a name flag, gliding between the\n * positions your realtime service sends, their selection outlined, a message beside the pointer. The frame loop runs\n * only while a cursor is travelling; a still room costs nothing.\n */\nexport function LiveCursors({\n  people,\n  children,\n  onCursorMove,\n  onMessage,\n  flags = \"always\",\n  label: labelProp,\n  className,\n  classNames,\n  style,\n  ref,\n}: LiveCursorsProps) {\n  const i18n = useMessages(\"live-cursors\", englishMessages);\n  const label = labelProp === undefined ? i18n(\"label\") : labelProp;\n\n  const root = useRef<HTMLDivElement>(null);\n  const attach = useComposedRef(root, ref);\n  const reduced = useReducedMotionSafe();\n  const [size, setSize] = useState<{ w: number; h: number } | null>(null);\n  useLayoutEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const read = () =>\n      setSize((s) =>\n        s && s.w === el.clientWidth && s.h === el.clientHeight ? s : { w: el.clientWidth, h: el.clientHeight },\n      );\n    read();\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // people who left stay a moment to fade where they were\n  const [leaving, setLeaving] = useState<LivePerson[]>([]);\n  const last = useRef<LivePerson[]>(people);\n  const [said, setSaid] = useState(\"\");\n  useEffect(() => {\n    const before = last.current;\n    last.current = people;\n    const gone = before.filter((p) => !people.some((q) => q.id === p.id));\n    const came = people.filter((p) => !before.some((q) => q.id === p.id));\n    const spoke = people.filter((p) => p.message && before.find((q) => q.id === p.id)?.message !== p.message);\n    const words = [\n      ...came.map((p) => i18n(\"joined\", { name: p.name })),\n      ...gone.map((p) => i18n(\"left\", { name: p.name })),\n      ...spoke.map((p) => `${p.name}: ${p.message}`),\n    ];\n    if (words.length) setSaid(words.join(\". \"));\n    if (!gone.length) return;\n    setLeaving((l) => [...l.filter((p) => !people.some((q) => q.id === p.id)), ...gone]);\n    const t = setTimeout(() => setLeaving((l) => l.filter((p) => !gone.some((g) => g.id === p.id))), 400);\n    return () => clearTimeout(t);\n  }, [people, i18n]);\n\n  // springs: one per cursor, written straight to the element; the loop stops when every cursor has arrived\n  const tracks = useRef(new Map<string, Track>());\n  const els = useRef(new Map<string, HTMLElement>());\n  const frame = useRef(0);\n  const place = (id: string, t: Track) => {\n    const el = els.current.get(id);\n    if (!el) return;\n    el.style.transform = `translate(${t.x}px, ${t.y}px)`;\n    const moving = flags === \"moving\" && performance.now() / 1000 - t.moved < REST;\n    if (moving !== el.hasAttribute(\"data-moving\")) el.toggleAttribute(\"data-moving\", moving);\n  };\n  const tick = (prev: number) => (now: number) => {\n    const dt = Math.min(1 / 30, (now - prev) / 1000);\n    let busy = false;\n    for (const [id, t] of tracks.current) {\n      const ax = OMEGA * OMEGA * (t.tx - t.x) - 2 * OMEGA * t.vx;\n      const ay = OMEGA * OMEGA * (t.ty - t.y) - 2 * OMEGA * t.vy;\n      t.vx += ax * dt;\n      t.vy += ay * dt;\n      t.x += t.vx * dt;\n      t.y += t.vy * dt;\n      if (Math.abs(t.tx - t.x) < 0.25 && Math.abs(t.ty - t.y) < 0.25 && Math.abs(t.vx) + Math.abs(t.vy) < 4) {\n        t.x = t.tx;\n        t.y = t.ty;\n        t.vx = t.vy = 0;\n        if (flags === \"moving\" && now / 1000 - t.moved < REST) busy = true;\n      } else busy = true;\n      place(id, t);\n    }\n    frame.current = busy ? requestAnimationFrame(tick(now)) : 0;\n  };\n  useLayoutEffect(() => {\n    if (!size) return;\n    const seen = new Set<string>();\n    let moved = false;\n    for (const p of people) {\n      seen.add(p.id);\n      if (p.x == null || p.y == null) continue; // off the surface: the cursor fades where it was\n      const tx = p.x * size.w,\n        ty = p.y * size.h;\n      const t = tracks.current.get(p.id);\n      if (!t || reduced) {\n        const fresh = {\n          x: tx,\n          y: ty,\n          vx: 0,\n          vy: 0,\n          tx,\n          ty,\n          moved: t && (t.tx !== tx || t.ty !== ty) ? performance.now() / 1000 : (t?.moved ?? 0),\n        };\n        tracks.current.set(p.id, fresh);\n        place(p.id, fresh);\n      } else if (t.tx !== tx || t.ty !== ty) {\n        t.tx = tx;\n        t.ty = ty;\n        t.moved = performance.now() / 1000;\n        moved = true;\n      } else place(p.id, t);\n    }\n    for (const id of tracks.current.keys())\n      if (!seen.has(id) && !leaving.some((p) => p.id === id)) tracks.current.delete(id);\n    if (moved && !frame.current) {\n      const now = performance.now();\n      frame.current = requestAnimationFrame(tick(now));\n    }\n  });\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  // your pointer: reported as fractions, once a frame\n  const mine = useRef<{ x: number; y: number } | null>(null);\n  const report = useRef(0);\n  const hovering = useRef(false);\n  const onMove = (e: PointerEvent<HTMLDivElement>) => {\n    const r = e.currentTarget.getBoundingClientRect();\n    mine.current = { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };\n    hovering.current = true;\n    if (composing) setComposeAt(mine.current);\n    if (!onCursorMove || report.current) return;\n    report.current = requestAnimationFrame(() => {\n      report.current = 0;\n      onCursorMove(mine.current);\n    });\n  };\n  const onLeave = () => {\n    hovering.current = false;\n    cancelAnimationFrame(report.current);\n    report.current = 0;\n    if (!composing) onCursorMove?.(null);\n  };\n\n  // cursor chat: / over the surface opens your message where your pointer is\n  const [composing, setComposing] = useState(false);\n  const [draft, setDraft] = useState(\"\");\n  const [composeAt, setComposeAt] = useState<{ x: number; y: number } | null>(null);\n  const input = useRef<HTMLInputElement>(null);\n  const startCompose = () => {\n    setComposeAt(mine.current ?? { x: 0.5, y: 0.5 });\n    setDraft(\"\");\n    setComposing(true);\n    requestAnimationFrame(() => input.current?.focus());\n  };\n  useEffect(() => {\n    if (!onMessage) return;\n    const onKey = (e: globalThis.KeyboardEvent) => {\n      if (e.key !== \"/\" || composing || !hovering.current) return;\n      const t = e.target as HTMLElement | null;\n      if (t?.closest(\"input, textarea, select, [contenteditable=''], [contenteditable='true']\")) return;\n      e.preventDefault();\n      startCompose();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  });\n  const onComposeKey = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Enter\") {\n      e.preventDefault();\n      if (draft.trim()) onMessage?.(draft.trim());\n      setComposing(false);\n      root.current?.focus();\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      e.stopPropagation();\n      setComposing(false);\n      root.current?.focus();\n    }\n  };\n\n  const all = [...people, ...leaving.filter((p) => !people.some((q) => q.id === p.id))];\n  return (\n    <div\n      ref={attach}\n      role=\"group\"\n      aria-label={onMessage ? i18n(\"pressToSaySomethingWhereYourPointerIs\", { label: label }) : label}\n      tabIndex={onMessage ? 0 : undefined}\n      data-slot=\"live-cursors\"\n      data-flags={flags}\n      style={style}\n      className={cn(styles.root, classNames?.root, className)}\n      onPointerMove={onMove}\n      onPointerLeave={onLeave}\n      onKeyDown={(e) => {\n        if (onMessage && e.key === \"/\" && !composing && e.target === e.currentTarget) {\n          e.preventDefault();\n          startCompose();\n        }\n      }}\n    >\n      {children}\n      <div aria-hidden className={styles.layer}>\n        {all.map((p) => {\n          const s = p.selection;\n          const gone = leaving.some((q) => q.id === p.id) && !people.some((q) => q.id === p.id);\n          return s ? (\n            <div\n              key={`sel-${p.id}`}\n              data-slot=\"live-cursors-selection\"\n              data-leaving={gone ? \"\" : undefined}\n              className={cn(styles.selection, classNames?.selection)}\n              style={\n                {\n                  \"--_c\": p.color ?? hueOf(p.name),\n                  left: `${s.x * 100}%`,\n                  top: `${s.y * 100}%`,\n                  width: `${s.width * 100}%`,\n                  height: `${s.height * 100}%`,\n                } as CSSProperties\n              }\n            >\n              <span className={styles.selectionName}>{p.name}</span>\n            </div>\n          ) : null;\n        })}\n        {all.map((p) => {\n          const gone =\n            (leaving.some((q) => q.id === p.id) && !people.some((q) => q.id === p.id)) || p.x == null || p.y == null;\n          if (!tracks.current.has(p.id) && (p.x == null || p.y == null)) return null;\n          return (\n            <div\n              key={p.id}\n              ref={(el) => {\n                if (el) {\n                  els.current.set(p.id, el);\n                  const t = tracks.current.get(p.id);\n                  if (t) el.style.transform = `translate(${t.x}px, ${t.y}px)`;\n                } else els.current.delete(p.id);\n              }}\n              data-slot=\"live-cursors-cursor\"\n              data-idle={p.idle ? \"\" : undefined}\n              data-leaving={gone ? \"\" : undefined}\n              data-message={p.message ? \"\" : undefined}\n              data-edge-x={p.x != null && p.x > 0.75 ? \"end\" : undefined}\n              data-edge-y={p.y != null && p.y > 0.85 ? \"end\" : undefined}\n              className={cn(styles.cursor, classNames?.cursor)}\n              style={{ \"--_c\": p.color ?? hueOf(p.name) } as CSSProperties}\n            >\n              <svg viewBox=\"0 0 16 20\" className={styles.arrow}>\n                <path d=\"M1.5 1.5 L1.5 16.5 L5.6 12.6 L8.4 18.6 L11 17.4 L8.3 11.6 L14 11.4 Z\" />\n              </svg>\n              {p.message ? (\n                <span data-slot=\"live-cursors-message\" className={cn(styles.message, classNames?.message)}>\n                  <span className={styles.messageName}>{p.name}</span>\n                  {p.message}\n                </span>\n              ) : (\n                <span data-slot=\"live-cursors-flag\" className={cn(styles.flag, classNames?.flag)}>\n                  {p.name}\n                  {p.note && <span className={styles.note}>{p.note}</span>}\n                  {p.idle && <span className={styles.note}>{i18n(\"idle\")}</span>}\n                </span>\n              )}\n            </div>\n          );\n        })}\n      </div>\n      {composing && composeAt && (\n        <div\n          data-slot=\"live-cursors-compose\"\n          className={cn(styles.compose, classNames?.compose)}\n          style={{ left: `${composeAt.x * 100}%`, top: `${composeAt.y * 100}%` }}\n        >\n          <input\n            ref={input}\n            value={draft}\n            maxLength={120}\n            aria-label={i18n(\"saySomethingHere\")}\n            placeholder={i18n(\"saySomething\")}\n            className={styles.composeInput}\n            onChange={(e) => setDraft(e.target.value)}\n            onKeyDown={onComposeKey}\n            onBlur={() => setComposing(false)}\n          />\n        </div>\n      )}\n      <span role=\"status\" className=\"sr-only\">\n        {said}\n      </span>\n    </div>\n  );\n}\n\nexport default LiveCursors;\n"},{"path":"beamline/components/live-cursors/messages.ts","type":"registry:file","target":"src/ui/beamline/components/live-cursors/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: \"Shared canvas\",\n  pressToSaySomethingWhereYourPointerIs: \"{label}. Press / to say something where your pointer is.\",\n  idle: \"Idle\",\n  saySomethingHere: \"Say something here\",\n  saySomething: \"Say something\",\n  joined: \"{name} joined\",\n  left: \"{name} left\",\n} satisfies MessageDefinitions;\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-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-reduced-motion-safe.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-reduced-motion-safe.ts","content":"\"use client\";\n\nimport { useMediaQuery } from \"./use-media-query\";\nimport { useScopeMotion } from \"../runtime/appearance\";\n\n/** Managed motion policy plus the OS preference, with a stable server snapshot. */\nexport function useReducedMotionSafe() {\n  const requested = useScopeMotion();\n  const os = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  return requested !== \"auto\" || os;\n}\n"},{"path":"beamline/lib/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":"Live cursors is in src/ui/beamline/components/live-cursors/live-cursors.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/live-cursors"}}