{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-compare","type":"registry:component","title":"Image compare","description":"Two images in one frame with a divider: drag anywhere, or arrow keys on the handle; horizontal or vertical, with captions.","categories":["display"],"dependencies":["@radix-ui/react-direction@1.1.4","clsx@^2.1.1","lucide-react@^1.48.0","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/image-compare/image-compare.module.css","type":"registry:file","target":"src/ui/beamline/components/image-compare/image-compare.module.css","content":"@layer properties, theme, base, components, utilities;\n@property --pui-compare-pos {\n  syntax: \"<percentage>\";\n  inherits: true;\n  initial-value: 50%;\n}\n@layer components {\n  /* Image compare: a media frame (it takes only the look's surface edge, never its fill), two layers, a divider line\n     and a round knob that wears the knob role. Captions and the line are inverse marks that read the same over any\n     picture, in either theme (the tooltip pair). */\n  .root {\n    position: relative;\n    /* The translated carrier extends past the frame. Focusing its handle must not scroll that overflow into view. */\n    overflow: clip;\n    border-radius: var(--pui-radius-card);\n    background: var(--pui-inset);\n    box-shadow: var(--m-surface-edge);\n    cursor: ew-resize;\n    /* the divider is dragged along one axis; the page keeps scrolling along the other */\n    touch-action: pan-y;\n    user-select: none;\n    container-type: inline-size;\n    /* a click or a key glides the divider and the clip together; a drag follows the finger with no easing */\n    transition: --pui-compare-pos var(--pui-motion-out) var(--pui-ease-standard);\n  }\n  .root[data-dragging] {\n    transition-duration: 0ms;\n  }\n  .root[data-orientation=\"vertical\"] {\n    cursor: ns-resize;\n    touch-action: pan-x;\n  }\n  /* the edge's inner light has to sit over the pictures, its outer shadow stays on the root */\n  .root::after {\n    content: \"\";\n    position: absolute;\n    inset: 0;\n    z-index: 3;\n    border-radius: inherit;\n    box-shadow: var(--m-surface-edge);\n    pointer-events: none;\n  }\n  .layer {\n    position: absolute;\n    inset: 0;\n  }\n  .layer > * {\n    width: 100%;\n    height: 100%;\n  }\n  .before {\n    clip-path: inset(0 calc(100% - var(--pui-compare-pos)) 0 0);\n  }\n  .before:dir(rtl) {\n    clip-path: inset(0 0 0 calc(100% - var(--pui-compare-pos)));\n  }\n  .root[data-orientation=\"vertical\"] .before {\n    clip-path: inset(0 0 calc(100% - var(--pui-compare-pos)) 0);\n  }\n  /* a caption steps aside (fades) when the divider leaves its side too little room to hold it, and comes back */\n  .caption {\n    position: absolute;\n    z-index: 2;\n    top: var(--pui-space-3);\n    padding: var(--pui-space-0-5) var(--pui-space-2);\n    border-radius: var(--pui-radius-chip);\n    background: color-mix(in oklab, var(--pui-tooltip-bg) 72%, transparent);\n    color: var(--pui-tooltip-fg);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-medium);\n    line-height: 1.5;\n    backdrop-filter: blur(6px);\n    pointer-events: none;\n    transition: opacity var(--pui-motion-in) var(--pui-ease-default);\n  }\n  .caption[data-hidden] {\n    opacity: 0;\n    transition-duration: var(--pui-motion-out);\n  }\n  .caption[data-side=\"before\"] {\n    inset-inline-start: var(--pui-space-3);\n  }\n  .caption[data-side=\"after\"] {\n    inset-inline-end: var(--pui-space-3);\n  }\n  .root[data-orientation=\"vertical\"] .caption[data-side=\"after\"] {\n    top: auto;\n    bottom: var(--pui-space-3);\n    inset-inline: var(--pui-space-3) auto;\n  }\n  /* Percentage transforms need the frame's dimensions, not the line or knob's. Keep both visual parts intact inside\n     one pointer-transparent carrier; the registered position drives this travel and the image clip together. */\n  .travel {\n    position: absolute;\n    inset: 0;\n    z-index: 2;\n    translate: var(--pui-compare-pos) 0;\n    pointer-events: none;\n  }\n  .travel:dir(rtl) {\n    translate: calc(-1 * var(--pui-compare-pos)) 0;\n  }\n  .root[data-orientation=\"vertical\"] .travel {\n    translate: 0 var(--pui-compare-pos);\n  }\n  .line {\n    position: absolute;\n    z-index: 2;\n    inset-block: 0;\n    inset-inline-start: 0;\n    width: 2px;\n    translate: -50% 0;\n    background: var(--pui-control-thumb);\n    box-shadow: 0 0 0 1px color-mix(in oklab, var(--pui-tooltip-bg) 30%, transparent);\n    pointer-events: none;\n  }\n  .line:dir(rtl) {\n    translate: 50% 0;\n  }\n  .root[data-orientation=\"vertical\"] .line {\n    inset-block: 0 auto;\n    inset-inline: 0;\n    width: auto;\n    height: 2px;\n    translate: 0 -50%;\n  }\n  .knob {\n    position: absolute;\n    z-index: 2;\n    top: 50%;\n    inset-inline-start: 0;\n    display: grid;\n    width: 36px;\n    height: 36px;\n    place-items: center;\n    border-radius: 50%;\n    color: var(--pui-tooltip-bg);\n    translate: -50% -50%;\n    outline-offset: 2px;\n    pointer-events: auto;\n    transition:\n      scale var(--pui-motion-out) var(--pui-ease-default),\n      var(--m-transition);\n  }\n  .knob:dir(rtl) {\n    translate: 50% -50%;\n  }\n  .root[data-orientation=\"vertical\"] .knob {\n    top: 0;\n    inset-inline-start: 50%;\n  }\n  .root[data-orientation=\"vertical\"] .knob:dir(rtl) {\n    translate: 50% -50%;\n  }\n  /* pointed at, the knob comes up a touch at once; held, it answers the finger at once and stays larger while the\n     divider follows it */\n  .root:hover .knob {\n    scale: 1.04;\n    transition-duration: 0ms;\n  }\n  .root[data-dragging] .knob,\n  .root:active .knob {\n    scale: 1.08;\n    transition-duration: 0ms;\n  }\n  /* a small frame keeps the knob in proportion */\n  @container (max-width: 20rem) {\n    .knob {\n      width: 30px;\n      height: 30px;\n    }\n    .knob > svg {\n      width: 14px;\n      height: 14px;\n    }\n  }\n  .knob > svg {\n    width: 16px;\n    height: 16px;\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.knob {\n  composes: m-knob from global;\n}\n"},{"path":"beamline/components/image-compare/image-compare.tsx","type":"registry:file","target":"src/ui/beamline/components/image-compare/image-compare.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { useDrag } from \"../../hooks/use-drag\";\nimport { english as englishMessages } from \"./messages\";\nimport type { Ref } from \"react\";\n\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { ChevronsLeftRight, ChevronsUpDown } from \"lucide-react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { resolveCssEasing, resolveCssTime } from \"../../lib/css-values\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport styles from \"./image-compare.module.css\";\n\nexport type ImageCompareClassNames = Partial<\n  Record<\"root\" | \"after\" | \"before\" | \"caption\" | \"divider\" | \"handle\", string>\n>;\n\nexport interface ImageCompareProps {\n  classNames?: ImageCompareClassNames;\n  before: ReactNode;\n  after: ReactNode;\n  /** Where the divider sits, 0–100 (percent from the start or top). */\n  position?: number;\n  defaultPosition?: number;\n  onPositionChange?: (position: number) => void;\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Corner captions, or false for none. Default [\"Before\", \"After\"]. */\n  labels?: false | { before?: string; after?: string } | /** @deprecated Use { before, after }. */ [string, string];\n  /** The comparison's name: \"Colour grade\". */\n  label: string;\n  /** CSS aspect-ratio of the frame. Default \"16/9\". */\n  aspectRatio?: string;\n  /** The first time the frame comes into view, the divider nudges once to show that it moves (not after a touch, not\n   *  under reduced motion). Default true. */\n  hint?: boolean;\n  style?: CSSProperties;\n  ref?: Ref<HTMLDivElement>;\n  className?: string;\n}\n\n/**\n * Two images in one frame with a divider between them: drag anywhere and it follows the finger; click or use the keys\n * and it glides there (arrows, Shift for 10%, Home and End for the ends; double-click to go back to where it\n * started). A caption steps aside when its side has too little room to hold it. The first time it comes into view the\n * divider nudges once, so a person sees it can be moved.\n */\nexport function ImageCompare({\n  before,\n  after,\n  position: positionProp,\n  defaultPosition = 50,\n  onPositionChange,\n  orientation = \"horizontal\",\n  labels: labelsProp,\n  label,\n  aspectRatio = \"16/9\",\n  hint = true,\n  className,\n  classNames,\n  style,\n  ref,\n}: ImageCompareProps) {\n  const i18n = useMessages(\"image-compare\", englishMessages);\n  const labels: false | [string, string] =\n    labelsProp === false\n      ? false\n      : Array.isArray(labelsProp)\n        ? labelsProp\n        : [labelsProp?.before ?? i18n(\"before\"), labelsProp?.after ?? i18n(\"after\")];\n\n  const [pos, setPos] = useControllable(positionProp, defaultPosition, onPositionChange);\n  const root = useRef<HTMLDivElement>(null);\n  const rootRef = useComposedRef(root, ref);\n  // the frame's size and each caption's, read when either changes: a caption shows only while its side can hold it\n  const [room, setRoom] = useState({ w: 0, h: 0, before: 0, after: 0 });\n  // A controlled wipe can provide a fresh labels tuple on every frame. Its words, not the tuple's identity, decide\n  // when captions need measuring; otherwise each position commit disconnects the observer and forces layout again.\n  const beforeLabel = labels ? labels[0] : null;\n  const afterLabel = labels ? labels[1] : null;\n  useLayoutEffect(() => {\n    const el = root.current;\n    if (!el || beforeLabel === null || typeof ResizeObserver === \"undefined\") return;\n    const read = () => {\n      const caps = el.querySelectorAll<HTMLElement>(\"[data-slot=image-compare-caption]\");\n      const span = (c?: HTMLElement) => (c ? (orientation === \"horizontal\" ? c.offsetWidth : c.offsetHeight) : 0);\n      const next = { w: el.clientWidth, h: el.clientHeight, before: span(caps[0]), after: span(caps[1]) };\n      setRoom((r) =>\n        r.w === next.w && r.h === next.h && r.before === next.before && r.after === next.after ? r : next,\n      );\n    };\n    read();\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [beforeLabel, afterLabel, orientation]);\n  // first view: one nudge of the divider says it moves (away and past its place, then home, about a second on the\n  // motion tokens, read from the frame when it fires); never after the person has touched it, never under reduced or\n  // no motion; a press or a key ends it\n  const touched = useRef(false);\n  const nudge = useRef<Animation | null>(null);\n  const latest = useRef(pos);\n  latest.current = pos;\n  useEffect(() => {\n    const el = root.current;\n    if (!hint || !el?.animate || typeof IntersectionObserver === \"undefined\") return;\n    const io = new IntersectionObserver(\n      ([e]) => {\n        if (!e.isIntersecting) return;\n        io.disconnect();\n        if (touched.current) return;\n        const calm =\n          !!el.closest('[data-motion=\"reduced\"], [data-motion=\"none\"]') ||\n          matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n        if (calm) return;\n        const beat = resolveCssTime(el, \"--pui-duration-considered\", 0.36);\n        const p = latest.current;\n        const at = (d: number) => `${Math.max(4, Math.min(96, p + d))}%`;\n        nudge.current = el.animate(\n          [\n            { \"--pui-compare-pos\": `${p}%` },\n            { \"--pui-compare-pos\": at(-7), offset: 0.4 },\n            { \"--pui-compare-pos\": at(4), offset: 0.75 },\n            { \"--pui-compare-pos\": `${p}%` },\n          ],\n          {\n            duration: beat * 3 * 1000,\n            delay: beat * 1000,\n            easing: resolveCssEasing(el, \"--pui-ease-in-out\"),\n          },\n        );\n      },\n      { threshold: 0.6 },\n    );\n    io.observe(el);\n    return () => {\n      io.disconnect();\n      nudge.current?.cancel();\n    };\n  }, [hint]);\n  const handsOn = () => {\n    touched.current = true;\n    nudge.current?.cancel();\n  };\n  const set = (p: number) => {\n    const v = Math.max(0, Math.min(100, p));\n    setPos(v);\n  };\n  const h = orientation === \"horizontal\";\n  const at = (x: number, y: number) => {\n    const el = root.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const rtl = getComputedStyle(el).direction === \"rtl\";\n    set(h ? ((rtl ? r.right - x : x - r.left) / r.width) * 100 : ((y - r.top) / r.height) * 100);\n  };\n  // the shared drag (BUILDING §7): a mouse press glides the divider to the point at once, a finger waits for a tap or a\n  // drag along the divider's axis (the page keeps the other axis), the drag follows with no easing, Escape puts it back\n  const from = useRef(pos);\n  const { dragging } = useDrag(\n    root,\n    {\n      onPress: (p, e) => {\n        handsOn();\n        from.current = pos;\n        if (e.pointerType === \"mouse\") at(p.x, p.y);\n      },\n      onStart: (p) => at(p.x, p.y),\n      onMove: (p) => at(p.x, p.y),\n      onTap: (p) => at(p.x, p.y),\n      onCancel: () => set(from.current),\n    },\n    { axis: h ? \"x\" : \"y\" },\n  );\n  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    const step = e.shiftKey ? 10 : 1;\n    const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const map: Record<string, number> = {\n      ArrowLeft: rtl ? step : -step,\n      ArrowRight: rtl ? -step : step,\n      ArrowUp: -step,\n      ArrowDown: step,\n    };\n    if (e.key in map) {\n      e.preventDefault();\n      set(pos + map[e.key]);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      set(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      set(100);\n    }\n  };\n  // run-time geometry reaches the stylesheet as a registered property, so a click or a key glides the divider and the\n  // clip together while a drag follows the finger\n  const vars = { \"--pui-compare-pos\": `${pos}%`, aspectRatio } as CSSProperties;\n  // a caption needs its own size and the inset on both sides of it (about 12px each) on its side of the divider\n  const extent = h ? room.w : room.h;\n  const fits = (share: number, size: number) => !extent || (share / 100) * extent >= size + 24;\n  const showBefore = fits(pos, room.before);\n  const showAfter = fits(100 - pos, room.after);\n  return (\n    <div\n      data-slot=\"image-compare\"\n      ref={rootRef}\n      data-orientation={orientation}\n      data-dragging={dragging || undefined}\n      className={cn(styles.root, classNames?.root, className)}\n      style={{ ...vars, ...style }}\n      // a double click anywhere on the frame returns the divider to where it started\n      onDoubleClick={() => set(defaultPosition)}\n    >\n      <div\n        data-slot=\"image-compare-after\"\n        className={cn(styles.layer, classNames?.after)}\n        aria-label={labels ? labels[1] : i18n(\"after\")}\n        role=\"img\"\n      >\n        {after}\n      </div>\n      <div\n        data-slot=\"image-compare-before\"\n        className={cn(styles.layer, styles.before, classNames?.before)}\n        role=\"img\"\n        aria-label={labels ? labels[0] : i18n(\"before\")}\n      >\n        {before}\n      </div>\n      {labels && (\n        <>\n          <span\n            aria-hidden\n            data-slot=\"image-compare-caption\"\n            data-side=\"before\"\n            data-hidden={!showBefore || undefined}\n            className={cn(styles.caption, classNames?.caption)}\n          >\n            {labels[0]}\n          </span>\n          <span\n            aria-hidden\n            data-slot=\"image-compare-caption\"\n            data-side=\"after\"\n            data-hidden={!showAfter || undefined}\n            className={cn(styles.caption, classNames?.caption)}\n          >\n            {labels[1]}\n          </span>\n        </>\n      )}\n      <div className={styles.travel}>\n        <div aria-hidden data-slot=\"image-compare-divider\" className={cn(styles.line, classNames?.divider)} />\n        <div\n          role=\"slider\"\n          tabIndex={0}\n          data-slot=\"image-compare-handle\"\n          aria-label={i18n(\"divider\", { label: label })}\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={Math.round(pos)}\n          aria-valuetext={i18n(\"position\", {\n            percent: i18n.number(pos / 100, { style: \"percent\", maximumFractionDigits: 0 }),\n            label: labels ? labels[0] : i18n(\"before\"),\n          })}\n          aria-orientation={orientation}\n          onKeyDown={(e) => {\n            handsOn();\n            onKey(e);\n          }}\n          className={cn(styles.knob, classNames?.handle)}\n        >\n          {h ? <ChevronsLeftRight /> : <ChevronsUpDown />}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default ImageCompare;\n"},{"path":"beamline/components/image-compare/messages.ts","type":"registry:file","target":"src/ui/beamline/components/image-compare/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  after: \"After\",\n  before: \"Before\",\n  divider: \"{label}: divider\",\n  position: \"{percent} {label}\",\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-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-drag.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-drag.ts","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\nimport { VelocityTracker } from \"../lib/gesture\";\n\n/** Where the pointer is during a drag, relative to where it was pressed, with its velocity (px/s) over the last 100 ms. */\nexport interface DragPoint {\n  /** Pointer position in client coordinates. */\n  x: number;\n  y: number;\n  /** Distance from the press. */\n  dx: number;\n  dy: number;\n  /** Velocity in px/s (read from the last 100 ms, so a release carries the flick, not the whole drag). */\n  vx: number;\n  vy: number;\n  pointerType: string;\n  /** Shift was held (a part may offer a second mode on it: move one task alone, a finer step). */\n  shift: boolean;\n}\n\nexport interface DragHandlers {\n  /** A press landed on the element. Return false to leave it alone (a press on a nested button or link). */\n  onPress?: (point: DragPoint, event: PointerEvent) => boolean | void;\n  /** The pointer moved past the threshold: the drag begins (the press point is in `point.x - point.dx`). */\n  onStart?: (point: DragPoint) => void;\n  /** The pointer moved during a drag. `event` is the browser's own, for what a point does not carry: a pen's pressure,\n   *  the coalesced samples a fast stroke packs into one event (a drawing surface). */\n  onMove?: (point: DragPoint, event: PointerEvent) => void;\n  /** Released after a drag, with the release velocity. */\n  onEnd?: (point: DragPoint) => void;\n  /** Released without moving past the threshold: a tap or click (the part's tap route, WCAG 2.5.7). */\n  onTap?: (point: DragPoint) => void;\n  /** Escape, a lost pointer or the browser taking the gesture (a page scroll): put things back as they were. */\n  onCancel?: () => void;\n  /** A press that ended as neither a drag nor a tap: a finger that went on to scroll the page, or a pointer the browser\n   *  took before the threshold. Undo whatever `onPress` prepared. */\n  onAbandon?: () => void;\n}\n\nexport interface DragOptions {\n  /** The axis the part moves on. On touch, a drag that starts mostly across it is left to the page (a scroll). */\n  axis?: \"x\" | \"y\" | \"both\";\n  /** Pixels before a press becomes a drag: 3 for a mouse, 8 for a finger or pen unless given. */\n  threshold?: number;\n  disabled?: boolean;\n}\n\n/**\n * The pointer side of a hand on a part, shared by everything dragged, thrown or pulled (a thumb, a puck, a stub, a\n * task, a cable). It listens natively on the element, so moves never render React: the part writes positions in its\n * handlers (a spring, a transform, a custom property) and only `dragging`, which changes at the start and end, is\n * state (for `data-state`).\n *\n * A press becomes a drag past a small threshold, then the pointer is captured so the drag continues outside the\n * element; a release without moving is a tap (`onTap`), the route every drag needs for people who cannot drag. Escape,\n * `pointercancel` and a lost capture call `onCancel`, and the part restores what it showed before the press. On touch the\n * element needs a `touch-action` that leaves the other axis to the page (`pan-y` for a part dragged sideways; never\n * `none` on more than the dragged surface, which would block zoom), and a drag that starts across its axis is dropped\n * so the page can scroll.\n */\nexport function useDrag<T extends HTMLElement | SVGElement>(\n  ref: RefObject<T | null>,\n  handlers: DragHandlers,\n  { axis = \"both\", threshold, disabled = false }: DragOptions = {},\n): { dragging: boolean } {\n  const [dragging, setDragging] = useState(false);\n  const latest = useRef(handlers);\n  latest.current = handlers;\n\n  useEffect(() => {\n    // an SVG part (a chart's brush) is dragged the same way; its pointer events are typed as an HTML element's\n    const el = ref.current as HTMLElement | null;\n    if (!el || disabled) return;\n    const doc = el.ownerDocument;\n    // Escape during a drag belongs to the drag: listened on the window's capture phase, it runs before any dialog or\n    // popover listening on the document, which would otherwise also close around the cancelled drag\n    const win = doc.defaultView ?? window;\n    let active: {\n      id: number;\n      x0: number;\n      y0: number;\n      type: string;\n      started: boolean;\n      tracker: VelocityTracker;\n    } | null = null;\n\n    const point = (e: PointerEvent): DragPoint => {\n      const a = active!;\n      const [vx, vy] = a.tracker.velocity(e.timeStamp);\n      return {\n        x: e.clientX,\n        y: e.clientY,\n        dx: e.clientX - a.x0,\n        dy: e.clientY - a.y0,\n        vx,\n        vy,\n        pointerType: e.pointerType,\n        shift: e.shiftKey,\n      };\n    };\n\n    const finish = () => {\n      if (active?.started && el.hasPointerCapture?.(active.id)) el.releasePointerCapture(active.id);\n      active = null;\n      doc.removeEventListener(\"pointermove\", move);\n      doc.removeEventListener(\"pointerup\", up);\n      doc.removeEventListener(\"pointercancel\", cancel);\n      win.removeEventListener(\"keydown\", key, true);\n      doc.removeEventListener(\"selectstart\", hold, true);\n      doc.removeEventListener(\"dragstart\", hold, true);\n      el.removeEventListener(\"lostpointercapture\", lost);\n      setDragging(false);\n    };\n\n    const cancel = () => {\n      if (!active) return;\n      const started = active.started;\n      finish();\n      if (started) latest.current.onCancel?.();\n      else latest.current.onAbandon?.();\n    };\n\n    // only the part's own capture ending cancels: a finger's press is captured by the child it landed on, and moving\n    // the capture to the part fires lostpointercapture on that child, which bubbles here\n    const lost = (e: PointerEvent) => {\n      if (e.target === el && active?.started && e.pointerId === active.id) cancel();\n    };\n\n    const key = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || !active?.started) return;\n      e.preventDefault();\n      e.stopImmediatePropagation();\n      cancel();\n    };\n\n    // while the hand holds the part, the page neither selects text nor starts its own drag and drop (a selection left\n    // by an earlier drag would otherwise be dragged, and the browser cancels the pointer)\n    const hold = (e: Event) => e.preventDefault();\n\n    const move = (e: PointerEvent) => {\n      if (!active || e.pointerId !== active.id) return;\n      active.tracker.add(e.timeStamp, e.clientX, e.clientY);\n      const p = point(e);\n      if (!active.started) {\n        const limit = threshold ?? (active.type === \"mouse\" ? 3 : 8);\n        if (Math.hypot(p.dx, p.dy) < limit) return;\n        // a finger that starts across the part's axis is scrolling the page\n        const across = axis === \"x\" ? Math.abs(p.dy) > Math.abs(p.dx) : axis === \"y\" && Math.abs(p.dx) > Math.abs(p.dy);\n        if (active.type !== \"mouse\" && across) {\n          finish();\n          latest.current.onAbandon?.();\n          return;\n        }\n        active.started = true;\n        doc.getSelection()?.removeAllRanges();\n        el.setPointerCapture?.(active.id);\n        el.addEventListener(\"lostpointercapture\", lost);\n        setDragging(true);\n        latest.current.onStart?.(p);\n      }\n      latest.current.onMove?.(p, e);\n    };\n\n    const up = (e: PointerEvent) => {\n      if (!active || e.pointerId !== active.id) return;\n      active.tracker.add(e.timeStamp, e.clientX, e.clientY);\n      const p = point(e);\n      const started = active.started;\n      finish();\n      if (started) latest.current.onEnd?.(p);\n      else latest.current.onTap?.(p);\n    };\n\n    const down = (e: PointerEvent) => {\n      if (active || !e.isPrimary || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n      const tracker = new VelocityTracker();\n      tracker.add(e.timeStamp, e.clientX, e.clientY);\n      active = { id: e.pointerId, x0: e.clientX, y0: e.clientY, type: e.pointerType, started: false, tracker };\n      if (latest.current.onPress?.(point(e), e) === false) {\n        active = null;\n        return;\n      }\n      doc.addEventListener(\"pointermove\", move);\n      doc.addEventListener(\"pointerup\", up);\n      doc.addEventListener(\"pointercancel\", cancel);\n      win.addEventListener(\"keydown\", key, true);\n      doc.addEventListener(\"selectstart\", hold, true);\n      doc.addEventListener(\"dragstart\", hold, true);\n    };\n\n    el.addEventListener(\"pointerdown\", down);\n    return () => {\n      el.removeEventListener(\"pointerdown\", down);\n      if (active) finish();\n    };\n  }, [ref, axis, threshold, disabled]);\n\n  return { dragging };\n}\n"},{"path":"beamline/lib/gesture.ts","type":"registry:file","target":"src/ui/beamline/lib/gesture.ts","content":"import { readMotionFactor } from \"./motion-tokens\";\n\n/**\n * The arithmetic of a hand on a part, shared by every part that is dragged, thrown or pulled (useDrag is the pointer\n * side). Three pieces, each from how real interfaces feel (Apple, \"Designing Fluid Interfaces\", WWDC 2018):\n *\n * - velocity: read from the last ~100 ms of movement, so the release carries the speed of the flick, not of the whole drag;\n * - projection: where a thrown thing would come to rest if it slid on with the look's carry (`project`), so a part\n *   can decide on release (the nearest value, step or preset to that point) instead of after an animation;\n * - give: past a limit the part follows the hand less and less (`rubberband`) instead of stopping dead, and springs back.\n *\n * How much a part carries and gives is the look's material, read once per gesture from the element (`readHand`):\n * `--m-hand-carry`, `--m-hand-give` and `--m-hand-bounce` (preset-source.css), with `--m-morph-weight` scaling how\n * long things take to settle (times a motion token's duration, which carries the speed setting). Reduced motion\n * carries nothing, gives nothing and never bounces.\n */\n\nexport interface Hand {\n  /** How far a release carries: 0 stops where the hand let go, 1 slides on freely (see `project`). */\n  carry: number;\n  /** How much a part follows the hand past a limit: 0 is a hard stop, 0.55 a phone's rubber band. */\n  give: number;\n  /** Bounce of a released held object (0 critically damped). Only for what the pointer held. */\n  bounce: number;\n  /** The look's weight (`--m-morph-weight`): multiply a motion token's duration by it. The token already carries the\n   *  speed setting, so the weight never does. */\n  weight: number;\n  /** Reduced or no motion: land at once. */\n  reduced: boolean;\n}\n\n/** The hand the defaults describe; also what a server or a test without styles gets. */\nexport const DEFAULT_HAND: Hand = { carry: 0.8, give: 0.45, bounce: 0.14, weight: 1, reduced: false };\n\nconst num = (style: CSSStyleDeclaration, name: string, fallback: number) => {\n  const v = Number.parseFloat(style.getPropertyValue(name));\n  return Number.isFinite(v) ? v : fallback;\n};\n\n/** The look's physics at `el`, read once at the start of a gesture (never per frame). */\nexport function readHand(el: Element | null | undefined): Hand {\n  if (!el || typeof getComputedStyle === \"undefined\") return DEFAULT_HAND;\n  const style = getComputedStyle(el);\n  const factor = readMotionFactor(el);\n  // the motion factor is ~0.005 under reduced motion and 1 at the foundation default\n  const reduced = factor < 0.05;\n  if (reduced) return { carry: 0, give: 0, bounce: 0, weight: 1, reduced: true };\n  return {\n    carry: num(style, \"--m-hand-carry\", DEFAULT_HAND.carry),\n    give: num(style, \"--m-hand-give\", DEFAULT_HAND.give),\n    bounce: num(style, \"--m-hand-bounce\", DEFAULT_HAND.bounce),\n    weight: num(style, \"--m-morph-weight\", 1),\n    reduced: false,\n  };\n}\n\n/** Samples of a pointer's path, kept for the last `window` ms, to read its velocity at release. */\nexport class VelocityTracker {\n  private samples: { t: number; x: number; y: number }[] = [];\n  // a declared field, not a parameter property: shipped source must also compile under erasableSyntaxOnly (Vite's\n  // TypeScript template turns it on), where TypeScript-only syntax with runtime meaning is refused\n  private readonly window: number;\n  constructor(window = 100) {\n    this.window = window;\n  }\n\n  add(t: number, x: number, y: number) {\n    this.samples.push({ t, x, y });\n    const from = t - this.window;\n    while (this.samples.length > 2 && this.samples[0].t < from) this.samples.shift();\n  }\n\n  /** Units per second over the kept window; 0 when the hand stopped before letting go (a pause of more than the window). */\n  velocity(now = this.samples.at(-1)?.t ?? 0): [number, number] {\n    const s = this.samples.filter((p) => p.t >= now - this.window);\n    if (s.length < 2) return [0, 0];\n    // least squares slope over the window: one jittery last sample does not decide the throw\n    const n = s.length;\n    const mt = s.reduce((a, p) => a + p.t, 0) / n;\n    const mx = s.reduce((a, p) => a + p.x, 0) / n;\n    const my = s.reduce((a, p) => a + p.y, 0) / n;\n    let tt = 0;\n    let tx = 0;\n    let ty = 0;\n    for (const p of s) {\n      tt += (p.t - mt) ** 2;\n      tx += (p.t - mt) * (p.x - mx);\n      ty += (p.t - mt) * (p.y - my);\n    }\n    if (tt === 0) return [0, 0];\n    return [(tx / tt) * 1000, (ty / tt) * 1000];\n  }\n\n  reset() {\n    this.samples = [];\n  }\n}\n\n/**\n * How far something released at `velocity` (units/s) slides before friction stops it, with the look's carry. `rate` is\n * the speed kept per millisecond: 0.99 (the default) suits controls on a short scale (a thumb, a puck, a cut), where a\n * flick should carry a little way; 0.998 is a phone list's deceleration, for long scrolling content.\n */\nexport function project(velocity: number, carry: number, rate = 0.99): number {\n  if (carry <= 0) return 0;\n  return (((velocity / 1000) * rate) / (1 - rate)) * carry;\n}\n\n/**\n * Where a part drawn `overshoot` units past its limit sits, given the room `extent` it moves in: it follows less and\n * less the further it goes and never reaches `extent`. `give` 0 is a hard stop.\n */\nexport function rubberband(overshoot: number, extent: number, give: number): number {\n  if (give <= 0 || extent <= 0) return 0;\n  const sign = Math.sign(overshoot);\n  const x = Math.abs(overshoot);\n  return sign * (1 - 1 / ((x * give) / extent + 1)) * extent;\n}\n\n/** `value` held inside [min, max], with the part's give past either end. */\nexport function within(value: number, min: number, max: number, extent: number, give: number): number {\n  if (value < min) return min + rubberband(value - min, extent, give);\n  if (value > max) return max + rubberband(value - max, extent, give);\n  return value;\n}\n\n/** The nearest of `stops` to `value` (a projected landing point picks its step or preset this way). */\nexport function nearest(value: number, stops: readonly number[]): number {\n  let best = stops[0];\n  for (const s of stops) if (Math.abs(s - value) < Math.abs(best - value)) best = s;\n  return best;\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":"Image compare is in src/ui/beamline/components/image-compare/image-compare.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/image-compare"}}