{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sula-fab",
  "type": "registry:ui",
  "dependencies": [
    "clsx",
    "motion",
    "ogl",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "https://usva.build/r/sula-motion.json",
    "https://usva.build/r/sula-core.json"
  ],
  "files": [
    {
      "path": "fab-geometry.ts",
      "target": "components/ui/fab-geometry.ts",
      "type": "registry:ui",
      "content": "import { emergeDroplet } from \"./emerge\";\nimport { type Blob, bridgeNecks, type Neck } from \"./geometry\";\n\nexport type FabLayout = \"line\" | \"arc\";\nexport type FabDirection = \"up\" | \"down\" | \"left\" | \"right\";\n\nexport interface FabSlot {\n  x: number;\n  y: number;\n}\n\n/** Real trigger and bead radii plus the constant edge gap between neighbours. */\nexport interface FabSpacing {\n  triggerR: number;\n  beadR: number;\n  gap: number;\n}\n\n/** Per-bead launch delay, as a fraction of open progress. Nearer beads lead. */\nexport const FAB_STAGGER = 0.08;\n/** The arc fan spans this many degrees, centred on the layout direction. */\nconst ARC_SPAN = (100 * Math.PI) / 180;\n\nconst UNIT: Record<FabDirection, FabSlot> = {\n  up: { x: 0, y: -1 },\n  down: { x: 0, y: 1 },\n  left: { x: -1, y: 0 },\n  right: { x: 1, y: 0 },\n};\n\nfunction lineSlots(\n  count: number,\n  direction: FabDirection,\n  { triggerR, beadR, gap }: FabSpacing,\n): FabSlot[] {\n  const unit = UNIT[direction];\n  /* Edge gaps are constant: the trigger edge to bead0 edge is `gap`, and every\n   * bead edge to the next bead edge is `gap`, whatever the two radii are. */\n  const first = triggerR + beadR + gap;\n  const step = 2 * beadR + gap;\n  return Array.from({ length: count }, (_, i) => {\n    const distance = first + i * step;\n    return { x: unit.x * distance, y: unit.y * distance };\n  });\n}\n\nfunction arcSlots(\n  count: number,\n  direction: FabDirection,\n  { triggerR, beadR, gap }: FabSpacing,\n): FabSlot[] {\n  /* Fan evenly across ARC_SPAN centred on the direction's axis. A single bead\n   * sits on that axis. Every bead is one radius from the trigger centre, so the\n   * trigger edge to bead edge gap is `gap`. */\n  const unit = UNIT[direction];\n  const center = Math.atan2(unit.y, unit.x);\n  const radius = triggerR + beadR + gap;\n  return Array.from({ length: count }, (_, i) => {\n    const frac = count === 1 ? 0.5 : i / (count - 1);\n    const angle = center - ARC_SPAN / 2 + frac * ARC_SPAN;\n    return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };\n  });\n}\n\n/** Slot centres for the actions, relative to the trigger centre at (0,0). */\nexport function fabSlots(\n  count: number,\n  layout: FabLayout,\n  direction: FabDirection,\n  spacing: FabSpacing,\n): FabSlot[] {\n  if (count <= 0) return [];\n  return layout === \"arc\"\n    ? arcSlots(count, direction, spacing)\n    : lineSlots(count, direction, spacing);\n}\n\n/** A fully rounded blob from a measured rect, relative to the stage box. */\nexport function blobFromRect(rect: {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n}): Blob {\n  const hw = rect.width / 2;\n  const hh = rect.height / 2;\n  return {\n    cx: rect.left + hw,\n    cy: rect.top + hh,\n    hw,\n    hh,\n    r: Math.min(hw, hh),\n  };\n}\n\n/**\n * The field at open-progress `t` (0 closed, 1 open; may pass 1 for spring\n * overshoot): the trigger blob plus one bead per action, each emerging from the\n * trigger to its slot, staggered so nearer beads lead. Necks tether the beads\n * that are still travelling. `slots` are the beads at their rest slot positions.\n */\nexport function fabPhase(\n  trigger: Blob,\n  slots: Blob[],\n  t: number,\n): { blobs: Blob[]; necks: Neck[] } {\n  const blobs: Blob[] = [trigger];\n  const necks: Neck[] = [];\n\n  for (let i = 0; i < slots.length; i++) {\n    const slot = slots[i] as Blob;\n    /* Later beads launch after the nearer ones, but every local timeline maps\n     * global t=1 back to exactly 1. This preserves the cascade without leaving\n     * the early beads stranded at different overshoot positions at rest. */\n    const delay = i * FAB_STAGGER;\n    const ti = (t - delay) / (1 - delay);\n    const { blob, neck } = emergeDroplet(trigger, slot, ti);\n    blobs.push(blob);\n    if (neck) necks.push(neck);\n  }\n\n  return { blobs, necks };\n}\n\n/** Resting surface tension follows the layout's visual topology. Arc actions\n * radiate from the trigger. Line actions form a strong chain, with only the first\n * action tied weakly to the trigger so the menu reads as one strand that can be\n * pulled back into the FAB. */\nexport function fabBridges(\n  blobs: Blob[],\n  k: number,\n  merge: number,\n  layout: FabLayout,\n): Neck[] {\n  const trigger = blobs[0];\n  if (!trigger) return [];\n  const actions = blobs.slice(1);\n  if (layout === \"arc\") {\n    return actions.flatMap((bead) => bridgeNecks([trigger, bead], k, merge));\n  }\n\n  const actionChain = bridgeNecks(actions, k, merge);\n  const first = actions[0];\n  const triggerTie = first ? bridgeNecks([trigger, first], k, merge * 0.5) : [];\n  return [...actionChain, ...triggerTie];\n}\n"
    },
    {
      "path": "sula-fab.tsx",
      "target": "components/ui/sula-fab.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\nimport { animate, useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  createField,\n  type FieldColors,\n  resolveColor,\n  shineForBackdrop,\n} from \"./field\";\nimport {\n  type Blob,\n  type Neck,\n  packHover,\n  packUniforms,\n} from \"./geometry\";\nimport { createPauseGate } from \"./pause\";\nimport { useContextRecovery } from \"./recovery\";\nimport { useFieldRetune } from \"./retune\";\nimport { clamp01, smoothstep } from \"./curves\";\nimport { createEnergyTracker } from \"./energy\";\nimport { sideSpring } from \"./springs\";\nimport {\n  FAB_STAGGER,\n  type FabDirection,\n  type FabLayout,\n  fabBridges,\n  fabPhase,\n  fabSlots,\n} from \"./fab-geometry\";\n\nexport interface SulaFabAction {\n  icon: React.ReactNode;\n  /** The accessible name and the tooltip text. */\n  label: string;\n  onClick?: () => void;\n  href?: string;\n}\n\nexport type SulaFabTooltipPosition = \"left\" | \"right\" | \"top\";\n\nexport interface SulaFabProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  actions: SulaFabAction[];\n  /** The trigger glyph. Defaults to a plus. */\n  icon?: React.ReactNode;\n  /** The trigger's accessible name. Defaults to \"Actions\". */\n  label?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  layout?: FabLayout;\n  direction?: FabDirection;\n  /** Tooltip side. Defaults to left for line layouts and top for arcs. */\n  tooltipPosition?: SulaFabTooltipPosition;\n  /** Constant edge gap in px between the trigger and beads, and between beads. */\n  gap?: number;\n  /** false or reduced-motion renders a plain stacked menu with no canvas. */\n  fluid?: boolean;\n  accentColor?: string;\n  backdrop?: string;\n  tint?: string;\n  shine?: number;\n}\n\n/** Edge gap between neighbours in px. Kept tight so open beads sit within bridge\n * reach and hold the rest necks. */\nconst GAP_DEFAULT = 12;\n/** Nominal trigger radius from the `size-14` class (56px box), for the non-fluid\n * fallback layout before any measurement runs. */\nconst NOMINAL_TRIGGER_R = 28;\n/** Nominal bead radius from the `size-11` class (44px box). */\nconst NOMINAL_BEAD_R = 22;\n/** Room past the outermost bead so its cap and neck are never clipped. */\nconst SLACK = 26;\nconst MAX_DPR = 2;\n/** Merge radius at rest: firm glass. */\nconst REST_K = 16;\n/** Merge radius while beads travel: gooey. */\nconst K_ACTIVE = 26;\n/** Peak surface undulation in px, alive only while beads move. */\nconst WOBBLE = 0.6;\n/** Merge floor while open: adjacent beads hold a soft surface-tension waist at\n * rest instead of floating apart. Kept low so the chain never fuses. */\nconst REST_MERGE = 0.32;\n/** Peak edge displacement of the hover ripple, in px. Local to the hovered\n * part, so it can run hotter than the global settle wobble. */\nconst HOVER_WOBBLE = 1.1;\n/** Per-frame ease toward the hover target, in and out. */\nconst HOVER_EASE = 0.16;\n\nfunction readColors(\n  node: HTMLElement,\n  overrides: {\n    backdrop?: string;\n    tint?: string;\n    accent?: string;\n    shine?: number;\n  },\n): FieldColors {\n  const styles = getComputedStyle(node);\n  const token = (name: string) => styles.getPropertyValue(name).trim();\n  const backdrop = resolveColor(overrides.backdrop ?? token(\"--usva-bg\"));\n  const tintToken =\n    overrides.tint ?? token(\"--usva-surface-2\") ?? token(\"--usva-surface\");\n  return {\n    backdrop,\n    tint: resolveColor(tintToken || token(\"--usva-surface\")),\n    accent: resolveColor(overrides.accent ?? token(\"--usva-accent\")),\n    shine: overrides.shine ?? shineForBackdrop(backdrop),\n  };\n}\n\nconst DefaultPlus = (\n  <svg\n    viewBox=\"0 0 24 24\"\n    width=\"20\"\n    height=\"20\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2\"\n    strokeLinecap=\"round\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M12 5v14M5 12h14\" />\n  </svg>\n);\n\nexport const SulaFab = React.forwardRef<HTMLDivElement, SulaFabProps>(\n  (\n    {\n      actions,\n      icon = DefaultPlus,\n      label = \"Actions\",\n      open: openProp,\n      defaultOpen = false,\n      onOpenChange,\n      layout = \"line\",\n      direction = \"up\",\n      tooltipPosition,\n      gap = GAP_DEFAULT,\n      fluid = true,\n      accentColor,\n      backdrop,\n      tint,\n      shine,\n      className,\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    const reduced = useReducedMotion();\n    const rootRef = React.useRef<HTMLDivElement | null>(null);\n    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n    const stageRef = React.useRef<HTMLDivElement | null>(null);\n    const triggerRef = React.useRef<HTMLButtonElement | null>(null);\n    const actionRefs = React.useRef<Array<HTMLElement | null>>([]);\n\n    const { failed, generation, onContextLost, onContextReady } =\n      useContextRecovery(canvasRef);\n    const overrides = React.useMemo(\n      () => ({ backdrop, tint, accent: accentColor, shine }),\n      [backdrop, tint, accentColor, shine],\n    );\n    const overridesRef = React.useRef(overrides);\n    overridesRef.current = overrides;\n    const gapRef = React.useRef(gap);\n    gapRef.current = gap;\n    const fieldRef = React.useRef<ReturnType<typeof createField>>(null);\n    const wakeRef = React.useRef<(() => void) | null>(null);\n    const measureRef = React.useRef<(() => void) | null>(null);\n    const [mounted, setMounted] = React.useState(false);\n    React.useEffect(() => setMounted(true), []);\n\n    const isFluid = fluid && !reduced && !failed && mounted;\n    /* Kept mounted through a failure so the context can be handed back. */\n    const keepCanvas = fluid && !reduced && mounted;\n    const resolvedTooltipPosition =\n      tooltipPosition ?? (layout === \"arc\" ? \"top\" : \"left\");\n\n    const isControlled = openProp !== undefined;\n    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);\n    const open = isControlled ? openProp : uncontrolledOpen;\n\n    const openRef = React.useRef(open);\n    openRef.current = open;\n\n    const setOpen = React.useCallback(\n      (next: boolean) => {\n        if (!isControlled) setUncontrolledOpen(next);\n        onOpenChange?.(next);\n      },\n      [isControlled, onOpenChange],\n    );\n\n    const slotOffsets = React.useMemo(\n      () =>\n        fabSlots(actions.length, layout, direction, {\n          triggerR: NOMINAL_TRIGGER_R,\n          beadR: NOMINAL_BEAD_R,\n          gap,\n        }),\n      [actions.length, layout, direction, gap],\n    );\n\n    const openTargetRef = React.useRef<(next: boolean) => void>(() => {});\n\n    // biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is not read here, it is what rebuilds the field on a restored context\n    React.useEffect(() => {\n      if (!isFluid) return;\n      const canvas = canvasRef.current;\n      const stage = stageRef.current;\n      const root = rootRef.current;\n      const trigger = triggerRef.current;\n      if (!canvas || !stage || !root || !trigger) return;\n\n      const field = createField({\n        canvas,\n        colors: readColors(root, overridesRef.current),\n        onContextLost,\n      });\n      if (!field) {\n        onContextLost();\n        return;\n      }\n      fieldRef.current = field;\n      onContextReady();\n\n      let triggerBlob: Blob = { cx: 0, cy: 0, hw: 0, hh: 0, r: 0 };\n      let triggerCenter = { x: 0, y: 0 };\n      let slotBlobs: Blob[] = [];\n      let canvasH = 0;\n      let dpr = 1;\n      let raf = 0;\n      let running = 0;\n      let hoverIndex = -1;\n      let hoverAmt = 0;\n      let hoverBlob: Blob | null = null;\n      const energy = createEnergyTracker();\n      const start = performance.now();\n\n      const oT = { value: openRef.current ? 1 : 0 };\n      let pOpen = oT.value;\n\n      /* Index 0 is the trigger, i + 1 the beads, matching fabPhase's blob order.\n       * On leave the last blob is kept so the ripple fades in place. */\n      const updateHover = (blobs: Blob[]) => {\n        const focus = hoverIndex >= 0 ? blobs[hoverIndex] : null;\n        hoverAmt += ((focus ? 1 : 0) - hoverAmt) * HOVER_EASE;\n        if (focus) hoverBlob = focus;\n      };\n\n      let lastW = 0;\n      let lastH = 0;\n      const measure = () => {\n        const rootBox = root.getBoundingClientRect();\n        const triggerRect = trigger.getBoundingClientRect();\n        /* Layout sizes, never rect sizes: the open trigger carries rotate-45,\n         * which inflates its client-rect AABB by sqrt(2). Measured that way the\n         * first slot sat a near-doubled edge gap out. The rect centre is still\n         * exact under any rotation, so positions may keep using it. */\n        const tw = trigger.offsetWidth || triggerRect.width;\n        const th = trigger.offsetHeight || triggerRect.height;\n\n        const firstNode = actionRefs.current[0];\n        const beadH = firstNode ? firstNode.offsetHeight : th * 0.7;\n        const triggerR = Math.min(tw, th) / 2;\n        const beadR = firstNode\n          ? Math.min(firstNode.offsetWidth, firstNode.offsetHeight) / 2\n          : beadH / 2;\n        const offsets = fabSlots(actions.length, layout, direction, {\n          triggerR,\n          beadR,\n          gap: gapRef.current,\n        });\n\n        // Stage bounds in root coords, covering the trigger and every slot.\n        const cx0 = triggerRect.left - rootBox.left + triggerRect.width / 2;\n        const cy0 = triggerRect.top - rootBox.top + triggerRect.height / 2;\n        let minX = -tw / 2;\n        let maxX = tw / 2;\n        let minY = -th / 2;\n        let maxY = th / 2;\n        for (const [i, off] of offsets.entries()) {\n          const node = actionRefs.current[i];\n          const bw = (node?.offsetWidth || beadH) / 2;\n          const bh = (node?.offsetHeight || beadH) / 2;\n          minX = Math.min(minX, off.x - bw);\n          maxX = Math.max(maxX, off.x + bw);\n          minY = Math.min(minY, off.y - bh);\n          maxY = Math.max(maxY, off.y + bh);\n        }\n        stage.style.left = `${cx0 + minX - SLACK}px`;\n        stage.style.top = `${cy0 + minY - SLACK}px`;\n        stage.style.width = `${maxX - minX + SLACK * 2}px`;\n        stage.style.height = `${maxY - minY + SLACK * 2}px`;\n\n        const stageBox = stage.getBoundingClientRect();\n        const width = Math.ceil(stageBox.width);\n        const height = Math.ceil(stageBox.height);\n        const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);\n        if (width !== lastW || height !== lastH || nextDpr !== dpr) {\n          canvas.style.width = `${width}px`;\n          canvas.style.height = `${height}px`;\n          field.resize(width, height, nextDpr);\n          lastW = width;\n          lastH = height;\n        }\n        canvasH = height;\n        dpr = nextDpr;\n\n        triggerBlob = {\n          cx: triggerRect.left + triggerRect.width / 2 - stageBox.left,\n          cy: triggerRect.top + triggerRect.height / 2 - stageBox.top,\n          hw: tw / 2,\n          hh: th / 2,\n          r: triggerR,\n        };\n        triggerCenter = { x: triggerBlob.cx, y: triggerBlob.cy };\n        slotBlobs = offsets.map((off, i) => {\n          const node = actionRefs.current[i];\n          const bw = (node?.offsetWidth || beadH) / 2;\n          const bh = (node?.offsetHeight || beadH) / 2;\n          return {\n            cx: triggerCenter.x + off.x,\n            cy: triggerCenter.y + off.y,\n            hw: bw,\n            hh: bh,\n            r: Math.min(bw, bh),\n          };\n        });\n      };\n\n      const paint = (blobs: Blob[], necks: Neck[], liveK: number) => {\n        field.draw({\n          packed: packUniforms({ blobs, necks, k: liveK }, dpr, canvasH),\n          k: liveK * dpr,\n          time: (performance.now() - start) / 1000,\n          wobble: WOBBLE * energy.value,\n          alpha: 1,\n          hover:\n            hoverBlob && hoverAmt > 0.01\n              ? packHover(hoverBlob, hoverAmt * HOVER_WOBBLE, dpr, canvasH)\n              : null,\n        });\n      };\n\n      const liveK = () => REST_K + (K_ACTIVE - REST_K) * energy.value;\n\n      const tick = () => {\n        const t = oT.value;\n        const prog = clamp01(t);\n        const v = t - pOpen;\n        pOpen = t;\n        energy.bump(Math.abs(v));\n\n        const { blobs, necks } = fabPhase(triggerBlob, slotBlobs, t);\n        updateHover(blobs);\n        const k = liveK();\n        /* Melt the neck as the fab closes: the whole merge fades to zero by the\n         * time the beads absorb, so the trigger-to-bead neck recedes by strength\n         * instead of snapping off at the clean-close cut. At settled open the\n         * floor keeps a faint pull; mid-travel the sine drives the full goo.\n         * Floor the neck merge while open so settled-open beads keep a faint\n         * pull; necks still fully retract as prog falls to 0 on close. */\n        const merge =\n          prog > 0\n            ? Math.max(REST_MERGE, Math.sin(Math.PI * prog)) *\n              smoothstep(0.02, 0.28, prog)\n            : 0;\n        const bridge = fabBridges(blobs, k, merge, layout);\n        /* Below this the field settles to a clean trigger, no absorbed-bead halo. */\n        const closed = prog < 0.06;\n        const drawBlobs = closed ? [triggerBlob] : blobs;\n        const drawNecks = closed ? [] : [...necks, ...bridge];\n        paint(drawBlobs, drawNecks, k);\n\n        // Beads are DOM parts moved to match their blob each frame.\n        const last = slotBlobs.length;\n        for (let i = 0; i < last; i++) {\n          const node = actionRefs.current[i];\n          const bead = blobs[i + 1];\n          if (!node || !bead) continue;\n          node.style.transform = `translate(-50%, -50%) translate(${\n            bead.cx - triggerCenter.x\n          }px, ${bead.cy - triggerCenter.y}px)`;\n          node.style.opacity = closed\n            ? \"0\"\n            : `${clamp01((prog - i * FAB_STAGGER) / 0.22)}`;\n        }\n\n        if (\n          running === 0 &&\n          energy.parked() &&\n          hoverIndex < 0 &&\n          hoverAmt < 0.02\n        ) {\n          return;\n        }\n        raf = requestAnimationFrame(tick);\n      };\n      const wake = () => {\n        if (!gate.awake()) return;\n        cancelAnimationFrame(raf);\n        raf = requestAnimationFrame(tick);\n      };\n      wakeRef.current = wake;\n      measureRef.current = measure;\n      const gate = createPauseGate({\n        target: root,\n        onPause: () => cancelAnimationFrame(raf),\n        onResume: () => wake(),\n      });\n\n      const controls = new Set<ReturnType<typeof animate>>();\n      const run = (to: [number, number]) => {\n        running += 1;\n        const control = animate(oT, { value: to }, sideSpring);\n        controls.add(control);\n        const finish = () => {\n          running = Math.max(0, running - 1);\n          controls.delete(control);\n        };\n        void control.finished.then(finish, finish);\n        wake();\n        return control;\n      };\n\n      let openControl: ReturnType<typeof animate> | null = null;\n      const toOpen = (next: boolean) => {\n        measure();\n        openControl?.stop();\n        /* Motion caches one visual element per animated subject and reads the\n         * next \"from\" out of it, so a bare oT.value reset is invisible to it.\n         * The explicit [0,1] / [1,0] keyframes are what make the second and\n         * every later toggle animate instead of snapping. */\n        openControl = run(next ? [0, 1] : [1, 0]);\n      };\n      openTargetRef.current = toOpen;\n\n      measure();\n      wake();\n\n      /* Hovering the trigger or a bead wakes a ripple on that part alone; the\n       * rest of the chain stays a calm sheet of glass. */\n      const hoverNodes = [trigger, ...actionRefs.current].filter(\n        (node): node is HTMLElement => node != null,\n      );\n      const hoverHandlers = hoverNodes.map((node, index) => {\n        const enter = () => {\n          hoverIndex = index;\n          wake();\n        };\n        const leave = () => {\n          if (hoverIndex === index) hoverIndex = -1;\n        };\n        node.addEventListener(\"pointerenter\", enter);\n        node.addEventListener(\"pointerleave\", leave);\n        return { node, enter, leave };\n      });\n\n      const remeasure = () => {\n        measure();\n        wake();\n      };\n      const observer =\n        typeof ResizeObserver === \"undefined\"\n          ? null\n          : new ResizeObserver(remeasure);\n      observer?.observe(root);\n      window.addEventListener(\"resize\", remeasure);\n      void document.fonts?.ready.then(remeasure).catch(() => undefined);\n\n      const themeObserver =\n        typeof MutationObserver === \"undefined\"\n          ? null\n          : new MutationObserver(() => {\n              field.setColors(readColors(root, overridesRef.current));\n              wake();\n            });\n      themeObserver?.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"data-theme\", \"class\"],\n      });\n\n      return () => {\n        for (const control of controls) control.stop();\n        openTargetRef.current = () => {};\n        for (const { node, enter, leave } of hoverHandlers) {\n          node.removeEventListener(\"pointerenter\", enter);\n          node.removeEventListener(\"pointerleave\", leave);\n        }\n        window.removeEventListener(\"resize\", remeasure);\n        observer?.disconnect();\n        themeObserver?.disconnect();\n        gate.dispose();\n        cancelAnimationFrame(raf);\n        field.dispose();\n        fieldRef.current = null;\n        wakeRef.current = null;\n        measureRef.current = null;\n        canvas.style.width = \"\";\n        canvas.style.height = \"\";\n        for (const node of actionRefs.current) {\n          if (!node) continue;\n          node.style.transform = \"\";\n          node.style.opacity = \"\";\n        }\n      };\n    }, [isFluid, actions.length, layout, direction, generation]);\n\n    useFieldRetune(\n      fieldRef,\n      () => (rootRef.current ? readColors(rootRef.current, overrides) : null),\n      overrides,\n      wakeRef,\n    );\n\n    /* gap moves every slot, so the field has to re-measure, and a parked loop\n     * needs waking or the new spacing waits for the pointer. */\n    // biome-ignore lint/correctness/useExhaustiveDependencies: `gap` is not read here, the effect closure reads it live\n    React.useEffect(() => {\n      measureRef.current?.();\n      wakeRef.current?.();\n    }, [gap]);\n\n    // Drive the field toggle from the open state.\n    const previousOpen = React.useRef(open);\n    React.useLayoutEffect(() => {\n      const was = previousOpen.current;\n      previousOpen.current = open;\n      if (isFluid && was !== open) openTargetRef.current(open);\n    }, [open, isFluid]);\n\n    // Hide the actions from AT and the tab order while closed, and move focus.\n    React.useEffect(() => {\n      const nodes = actionRefs.current;\n      for (const node of nodes) {\n        if (!node) continue;\n        if (open) node.removeAttribute(\"inert\");\n        else node.setAttribute(\"inert\", \"\");\n      }\n      if (open) {\n        const first = nodes[0]?.querySelector<HTMLElement>(\n          \"a, button, [tabindex]\",\n        );\n        first?.focus();\n      }\n    }, [open]);\n\n    const onRootKeyDown = (event: React.KeyboardEvent) => {\n      if (event.key === \"Escape\" && openRef.current) {\n        event.stopPropagation();\n        setOpen(false);\n        triggerRef.current?.focus();\n      }\n    };\n\n    const runAction = (action: SulaFabAction) => {\n      action.onClick?.();\n      setOpen(false);\n    };\n\n    return (\n      // biome-ignore lint/a11y/noStaticElementInteractions: the root is a plain container; the keydown only closes the menu on Escape, while the real controls are the trigger button and the action buttons inside it\n      <div\n        ref={(node) => {\n          rootRef.current = node;\n          if (typeof forwardedRef === \"function\") forwardedRef(node);\n          else if (forwardedRef) forwardedRef.current = node;\n        }}\n        data-fluid={isFluid ? \"on\" : \"off\"}\n        data-open={open || undefined}\n        onKeyDown={onRootKeyDown}\n        className={cn(\"relative inline-flex\", className)}\n        {...props}\n      >\n        {keepCanvas ? (\n          <div\n            ref={stageRef}\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute overflow-hidden\",\n              !isFluid && \"hidden\",\n            )}\n          >\n            <canvas ref={canvasRef} />\n          </div>\n        ) : null}\n\n        {actions.map((action, index) => {\n          const offset = slotOffsets[index] ?? { x: 0, y: 0 };\n          const restTransform = `translate(-50%, -50%) translate(${\n            open ? offset.x : 0\n          }px, ${open ? offset.y : 0}px)`;\n          const domStyle: React.CSSProperties | undefined = isFluid\n            ? undefined\n            : {\n                transform: restTransform,\n                opacity: open ? 1 : 0,\n              };\n          const Tag = action.href ? \"a\" : \"button\";\n          return (\n            <div\n              key={action.label}\n              ref={(node) => {\n                actionRefs.current[index] = node;\n              }}\n              style={domStyle}\n              className={cn(\n                \"absolute top-1/2 left-1/2 z-10\",\n                !isFluid &&\n                  \"transition-[transform,opacity] duration-slow ease-spring motion-reduce:transition-none\",\n                !isFluid && !open && \"pointer-events-none\",\n              )}\n            >\n              <Tag\n                type={action.href ? undefined : \"button\"}\n                href={action.href}\n                aria-label={action.label}\n                onClick={() => runAction(action)}\n                className={cn(\n                  \"peer grid size-11 place-items-center rounded-full outline-none\",\n                  \"border border-border bg-surface/80 text-ink shadow-raised backdrop-blur-md\",\n                  \"transition-tint duration-fast ease-soft hover:text-accent\",\n                  \"focus-visible:ring-focus\",\n                )}\n              >\n                <span aria-hidden=\"true\" className=\"inline-flex\">\n                  {action.icon}\n                </span>\n              </Tag>\n              <span\n                aria-hidden=\"true\"\n                data-tooltip-position={resolvedTooltipPosition}\n                className={cn(\n                  \"pointer-events-none absolute\",\n                  resolvedTooltipPosition === \"left\" &&\n                    \"top-1/2 right-full mr-3 -translate-y-1/2\",\n                  resolvedTooltipPosition === \"right\" &&\n                    \"top-1/2 left-full ml-3 -translate-y-1/2\",\n                  resolvedTooltipPosition === \"top\" &&\n                    \"bottom-full left-1/2 mb-3 -translate-x-1/2\",\n                  \"rounded-md border border-border bg-sunken px-2 py-1 text-xs whitespace-nowrap text-on-sunken\",\n                  \"opacity-0 transition-opacity duration-fast\",\n                  \"peer-hover:opacity-100 peer-focus-visible:opacity-100\",\n                )}\n              >\n                {action.label}\n              </span>\n            </div>\n          );\n        })}\n\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          aria-label={label}\n          aria-expanded={open}\n          aria-haspopup=\"menu\"\n          onClick={() => setOpen(!open)}\n          className={cn(\n            \"relative z-20 grid size-14 place-items-center rounded-full outline-none\",\n            \"border border-border bg-surface text-ink shadow-raised\",\n            \"transition-transform duration-fast ease-soft focus-visible:ring-focus\",\n            \"data-[open=true]:rotate-45\",\n          )}\n          data-open={open || undefined}\n        >\n          <span aria-hidden=\"true\" className=\"inline-flex\">\n            {icon}\n          </span>\n        </button>\n      </div>\n    );\n  },\n);\nSulaFab.displayName = \"SulaFab\";\n"
    }
  ]
}