{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sula-nav",
  "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": "nav-geometry.ts",
      "target": "components/ui/nav-geometry.ts",
      "type": "registry:ui",
      "content": "import {\n  type Blob,\n  BRIDGE_REACH,\n  NECK_MIN,\n  type Neck,\n} from \"./geometry\";\nimport {\n  c1Settle,\n  clamp01,\n  easeOutCubic,\n  mix,\n  smoother,\n  smoothstep,\n} from \"./curves\";\n\n/** The drop separates from the edge across this window of the fall, while its\n * neck is still short, then falls the rest of the way free. Pinching early is\n * what keeps a long thread from ever spanning the gap. */\nconst SEPARATE_START = 0.5;\nconst SEPARATE_END = 0.9;\n/** The leftover top mass has fully recoiled into the edge by here in the retract. */\nconst EDGE_HIDE_START = 0.7;\n\nexport interface LoadShape {\n  bar: Blob;\n  extras: Blob[];\n  necks: Neck[];\n}\n\n/** A small, frame-rate-independent deformation that makes moving blobs yield\n * like fluid while returning to the exact authored shape at both endpoints. */\nexport function flowStretch(\n  blob: Blob,\n  progress: number,\n  vertical: boolean,\n): Blob {\n  const wave = Math.sin(Math.PI * clamp01(progress)) ** 2;\n  if (wave <= 0.000001) return blob;\n  const stretch = wave * 0.055;\n  const along = 1 + stretch;\n  const across = 1 - stretch * 0.4;\n  const hw = blob.hw * (vertical ? across : along);\n  const hh = blob.hh * (vertical ? along : across);\n  return { ...blob, hw, hh, r: Math.min(blob.r, hw, hh) };\n}\n\n/**\n * The bar is drawn down out of the top edge as a teardrop. A small bulb gathers\n * at the edge, the drop stretches away on a short neck, and it separates early in\n * the fall: the neck pinches off while it is still short and the top bulb recoils\n * into the edge, so the drop finishes falling free rather than trailing a thread.\n * `t` may pass 1: a spring's overshoot carries the bar past its rest line so it\n * lands with a dip and a soft second bob instead of decelerating into a wall.\n */\nexport function loadPhase(\n  barRest: Blob,\n  t: number,\n  edgeY: number,\n  retractT: number = 1,\n): LoadShape {\n  const p = clamp01(t);\n  const retract = clamp01(retractT);\n  const cx = barRest.cx;\n\n  const gather = smoothstep(0, 0.14, p);\n  const startCy = edgeY + barRest.hh * 0.55;\n  /* One continuous fall. The eased launch reaches the rest line at t=1 already\n   * moving (slope 1), so the spring's overshoot past 1 flows straight on past the\n   * line and back as one motion, instead of the bar easing to a dead stop at the\n   * line and only then starting a separate dip. `t` is the raw spring value and\n   * carries the overshoot; f is its delayed, unit-landing fall time. */\n  const fall = c1Settle(t, 0.1);\n  const cy = mix(startCy, barRest.cy, fall);\n  const hw = barRest.hw * mix(0.3, 1, smoothstep(0.3, 0.85, p));\n  const hh = barRest.hh * mix(0.42, 1, smoothstep(0.2, 0.8, p));\n  const bar: Blob = { cx, cy, hw, hh, r: Math.min(hw, hh) };\n\n  if (retract >= 1) return { bar, extras: [], necks: [] };\n\n  /* The drop lets go of the edge early in its fall, while the neck is still\n   * short, then falls free; the leftover top mass recoils into the edge as it\n   * separates. So nothing ever spans the gap as a long thread. Recoil is driven\n   * by the fall itself, and the retract only mops up any remainder. */\n  const separate = smoothstep(SEPARATE_START, SEPARATE_END, p);\n  const recoil = Math.max(separate, smoothstep(EDGE_HIDE_START, 1, retract));\n\n  /* A wide, shallow fluid lens at the edge that necks down into the bar, not a\n   * bead: the width is what reads as a gooey surface being pulled, and it drains\n   * to nothing as the drop separates. */\n  const swell = mix(0.78, 1, gather);\n  const bulbHw = barRest.hw * mix(0.52, 0, recoil) * swell;\n  const bulbHh = barRest.hh * mix(0.92, 0, recoil) * swell;\n  const reservoir: Blob = {\n    cx,\n    cy: edgeY + bulbHh * 0.5,\n    hw: bulbHw,\n    hh: bulbHh,\n    r: Math.min(bulbHw, bulbHh),\n  };\n\n  /* A fat neck, so the smooth-min bends the surface into a concave funnel with\n   * real surface tension instead of a thread. It thins and its strength fades as\n   * the drop separates, so the fat column necks in the middle and pinches. */\n  const barTop = cy - hh;\n  const tetherRadius = barRest.hh * mix(1.15, 0.22, separate);\n  const tetherStrength = 1 - separate;\n  const necks: Neck[] =\n    barTop > edgeY + 2 && tetherStrength > 0.001\n      ? [\n          {\n            ax: cx,\n            ay: edgeY,\n            bx: cx,\n            by: barTop,\n            r: tetherRadius,\n            strength: tetherStrength,\n          },\n        ]\n      : [];\n\n  const extras = bulbHw > 0.001 && bulbHh > 0.001 ? [reservoir] : [];\n  return { bar, extras, necks };\n}\n\n/** The side first redistributes mass into the bar's shoulder without travelling.\n * A longer swell reads as surface tension: the pill clings and gathers before it\n * lets go, rather than shooting straight out. */\nconst SIDE_SWELL_END = 0.36;\n/** Travel overlaps the end of the swell so the shoulder pours outward. */\nconst SIDE_TRAVEL_START = 0.3;\n/** The side is already home when its tether pinches free. */\nconst SIDE_PINCH_START = 0.76;\nconst SIDE_PINCH_END = 0.97;\n\n/** Past this much free length, in CSS px, a neck has stopped being surface\n * tension and started being a thread, so it thins; by the break it is gone.\n * This is what lets one part travel much further than its neighbours: a pill\n * sitting next to the bar never stretches this far and keeps the travel-driven\n * pinch above, while a satellite bound for a corner separates near the body it\n * came from and covers the rest of the distance free. Without it, a part that\n * flies 500px out drags a fat capsule behind it and the bar reads as one slab\n * spanning the whole viewport. */\nconst NECK_STRETCH_FREE = 0;\nconst NECK_STRETCH_BREAK = 104;\n\n/** How much of the bridge's remaining slack the settle wobble is allowed to\n * spend. The rest is margin: the merge radius breathes with velocity, so a\n * bounce that lands exactly on the reach would still flicker the neck. */\nconst BOUNCE_OF_SLACK = 0.75;\n\n/**\n * A side emerges the way a drop separates from a larger one. At rest it is fully\n * absorbed inside the bar's end, so nothing pokes out and the closed nav has clean\n * edges. As it opens it first swells out past the end while still merged (the end\n * bulges), then travels out trailing a neck that thins until it snaps. Melting it\n * back runs the same path in reverse, ending flush inside the bar. It never pops.\n *\n * The neck pinches on whichever comes first: the end of the travel, or the neck\n * stretching past what surface tension holds. A neighbouring pill lands still\n * attached and lets go on arrival; a satellite bound for a corner lets go early\n * and flies the rest of the way as a separate field.\n */\nexport function revealSide(\n  bar: Blob,\n  rest: Blob,\n  t: number,\n  k: number,\n): { blob: Blob; neck: Neck | null } {\n  const p = clamp01(t);\n  const dir = Math.sign(rest.cx - bar.cx) || 1;\n  const barEnd = bar.cx + bar.hw * dir;\n\n  const swell = smoothstep(0, SIDE_SWELL_END, p);\n  /* Travel reaches the rest line at t=1 still moving (slope 1), so an underdamped\n   * SIDE_SPRING carries a small settle wobble past the line and back instead of the\n   * pill landing hard. Raw t, not clamped p, so the overshoot is not flattened. */\n  const travel = c1Settle(t, SIDE_TRAVEL_START);\n\n  const scale = mix(0.5, 1, swell);\n  const hw = rest.hw * scale;\n  const hh = rest.hh * scale;\n\n  const startCx = barEnd - dir * rest.hw * 0.62;\n  const rawCx = mix(startCx, rest.cx, travel);\n\n  const rawInner = rawCx - dir * hw;\n  const stretch = Math.abs(rawInner - barEnd);\n  const pinch = Math.max(\n    smoothstep(SIDE_PINCH_START, SIDE_PINCH_END, p),\n    smoothstep(NECK_STRETCH_FREE, NECK_STRETCH_BREAK, stretch),\n  );\n\n  /* The spring's outer turn must not tear the standing bridge: at the turning\n   * point velocity hits zero, so the live merge radius relaxes too, and an\n   * overshoot past the bridge's reach disconnects the body for one frame and\n   * reconnects it on the rebound.\n   *\n   * But how far the pill may bounce is not a constant, it is whatever slack the\n   * bridge has left. A flat cap spent the same tiny budget on a pill sitting\n   * against the bar and on one thrown at a corner, so the throw landed dead on\n   * its rest line: the eye reads that as the spring hitting a wall, not as an\n   * underdamped body settling. A satellite that comes to rest beyond the reach\n   * has no bridge to protect at all, and is free to overshoot as far as the\n   * spring carries it. */\n  const restGap = Math.abs(rest.cx - bar.cx) - rest.hw - bar.hw;\n  const slack = k * BRIDGE_REACH - restGap;\n  const cx =\n    slack <= 0\n      ? rawCx\n      : dir > 0\n        ? Math.min(rawCx, rest.cx + slack * BOUNCE_OF_SLACK)\n        : Math.max(rawCx, rest.cx - slack * BOUNCE_OF_SLACK);\n  const cy = mix(bar.cy, rest.cy, travel);\n  const blob: Blob = { cx, cy, hw, hh, r: Math.min(hw, hh) };\n\n  if (travel <= 0) return { blob, neck: null };\n  if (pinch >= 1) return { blob, neck: null };\n  const inner = cx - dir * hw;\n  const tail = 1 - pinch;\n  const r = rest.hh * 0.86 * tail;\n  const strength = tail * tail;\n  if (r < 0.2 || strength < 0.0001) return { blob, neck: null };\n  const neck: Neck = {\n    ax: barEnd,\n    ay: bar.cy,\n    bx: inner,\n    by: cy,\n    r,\n    strength,\n  };\n  return { blob, neck };\n}\n\n/** Leading and trailing start merged inside the bar and spring out, each trailing a neck. */\nexport function revealPhase(\n  bar: Blob,\n  leadRest: Blob,\n  trailRest: Blob,\n  t: number,\n  k: number,\n): { lead: Blob; trail: Blob; necks: Neck[] } {\n  const lead = revealSide(bar, leadRest, t, k);\n  const trail = revealSide(bar, trailRest, t, k);\n  const necks = [lead.neck, trail.neck].filter(\n    (neck): neck is Neck => neck !== null,\n  );\n  return { lead: lead.blob, trail: trail.blob, necks };\n}\n\n/** The panel deepens first and only then spreads, so it reads as the body being\n * pulled down and then letting go sideways, not as a box scaling up. */\nconst SWELL_SPREAD_START = 0.16;\n/** How far into the droplet the panel's top edge starts, as a fraction of the\n * droplet's half-height. Overlapping means the two are one surface from the\n * first frame: the panel is never a second shape that flies in and docks. */\nconst SWELL_ROOT = -0.35;\n\n/**\n * The menu panel grows out of the collapsed nav's own body. Where `revealSide`\n * splits a droplet off and sends it away, this is the same material moving the\n * other way: the body swells downward and stretches open, staying attached the\n * whole time. Its top edge is pinned inside the droplet it came from, so it\n * cannot read as a drawer arriving from somewhere else; only the bottom edge\n * travels. Closing runs the identical path in reverse and ends flush inside the\n * droplet, at which point nothing of it is left to see.\n *\n * `t` carries the spring's overshoot past 1: the bottom edge dips past the rest\n * line and comes back, which is the panel landing with weight.\n */\nexport function swellPanel(\n  source: Blob,\n  rest: Blob,\n  t: number,\n): { blob: Blob; neck: Neck | null } {\n  const p = clamp01(t);\n  const deep = c1Settle(t, 0);\n  const wide = smoothstep(SWELL_SPREAD_START, 1, p);\n\n  const top = source.cy + source.hh * SWELL_ROOT;\n  const bottom = mix(top, rest.cy + rest.hh, deep);\n  const hh = Math.max((bottom - top) / 2, 0.5);\n  const rootHw = mix(\n    0.5,\n    source.hw * 0.72,\n    smoothstep(0, SWELL_SPREAD_START, p),\n  );\n  const hw = Math.max(mix(rootHw, rest.hw, wide), 0.5);\n  const cx = mix(source.cx, rest.cx, wide);\n  const blob: Blob = {\n    cx,\n    cy: top + hh,\n    hw,\n    hh,\n    r: Math.min(rest.r, hw, hh),\n  };\n\n  if (deep <= 0.001) return { blob, neck: null };\n  /* A fat, short column between the droplet and the panel's shoulder. Its\n   * strength melts through the closing tail so the resting droplet never\n   * inherits a one-frame swollen outline. */\n  const r = Math.max(mix(source.hh * 0.9, source.hw * 0.62, wide), NECK_MIN);\n  const neck: Neck = {\n    ax: source.cx,\n    ay: source.cy,\n    bx: cx,\n    by: Math.min(top + hh, top + source.hh),\n    r,\n    strength: smoothstep(0.02, 0.28, p),\n  };\n  return { blob, neck };\n}\n\n/** The panel's own contents arrive only once there is material to hold them.\n * Late on purpose: the row nearest the bottom edge is the last thing the swell\n * reaches, and a label lit before the glass is under it floats in mid air. */\nexport function swellFade(t: number): number {\n  return smoother(smoothstep(0.62, 0.96, clamp01(t)));\n}\n\nexport type SwitchRole = \"hide\" | \"show\" | \"keep\";\n\n/** The collapsing pill's whole morph fits inside this fraction of the switch. */\nconst HIDE_SPAN = 0.62;\n/** The expanding pill holds until this fraction, then fills. */\nconst SHOW_LAG = 0.24;\n\nconst roleSpan = (t: number, role: SwitchRole): number => {\n  const x = clamp01(t);\n  if (role === \"hide\") return clamp01(x / HIDE_SPAN);\n  if (role === \"show\") return clamp01((x - SHOW_LAG) / (1 - SHOW_LAG));\n  return x;\n};\n\n/**\n * A draining pill leads the morph and a filling pill lags it, so the two are\n * never wide at the same moment and the row cannot fuse into one ballooning\n * slab mid-switch. The outgoing bar collapses on a fast ease-out so it never\n * lingers wide; the incoming bar pours out on the gentler quintic. Each blob\n * still moves monotonically between its endpoints.\n */\nexport function switchProgress(t: number, role: SwitchRole): number {\n  const s = roleSpan(t, role);\n  return role === \"hide\" ? easeOutCubic(s) : smoother(s);\n}\n\n/**\n * Content is swapped in the DOM the instant the view changes, so each changing\n * part already holds its target label. A monotonic fade-in materialises that new\n * label as the part reshapes; the old 1 to 0 to 1 dip flashed already-correct\n * content for nothing. Width staggers per role, but the fade stays on the gentle\n * quintic so the label never snaps in. Unchanged parts stay lit.\n */\nexport function switchFade(t: number, role: SwitchRole): number {\n  return role === \"keep\" ? 1 : smoother(roleSpan(t, role));\n}\n"
    },
    {
      "path": "sula-nav.tsx",
      "target": "components/ui/sula-nav.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  bridgeNecks,\n  measureRestBlobs,\n  morphBlob,\n  type Neck,\n  packHover,\n  packUniforms,\n  restDiffers,\n} from \"./geometry\";\nimport { createPauseGate } from \"./pause\";\nimport { useContextRecovery } from \"./recovery\";\nimport { useFieldRetune } from \"./retune\";\nimport { clamp01, smoother, smoothstep } from \"./curves\";\nimport { createEnergyTracker } from \"./energy\";\nimport {\n  barSpring,\n  dripRetract,\n  menuRetract,\n  menuSwell,\n  type SulaMotionSpec,\n  sideSpring,\n  switchSpring,\n  textFade,\n} from \"./springs\";\nimport {\n  flowStretch,\n  loadPhase,\n  revealSide,\n  type SwitchRole,\n  swellFade,\n  swellPanel,\n  switchFade,\n  switchProgress,\n} from \"./nav-geometry\";\n\n/** A section-indicator tab shown inside an expanded view. */\nexport interface SulaNavItem {\n  href: string;\n  label: string;\n  icon?: React.ReactNode;\n  /** On the tab itself, so a dense route set can drop its secondary entries at a\n   * breakpoint rather than overflowing the bar. */\n  className?: string;\n}\n\n/** A navigable view: collapsed to an icon pill, expanded to a bar of its items. */\nexport interface SulaNavView {\n  href: string;\n  label: string;\n  icon: React.ReactNode;\n  items?: SulaNavItem[];\n}\n\n/**\n * A field that separates off the nav's body and settles in a corner: a control\n * that belongs to the nav but does not belong in the middle of it, like a search\n * trigger or a theme control.\n *\n * It is not a second component. It is the same body, split. One renderer paints\n * the whole bar, so a satellite is a disjoint field of the nav's own material\n * rather than a widget parked beside it, and the separation is what the reveal\n * animates. Peer components crammed into the bar would put several liquid fields\n * in one region, competing for the same attention; a satellite adds none.\n */\nexport interface SulaNavSatellite {\n  id: string;\n  /** Which corner it flows out to. Defaults to the right. */\n  align?: \"left\" | \"right\";\n  /** Names the group for assistive tech, since the contents are yours. */\n  label: string;\n  children: React.ReactNode;\n}\n\n/** The width at which item labels unfold; below it the tabs are icon-only. */\nexport type SulaNavLabelsFrom = \"sm\" | \"md\" | \"lg\" | \"xl\";\n\n/** The width below which the bar folds into a menu droplet. */\nexport type SulaNavCollapseBelow = \"sm\" | \"md\" | \"lg\";\n\nconst BREAKPOINT_PX: Record<SulaNavCollapseBelow, number> = {\n  sm: 640,\n  md: 768,\n  lg: 1024,\n};\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nfunction useBelow(breakpoint?: SulaNavCollapseBelow): boolean {\n  const [below, setBelow] = React.useState(false);\n  useIsomorphicLayoutEffect(() => {\n    if (!breakpoint) {\n      setBelow(false);\n      return;\n    }\n    const query = window.matchMedia(\n      `(max-width: ${BREAKPOINT_PX[breakpoint] - 0.02}px)`,\n    );\n    const sync = () => setBelow(query.matches);\n    sync();\n    query.addEventListener(\"change\", sync);\n    return () => query.removeEventListener(\"change\", sync);\n  }, [breakpoint]);\n  return below;\n}\n\nconst LABEL_COLLAPSE: Record<SulaNavLabelsFrom, string> = {\n  sm: \"max-sm:max-w-0 max-sm:overflow-hidden max-sm:opacity-0\",\n  md: \"max-md:max-w-0 max-md:overflow-hidden max-md:opacity-0\",\n  lg: \"max-lg:max-w-0 max-lg:overflow-hidden max-lg:opacity-0\",\n  xl: \"max-xl:max-w-0 max-xl:overflow-hidden max-xl:opacity-0\",\n};\n\nconst COLLAPSE_GUARD: Record<SulaNavCollapseBelow, string> = {\n  sm: \"max-sm:invisible\",\n  md: \"max-md:invisible\",\n  lg: \"max-lg:invisible\",\n};\n\nexport interface SulaNavProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  views: SulaNavView[];\n  /** href of the expanded view. Controlled. Defaults to the first view. */\n  activeView?: string;\n  onViewChange?: (href: string) => void;\n  /** href of the active section tab inside the expanded view. Controlled. */\n  activeItem?: string;\n  onNavigate?: (href: string) => void;\n  linkComponent?: React.ElementType;\n\n  brand?: React.ReactNode;\n  brandHref?: string;\n  brandLabel?: string;\n\n  /**\n   * Fields that split off the body and settle in a corner. Handing them here\n   * rather than rendering them beside the nav is what keeps them one material\n   * with it: they are measured, revealed and painted as parts of this field.\n   */\n  satellites?: SulaNavSatellite[];\n  /** Below this width the item labels fold away and the tabs are icons. */\n  labelsFrom?: SulaNavLabelsFrom;\n\n  /**\n   * Below this width the routes and the satellites fold into a single menu\n   * droplet, and the nav's own body swells open into a panel holding them. The\n   * nav becomes the menu: it is not a drawer arriving beside it, which would put\n   * a second liquid field in the nav's region. Nothing is hidden to make room,\n   * because hiding routes at the one width where the bar is most constrained is\n   * amputation, not responsiveness.\n   */\n  collapseBelow?: SulaNavCollapseBelow;\n  /** Accessible name of the menu droplet. */\n  menuLabel?: string;\n\n  /** Vertical nudge in px from the nav's anchor: positive is down, negative up. */\n  offset?: number;\n\n  ariaLabel?: string;\n  fluid?: boolean;\n  backdrop?: string;\n  tint?: string;\n  accentColor?: string;\n  /** 0 is flat matte glass, 1 is the full neon rim. Defaults to the theme. */\n  shine?: number;\n  mergeRadius?: number;\n  revealDelay?: number;\n  /**\n   * Holds the reveal back this many ms before it begins, for a page that opens\n   * behind a cover. The bar stays in the markup throughout, so nothing about the\n   * document changes: only the moment the liquid starts moving.\n   */\n  revealAfter?: number;\n  /**\n   * Whether the brand and the collapsed view pills are out. Defaults to true and\n   * reveals on mount. Drive it from scroll or focus to melt everything but the\n   * active bar back in; a hidden part is not tabbable.\n   */\n  sidesOpen?: boolean;\n}\n\n/**\n * The canvas is anchored to the nav, not to the viewport, so the bar falls out of\n * the canvas's own top edge. Kept compact so the drip forms just above the bar and\n * separates near it, rather than trailing a long thread down the whole card.\n */\nconst DROP_HEIGHT = 124;\n/** The shortest fall worth drawing. The drop's edge is never pushed closer to the\n * bar than this, so even a nav pinned tight to the top of the window pours rather\n * than simply materialising. */\nconst MIN_FALL = 50;\n/** How far above the visible edge the liquid is sourced. The gathering bulb is\n * the ugly part of a drip: on screen it reads as a stray pill parked at the top\n * of the window, with nothing holding it up. Sourced this far above the edge, the\n * bulb forms out of frame and only the fall, the neck and the pinch are seen,\n * which is the whole illusion: the material comes from somewhere off-page. */\nconst EDGE_ABOVE = 32;\n/** How far below the nav the canvas reaches, so a neck can hang past the pills. */\nconst CANVAS_SLACK = 64;\n/** How far below the nav the canvas reaches while the bar is collapsed, so the\n * swollen panel and its overshoot are inside the field. A constant, not the\n * panel's measured height: resizing the canvas mid-swell would re-init the field\n * in the middle of the animation. The panel caps its own height to stay under it. */\nconst PANEL_REACH = 560;\n/** The panel's corner radius. Its blob carries the same one, so the painted\n * material and the DOM box are the same shape. */\nconst PANEL_RADIUS = 28;\n/** Horizontal room past the nav, so a pill's cap and neck are not clipped. */\nconst SLACK_X = 104;\nconst MAX_DPR = 2;\n\n/** The bar's labels start fading in at this much of the drop, not on settle. */\nconst TEXT_AT = 0.75;\n/** The tether lets go the moment the bar first reaches its line, mid-settle,\n * instead of waiting out the spring's long formal tail. */\nconst DRIP_AT = 0.97;\n/** Peak surface undulation, in px of edge displacement. Kept below the authored\n * stretch/squash motion so long bars read as fluid glass, not a noisy coastline. */\nconst WOBBLE_MAX = 0.24;\n/** How far the row compresses toward its centre at the peak of a switch. */\nconst PULL_FRAC = 0.06;\n/** Merge floor: adjacent parts within reach hold a soft surface-tension waist at\n * rest, so the row reads as parts pulling toward each other rather than floating\n * apart. Kept low so it never fuses into one slab. */\nconst REST_MERGE = 0.32;\n/** Bridge-neck reach as a multiple of the merge radius. The switch tail and the\n * rest path must share it: a spring's `.finished` resolves about a second after\n * the visual settle, and only then does the rest path take over, so if it reached\n * for necks any differently the bridges would visibly recalculate a beat late. */\nconst NECK_REACH = 1.15;\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  return {\n    backdrop,\n    tint: resolveColor(overrides.tint ?? token(\"--usva-surface\")),\n    accent: resolveColor(overrides.accent ?? token(\"--usva-accent\")),\n    shine: overrides.shine ?? shineForBackdrop(backdrop),\n  };\n}\n\n/** `toCanvasSpace` gives every box a stadium radius, which is right for a pill and\n * wrong for a panel: it would round a tall box into a lozenge. */\nconst panelBlob = (rect: DOMRect, box: DOMRect): Blob => {\n  const hw = rect.width / 2;\n  const hh = rect.height / 2;\n  return {\n    cx: rect.left - box.left + hw,\n    cy: rect.top - box.top + hh,\n    hw,\n    hh,\n    r: Math.min(PANEL_RADIUS, hw, hh),\n  };\n};\n\nconst shift = (blob: Blob, rest: Blob): string =>\n  `translate3d(${blob.cx - rest.cx}px, ${blob.cy - rest.cy}px, 0)`;\n\n/** The pills and brand fade up once the sides are most of the way out. */\nconst labelFade = (t: number): number => {\n  const s = clamp01((t - 0.82) / 0.16);\n  return s * s * (3 - 2 * s);\n};\n\nexport const SulaNav = React.forwardRef<HTMLElement, SulaNavProps>(\n  (\n    {\n      views,\n      activeView,\n      onViewChange,\n      activeItem,\n      onNavigate,\n      linkComponent: Link = \"a\",\n      brand,\n      brandHref = \"/\",\n      brandLabel,\n      satellites,\n      labelsFrom = \"sm\",\n      collapseBelow,\n      menuLabel = \"Menu\",\n      offset = 0,\n      ariaLabel = \"Primary\",\n      fluid = true,\n      backdrop,\n      tint,\n      accentColor,\n      shine,\n      mergeRadius = 14,\n      revealDelay = 120,\n      revealAfter = 0,\n      sidesOpen = true,\n      className,\n      style,\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    const reduced = useReducedMotion();\n    const navRef = React.useRef<HTMLElement | null>(null);\n    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n    const stageRef = React.useRef<HTMLDivElement | null>(null);\n    const brandRef = React.useRef<HTMLDivElement | null>(null);\n    const viewRefs = React.useRef<Array<HTMLDivElement | null>>([]);\n    const itemRefs = React.useRef<Record<string, HTMLLIElement | null>>({});\n    const satRefs = React.useRef<Record<string, HTMLDivElement | null>>({});\n    const panelRef = React.useRef<HTMLDivElement | null>(null);\n    const panelBodyRef = React.useRef<HTMLDivElement | null>(null);\n\n    const collapsed = useBelow(collapseBelow);\n    const panelId = React.useId();\n    const [menuOpen, setMenuOpen] = React.useState(false);\n    const setMenuRef = React.useRef<(open: boolean) => void>(() => {});\n    React.useEffect(() => {\n      if (!collapsed) setMenuOpen(false);\n    }, [collapsed]);\n\n    const leftSats = React.useMemo(\n      () => (satellites ?? []).filter((s) => s.align === \"left\"),\n      [satellites],\n    );\n    const rightSats = React.useMemo(\n      () => (satellites ?? []).filter((s) => s.align !== \"left\"),\n      [satellites],\n    );\n    const hasSatellites = leftSats.length > 0 || rightSats.length > 0;\n    const leftKey = leftSats.map((s) => s.id).join(\" \");\n    const rightKey = rightSats.map((s) => s.id).join(\" \");\n\n    const { failed, generation, onContextLost, onContextReady } =\n      useContextRecovery(canvasRef);\n    const [mounted, setMounted] = React.useState(false);\n    useIsomorphicLayoutEffect(() => setMounted(true), []);\n\n    const isFluid = fluid && !reduced && !failed && mounted;\n    const fluidShell = fluid && !failed && (!mounted || !reduced);\n    const keepCanvas = fluid && (!mounted || !reduced);\n\n    const resolvedActiveView = activeView ?? views[0]?.href;\n    const activeViewIndex = Math.max(\n      0,\n      views.findIndex((view) => view.href === resolvedActiveView),\n    );\n    const activeItems = views[activeViewIndex]?.items ?? [];\n\n    const activeIndexRef = React.useRef(activeViewIndex);\n    activeIndexRef.current = activeViewIndex;\n    const leftCountRef = React.useRef(leftSats.length);\n    leftCountRef.current = collapsed ? 0 : leftSats.length;\n    const switchRef = React.useRef<(previous: number) => void>(() => {});\n    const setSidesRef = React.useRef<(open: boolean) => void>(() => {});\n    const sidesOpenRef = React.useRef(sidesOpen);\n    sidesOpenRef.current = sidesOpen;\n\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 mergeRadiusRef = React.useRef(mergeRadius);\n    mergeRadiusRef.current = mergeRadius;\n    const revealDelayRef = React.useRef(revealDelay);\n    revealDelayRef.current = revealDelay;\n    const fieldRef = React.useRef<ReturnType<typeof createField>>(null);\n    const wakeRef = React.useRef<(() => void) | null>(null);\n\n    const [indicator, setIndicator] = React.useState({\n      left: 0,\n      width: 0,\n      ready: false,\n    });\n\n    /* A tab the route set drops at this breakpoint measures zero wide, and an\n     * indicator pinned to it would read as the first tab being active. */\n    React.useLayoutEffect(() => {\n      const place = () => {\n        const el = activeItem ? itemRefs.current[activeItem] : null;\n        if (!el || el.offsetWidth === 0) {\n          setIndicator((current) => ({ ...current, ready: false }));\n          return;\n        }\n        setIndicator({\n          left: el.offsetLeft,\n          width: el.offsetWidth,\n          ready: true,\n        });\n      };\n      place();\n      window.addEventListener(\"resize\", place);\n      return () => window.removeEventListener(\"resize\", place);\n    }, [activeItem]);\n\n    /* Left to right, because bridgeNecks reads adjacency out of this order. */\n    // biome-ignore lint/correctness/useExhaustiveDependencies: `collapsed` is not read here, it decides which of these nodes exist. Its identity change is what re-inits the field on a breakpoint cross.\n    const collectParts = React.useCallback(\n      () =>\n        [\n          brandRef.current,\n          ...(leftKey ? leftKey.split(\" \") : []).map(\n            (id) => satRefs.current[id] ?? null,\n          ),\n          ...viewRefs.current,\n          ...(rightKey ? rightKey.split(\" \") : []).map(\n            (id) => satRefs.current[id] ?? null,\n          ),\n        ].filter((node): node is HTMLDivElement => node != null),\n      /* `collapsed` changes which nodes exist, so it must give a new identity:\n       * the field effect re-inits on it, and the row order it reads is the DOM\n       * order it now has. */\n      [leftKey, rightKey, collapsed],\n    );\n\n    useIsomorphicLayoutEffect(() => {\n      if (!isFluid) return;\n      const canvas = canvasRef.current;\n      const stage = stageRef.current;\n      const nav = navRef.current;\n      if (!canvas || !stage || !nav) return;\n\n      const field = createField({\n        canvas,\n        colors: readColors(nav, overridesRef.current),\n        onContextLost,\n      });\n      if (!field) {\n        onContextLost();\n        return;\n      }\n      fieldRef.current = field;\n      onContextReady();\n\n      let rest: Blob[] = [];\n      let panelRest: Blob | null = null;\n      /** Blob coordinates are relative to the stage box. A width change re-centres\n       * the nav, so the box shifts on screen and the two ends of a switch would be\n       * measured in different frames; the last box lets a switch reconcile them. */\n      let stageBox: DOMRect | null = null;\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      let hoverPoint = { x: 0, y: 0 };\n      let hoverTarget = { x: 0, y: 0 };\n      let hasHoverPoint = false;\n      const energy = createEnergyTracker();\n      const start = performance.now();\n\n      /* The ripple follows the hovered part's live blob; on leave the blob is\n       * kept so the ripple fades in place instead of teleporting to zero. */\n      const updateHover = (aligned: Array<Blob | undefined>) => {\n        const focus =\n          hoverIndex >= 0 ? (aligned[hoverIndex] ?? rest[hoverIndex]) : null;\n        hoverAmt += ((focus ? 1 : 0) - hoverAmt) * HOVER_EASE;\n        if (focus) {\n          hoverBlob = focus;\n          if (!hasHoverPoint) {\n            hoverPoint = { x: focus.cx, y: focus.cy };\n            hoverTarget = hoverPoint;\n            hasHoverPoint = true;\n          }\n        }\n        hoverPoint.x += (hoverTarget.x - hoverPoint.x) * HOVER_EASE;\n        hoverPoint.y += (hoverTarget.y - hoverPoint.y) * HOVER_EASE;\n      };\n\n      const bT = { value: 0 };\n      const dT = { value: 0 };\n      const sT = { value: 0 };\n      const swT = { value: 1 };\n      const mT = { value: 0 };\n      let pMenu = 0;\n      let switchFrom: Blob[] = [];\n      let switchTo: Blob[] = [];\n      let switchRoles: SwitchRole[] = [];\n      let switching = false;\n      let loaded = false;\n      let lastBlobs: Blob[] = [];\n\n      let pBar = 0;\n      let pSide = 0;\n      let pDrip = 0;\n      let pSwitch = 1;\n      const barText = { value: 0 };\n\n      let lastW = 0;\n      let lastH = 0;\n      const measure = (): boolean => {\n        const parts = collectParts();\n\n        const box = stage.getBoundingClientRect();\n        stageBox = box;\n        const width = Math.ceil(box.width);\n        const height = Math.ceil(box.height);\n        const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);\n        const sized = width !== lastW || height !== lastH || nextDpr !== dpr;\n\n        if (sized) {\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        const panelNode = panelRef.current;\n        const nextPanel = panelNode\n          ? panelBlob(panelNode.getBoundingClientRect(), box)\n          : null;\n        const panelChanged = restDiffers(\n          panelRest ? [panelRest] : [],\n          nextPanel ? [nextPanel] : [],\n        );\n        panelRest = nextPanel;\n\n        const next = measureRestBlobs(parts, box);\n        if (!sized && !panelChanged && !restDiffers(rest, next)) return false;\n        rest = next;\n        return true;\n      };\n\n      /* Parts run [left satellites, brand, views, right satellites], so the view\n       * block starts past whatever sits to its left. */\n      const brandOffset = () =>\n        leftCountRef.current + (brandRef.current ? 1 : 0);\n      const activePart = () => activeIndexRef.current + brandOffset();\n\n      const drawFrame = (blobs: Blob[], necks: Neck[], liveK: number) => {\n        if (disposed) return;\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_MAX * energy.value * energy.value,\n          alpha: 1,\n          hover:\n            hoverBlob && hoverAmt > 0.01\n              ? packHover(\n                  hoverBlob,\n                  hoverAmt * HOVER_WOBBLE,\n                  dpr,\n                  canvasH,\n                  hoverPoint,\n                )\n              : null,\n        });\n      };\n\n      /* The panel is not a part. It is the body itself, swollen, so it never\n       * joins the row: no index in a switch, no bridge from bridgeNecks, no\n       * hover slot. It carries the one neck that holds it to the droplet it grew\n       * out of, and that neck never pinches. */\n      const menuShape = (\n        source: Blob | undefined,\n      ): { blobs: Blob[]; necks: Neck[] } => {\n        const body = panelBodyRef.current;\n        if (!source || !panelRest || mT.value <= 0.0005) {\n          if (body) body.style.opacity = \"0\";\n          return { blobs: [], necks: [] };\n        }\n        const swell = swellPanel(source, panelRest, mT.value);\n        if (body) {\n          const fade = swellFade(mT.value);\n          body.style.opacity = `${fade}`;\n          body.style.transform = `translate3d(0, ${(1 - fade) * -10}px, 0)`;\n        }\n        return { blobs: [swell.blob], necks: swell.neck ? [swell.neck] : [] };\n      };\n\n      /* The liquid has to come out of an edge the viewer can actually see. The\n       * stage hangs DROP_HEIGHT above the bar to give the drip room, but a nav\n       * pinned near the top of the window puts most of that room *above* the\n       * window: the bulb gathers, necks and pinches offscreen, and the bar simply\n       * appears already landed. So the edge is whichever is lower, the stage's own\n       * top or the top of the window, and the drop always pours into view. */\n      const visibleEdge = (barRest: Blob): number => {\n        const clipped = stageBox ? Math.max(0, -stageBox.top) : 0;\n        const latest = Math.max(0, barRest.cy - barRest.hh - MIN_FALL);\n        return Math.max(0, Math.min(clipped - EDGE_ABOVE, latest));\n      };\n\n      const loadFrame = () => {\n        const parts = collectParts();\n        const barIndex = activePart();\n        const barRest = rest[barIndex];\n        if (!barRest) return;\n\n        const vBar = bT.value - pBar;\n        const vSide = sT.value - pSide;\n        const vDrip = dT.value - pDrip;\n        const vMenu = mT.value - pMenu;\n        pBar = bT.value;\n        pSide = sT.value;\n        pDrip = dT.value;\n        pMenu = mT.value;\n\n        const load = loadPhase(\n          barRest,\n          bT.value,\n          visibleEdge(barRest),\n          dT.value,\n        );\n        const barBlob = flowStretch(load.bar, bT.value, true);\n        /* The row in left-to-right part order (brand, active bar, side pills),\n         * so bridgeNecks can hold a neck between each adjacent pair. */\n        const rowBlobs: Blob[] = [];\n        const aligned: Array<Blob | undefined> = [];\n        const necks: Neck[] = [...load.necks];\n\n        const barNode = parts[barIndex];\n        if (barNode) {\n          barNode.style.transform = shift(load.bar, barRest);\n          barNode.style.opacity = `${barText.value}`;\n        }\n\n        for (let i = 0; i < parts.length; i++) {\n          if (i === barIndex) {\n            rowBlobs.push(barBlob);\n            aligned[i] = barBlob;\n            continue;\n          }\n          const restI = rest[i];\n          if (!restI) continue;\n          const side = revealSide(\n            load.bar,\n            restI,\n            sT.value,\n            mergeRadiusRef.current,\n          );\n          const sideBlob = flowStretch(side.blob, sT.value, false);\n          rowBlobs.push(sideBlob);\n          aligned[i] = sideBlob;\n          if (side.neck) necks.push(side.neck);\n          const node = parts[i];\n          if (node) {\n            node.style.transform = shift(side.blob, restI);\n            node.style.opacity = `${labelFade(sT.value)}`;\n          }\n        }\n\n        /* Energy is measured velocity, not a spring's finished promise: a\n         * spring reports done long after visible rest, so releasing k and\n         * wobble on it reads as a phantom width shift a second late. */\n        const speed =\n          Math.abs(vBar) + Math.abs(vSide) + Math.abs(vDrip) + Math.abs(vMenu);\n        energy.bump(speed);\n        const liveK = mergeRadiusRef.current;\n        /* Rest tension necks between adjacent row parts. The reach uses a\n         * slightly wider k so parts at the default flex gap still neck at rest.\n         * They persist because the last drawn frame keeps them once the field\n         * parks. Transition necks come first so they win the MAX_NECKS budget.\n         * Scaled by how far the sides are out (sT): when they melt in, the merge\n         * fades to zero so a lone bar shows no orphan pinch toward a hidden part,\n         * and adjacency stays correct because the row order is unchanged. */\n        const restMerge = REST_MERGE * smoothstep(0.5, 0.95, sT.value);\n        const restNecks =\n          restMerge > 0.001\n            ? bridgeNecks(rowBlobs, liveK * NECK_REACH, restMerge)\n            : [];\n        const menu = menuShape(barBlob);\n        const blobs: Blob[] = [...rowBlobs, ...load.extras, ...menu.blobs];\n        lastBlobs = blobs;\n        updateHover(aligned);\n        drawFrame(blobs, [...necks, ...restNecks, ...menu.necks], liveK);\n      };\n\n      const switchFrame = () => {\n        const parts = collectParts();\n        const t = swT.value;\n        const posT = smoother(t);\n        /* The transition envelope is zero at both ends of the switch and peaks in\n         * the middle. Necks floor at REST_MERGE so the row keeps its rest surface\n         * tension, but the centroid pull rides the raw envelope so it releases\n         * fully at t=1; a floored pull would leave the row compressed at rest and\n         * jump the moment loadFrame takes over. */\n        const transition = Math.sin(Math.PI * clamp01(t));\n        const merge = Math.max(REST_MERGE, transition);\n        const vSwitch = t - pSwitch;\n        pSwitch = t;\n        const centroid =\n          switchTo.reduce((sum, b) => sum + b.cx, 0) /\n          Math.max(1, switchTo.length);\n\n        const blobs: Blob[] = [];\n        const aligned: Array<Blob | undefined> = [];\n        for (let i = 0; i < parts.length; i++) {\n          const from = switchFrom[i];\n          const to = switchTo[i];\n          if (!from || !to) continue;\n          const role = switchRoles[i] ?? \"keep\";\n          const base = morphBlob(from, to, posT, switchProgress(t, role));\n          const pulled: Blob = {\n            ...base,\n            cx: base.cx + (centroid - to.cx) * PULL_FRAC * transition,\n          };\n          blobs.push(pulled);\n          aligned[i] = pulled;\n          const node = parts[i];\n          if (node) {\n            node.style.transform = shift(pulled, to);\n            node.style.opacity = `${switchFade(t, role)}`;\n          }\n        }\n\n        energy.bump(Math.abs(vSwitch));\n        const liveK = mergeRadiusRef.current;\n        /* Same reach as the rest path, so when the switch spring's `.finished`\n         * hands off to loadFrame a beat after settle the necks do not resize. */\n        const necks = bridgeNecks(blobs, liveK * NECK_REACH, merge);\n        const menu = menuShape(aligned[activePart()]);\n        lastBlobs = blobs;\n        updateHover(aligned);\n        drawFrame([...blobs, ...menu.blobs], [...necks, ...menu.necks], liveK);\n      };\n\n      let textStarted = false;\n      let dripStarted = false;\n      const tick = () => {\n        if (switching) switchFrame();\n        else loadFrame();\n\n        if (!textStarted && bT.value >= TEXT_AT) {\n          textStarted = true;\n          run(barText, 1, textFade);\n        }\n        if (!dripStarted && bT.value >= DRIP_AT) {\n          dripStarted = true;\n          run(dT, 1, dripRetract);\n        }\n        if (\n          running === 0 &&\n          energy.parked() &&\n          hoverIndex < 0 &&\n          hoverAmt < 0.02\n        ) {\n          switching = false;\n          return;\n        }\n        raf = requestAnimationFrame(tick);\n      };\n      /* Resuming re-enters the same tick against live spring values, so a nav\n       * that scrolled away mid-reveal picks its drop up where it left it rather\n       * than dropping a second time. */\n      const wake = () => {\n        if (!gate.awake()) return;\n        cancelAnimationFrame(raf);\n        raf = requestAnimationFrame(tick);\n      };\n      wakeRef.current = wake;\n      const gate = createPauseGate({\n        target: nav,\n        onPause: () => cancelAnimationFrame(raf),\n        onResume: () => wake(),\n      });\n\n      const controls = new Set<ReturnType<typeof animate>>();\n      const run = (\n        target: { value: number },\n        to: number | [number, number],\n        opts: SulaMotionSpec,\n      ) => {\n        running += 1;\n        const control = animate(target, { value: to }, opts);\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 disposed = false;\n      let revealComplete = false;\n      let revealTimer = 0;\n      let sideControl: ReturnType<typeof animate> | null = null;\n      let swControl: ReturnType<typeof animate> | null = null;\n      let switchId = 0;\n      let measureQueued = false;\n\n      const setSides = (open: boolean) => {\n        if (!revealComplete) return;\n        const to = open ? 1 : 0;\n        if (sT.value === to) return;\n        sideControl?.stop();\n        sideControl = run(sT, to, sideSpring);\n      };\n      setSidesRef.current = setSides;\n\n      let menuControl: ReturnType<typeof animate> | null = null;\n      const setMenu = (open: boolean) => {\n        const to = open ? 1 : 0;\n        if (mT.value === to) return;\n        menuControl?.stop();\n        /* Explicit keyframes from the live value: motion caches one visual\n         * element per subject, so a reversal mid-swell must be told where it is\n         * starting from or it snaps to 0 and re-runs. */\n        menuControl = run(mT, [mT.value, to], open ? menuSwell : menuRetract);\n      };\n      setMenuRef.current = setMenu;\n\n      const doSwitch = (previousIndex: number) => {\n        if (!loaded) return;\n        const currentSwitchId = ++switchId;\n        const off = brandOffset();\n        const oldBox = stageBox;\n        const from =\n          switching && lastBlobs.length === rest.length ? lastBlobs : rest;\n        measure();\n        const dx = oldBox && stageBox ? oldBox.left - stageBox.left : 0;\n        const dy = oldBox && stageBox ? oldBox.top - stageBox.top : 0;\n        switchFrom = from.map((b) => ({ ...b, cx: b.cx + dx, cy: b.cy + dy }));\n        switchTo = rest.map((b) => ({ ...b }));\n        switchRoles = rest.map((_, i) => {\n          const view = i - off;\n          if (view === activeIndexRef.current) return \"show\";\n          if (view === previousIndex) return \"hide\";\n          return \"keep\";\n        });\n        switching = true;\n        swT.value = 0;\n        pSwitch = 0;\n        swControl?.stop();\n        /* Motion caches one visual element per animated subject and reads the\n         * next \"from\" out of it, so a bare swT.value reset is invisible to it.\n         * The explicit [0, 1] keyframes are what make the second and every\n         * later switch animate instead of snapping. */\n        swControl = run(swT, [0, 1], switchSpring);\n        void swControl.finished.then(\n          () => {\n            if (disposed || currentSwitchId !== switchId) return;\n            switching = false;\n            if (measureQueued) {\n              measureQueued = false;\n              measure();\n            }\n            wake();\n          },\n          () => undefined,\n        );\n        switchFrame();\n      };\n      switchRef.current = doSwitch;\n\n      measure();\n      for (const node of collectParts()) node.style.opacity = \"0\";\n      let fontTimer = 0;\n      let startTimer = 0;\n      let revealed = false;\n      const beginReveal = () => {\n        if (disposed || revealed) return;\n        revealed = true;\n        window.clearTimeout(fontTimer);\n        measure();\n        const barControl = run(bT, 1, barSpring);\n        void barControl.finished\n          .then(() => {\n            if (disposed) return;\n            const handOff = () => {\n              if (disposed) return;\n              revealComplete = true;\n              loaded = true;\n              if (collectParts().length > 1) setSides(sidesOpenRef.current);\n            };\n            if (revealDelayRef.current > 0) {\n              revealTimer = window.setTimeout(handOff, revealDelayRef.current);\n            } else {\n              handOff();\n            }\n          })\n          .catch(() => undefined);\n        wake();\n      };\n      /* A late webfont swap re-measures wider text and twitches a bar that\n       * already looks settled, so the drop waits for the fonts, capped so a\n       * slow font file cannot hold the nav hostage. */\n      const waitForFonts = () => {\n        const fonts = document.fonts;\n        if (fonts && fonts.status !== \"loaded\") {\n          void fonts.ready.then(beginReveal).catch(beginReveal);\n          fontTimer = window.setTimeout(beginReveal, 400);\n        } else {\n          beginReveal();\n        }\n      };\n      if (revealAfter > 0) {\n        startTimer = window.setTimeout(waitForFonts, revealAfter);\n      } else {\n        waitForFonts();\n      }\n\n      /* Hovering a part wakes a broad wave at the pointer. The target updates on\n       * pointermove while updateHover eases the rendered point toward it. */\n      const hoverNodes = collectParts();\n      const hoverHandlers = hoverNodes.map((node, index) => {\n        const move = (event: PointerEvent) => {\n          if (!stageBox) return;\n          hoverTarget = {\n            x: event.clientX - stageBox.left,\n            y: event.clientY - stageBox.top,\n          };\n          if (!hasHoverPoint) {\n            hoverPoint = hoverTarget;\n            hasHoverPoint = true;\n          }\n          wake();\n        };\n        const enter = (event: PointerEvent) => {\n          hoverIndex = index;\n          move(event);\n        };\n        const leave = () => {\n          if (hoverIndex === index) hoverIndex = -1;\n        };\n        node.addEventListener(\"pointerenter\", enter);\n        node.addEventListener(\"pointermove\", move);\n        node.addEventListener(\"pointerleave\", leave);\n        return { node, enter, move, leave };\n      });\n\n      const remeasure = () => {\n        if (switching) {\n          measureQueued = true;\n          return;\n        }\n        if (measure()) wake();\n      };\n      const observer =\n        typeof ResizeObserver === \"undefined\"\n          ? null\n          : new ResizeObserver(remeasure);\n      observer?.observe(nav);\n      if (panelRef.current) observer?.observe(panelRef.current);\n\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(nav, overrides));\n              wake();\n            });\n      themeObserver?.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"data-theme\", \"class\"],\n      });\n\n      return () => {\n        disposed = true;\n        window.clearTimeout(revealTimer);\n        window.clearTimeout(fontTimer);\n        window.clearTimeout(startTimer);\n        for (const control of controls) control.stop();\n        switchRef.current = () => {};\n        setSidesRef.current = () => {};\n        setMenuRef.current = () => {};\n        if (panelBodyRef.current) {\n          panelBodyRef.current.style.opacity = \"\";\n          panelBodyRef.current.style.transform = \"\";\n        }\n        for (const { node, enter, move, leave } of hoverHandlers) {\n          node.removeEventListener(\"pointerenter\", enter);\n          node.removeEventListener(\"pointermove\", move);\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        for (const node of collectParts()) {\n          node.style.transform = \"\";\n          node.style.opacity = \"\";\n        }\n      };\n    }, [isFluid, revealAfter, collectParts, generation]);\n\n    useFieldRetune(\n      fieldRef,\n      () => (navRef.current ? readColors(navRef.current, overrides) : null),\n      overrides,\n      wakeRef,\n    );\n\n    /* The loop reads mergeRadius live, but a parked one is holding its last\n     * frame and would not show the new merge until the pointer arrived. */\n    // biome-ignore lint/correctness/useExhaustiveDependencies: `mergeRadius` is not read here, the loop reads it live\n    React.useEffect(() => {\n      wakeRef.current?.();\n    }, [mergeRadius]);\n\n    const previousViewIndex = React.useRef(activeViewIndex);\n    React.useLayoutEffect(() => {\n      const previous = previousViewIndex.current;\n      previousViewIndex.current = activeViewIndex;\n      if (isFluid && previous !== activeViewIndex) {\n        switchRef.current(previous);\n      }\n    }, [activeViewIndex, isFluid]);\n\n    React.useEffect(() => {\n      if (isFluid) setSidesRef.current(sidesOpen);\n    }, [sidesOpen, isFluid]);\n\n    React.useEffect(() => {\n      if (isFluid) setMenuRef.current(menuOpen);\n    }, [menuOpen, isFluid]);\n\n    React.useEffect(() => {\n      if (!menuOpen) return;\n      const onKey = (event: KeyboardEvent) => {\n        if (event.key === \"Escape\") setMenuOpen(false);\n      };\n      const onPointer = (event: PointerEvent) => {\n        const target = event.target as Node | null;\n        if (!target) return;\n        if (navRef.current?.contains(target)) return;\n        setMenuOpen(false);\n      };\n      document.addEventListener(\"keydown\", onKey);\n      document.addEventListener(\"pointerdown\", onPointer);\n      return () => {\n        document.removeEventListener(\"keydown\", onKey);\n        document.removeEventListener(\"pointerdown\", onPointer);\n      };\n    }, [menuOpen]);\n\n    // biome-ignore lint/correctness/useExhaustiveDependencies: brand/views/satellites gate which nodes exist\n    React.useEffect(() => {\n      const nodes = [\n        brandRef.current,\n        ...viewRefs.current.filter((_, i) => i !== activeViewIndex),\n        ...Object.values(satRefs.current),\n      ];\n      for (const node of nodes) {\n        if (!node) continue;\n        if (sidesOpen) node.removeAttribute(\"inert\");\n        else node.setAttribute(\"inert\", \"\");\n      }\n    }, [sidesOpen, activeViewIndex, brand, views, satellites]);\n\n    /* A closed panel is still laid out, because the field has to know the shape\n     * it is growing toward. Laid out but not reachable: inert, or the whole menu\n     * is in the tab order behind a bar that shows no sign of it. */\n    // biome-ignore lint/correctness/useExhaustiveDependencies: `collapsed` is what mounts the panel, so the attribute has to be set again on the render that creates it.\n    React.useEffect(() => {\n      const node = panelRef.current;\n      if (!node) return;\n      if (menuOpen) node.removeAttribute(\"inert\");\n      else node.setAttribute(\"inert\", \"\");\n    }, [menuOpen, collapsed]);\n\n    const part = cn(\n      \"relative rounded-full\",\n      !fluidShell &&\n        \"border border-border bg-surface/85 shadow-raised backdrop-blur-xl\",\n    );\n\n    const navStyle: React.CSSProperties = {\n      ...style,\n      ...(offset ? { transform: `translateY(${offset}px)` } : null),\n    };\n\n    const brandNode = brand ? (\n      <div\n        ref={brandRef}\n        className={cn(part, !sidesOpen && !fluidShell && \"hidden\")}\n      >\n        <Link\n          href={brandHref}\n          aria-label={brandLabel}\n          onClick={() => onNavigate?.(brandHref)}\n          className=\"flex min-h-11 items-center rounded-full px-4 text-sm font-medium text-ink outline-none focus-visible:ring-focus sm:px-5\"\n        >\n          {brand}\n        </Link>\n      </div>\n    ) : null;\n\n    const satelliteNode = (satellite: SulaNavSatellite) => (\n      // biome-ignore lint/a11y/useSemanticElements: a fieldset is for form controls; this is a named grouping of nav controls inside a landmark\n      <div\n        key={satellite.id}\n        ref={(node) => {\n          satRefs.current[satellite.id] = node;\n        }}\n        role=\"group\"\n        aria-label={satellite.label}\n        className={cn(part, !sidesOpen && !fluidShell && \"hidden\")}\n      >\n        {satellite.children}\n      </div>\n    );\n\n    const itemLink = (item: SulaNavItem, inPanel: boolean) => (\n      <Link\n        href={item.href}\n        aria-current={item.href === activeItem ? \"page\" : undefined}\n        onClick={() => {\n          onNavigate?.(item.href);\n          if (inPanel) setMenuOpen(false);\n        }}\n        className={cn(\n          \"flex min-h-11 items-center gap-2 rounded-full text-sm whitespace-nowrap outline-none\",\n          \"text-muted transition-tint duration-fast ease-soft hover:text-ink\",\n          \"aria-[current=page]:text-ink focus-visible:ring-focus\",\n          inPanel\n            ? \"w-full gap-3 px-4 aria-[current=page]:bg-ink/6\"\n            : \"px-3 sm:px-4\",\n        )}\n      >\n        {item.icon ? (\n          <span aria-hidden=\"true\" className=\"inline-flex shrink-0\">\n            {item.icon}\n          </span>\n        ) : null}\n        <span\n          className={cn(!inPanel && item.icon && LABEL_COLLAPSE[labelsFrom])}\n        >\n          {item.label}\n        </span>\n      </Link>\n    );\n\n    const menuDroplet = (\n      <button\n        type=\"button\"\n        aria-expanded={menuOpen}\n        aria-controls={panelId}\n        aria-label={menuLabel}\n        onClick={() => setMenuOpen((open) => !open)}\n        className=\"grid size-11 place-items-center rounded-full text-ink outline-none focus-visible:ring-focus\"\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"relative flex h-4 w-5 flex-col justify-between\"\n        >\n          {[0, 1, 2].map((line) => (\n            <span\n              key={line}\n              className={cn(\n                \"h-px w-full origin-center rounded-full bg-current\",\n                \"transition-layout duration-base ease-spring motion-reduce:transition-none\",\n                menuOpen && line === 0 && \"translate-y-[7.5px] rotate-45\",\n                menuOpen && line === 1 && \"scale-x-0 opacity-0\",\n                menuOpen && line === 2 && \"-translate-y-[7.5px] -rotate-45\",\n              )}\n            />\n          ))}\n        </span>\n      </button>\n    );\n\n    const viewNodes = views.map((view, index) => {\n      const isActive = index === activeViewIndex;\n      const isMenu = isActive && collapsed;\n      return (\n        <div\n          key={view.href}\n          ref={(node) => {\n            viewRefs.current[index] = node;\n          }}\n          className={cn(\n            part,\n            !isActive && !sidesOpen && !fluidShell && \"hidden\",\n          )}\n          data-active={isActive || undefined}\n        >\n          {isMenu ? (\n            menuDroplet\n          ) : isActive ? (\n            <ul className=\"flex items-center gap-1 p-1.5\">\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  \"pointer-events-none absolute top-1.5 bottom-1.5 left-0 rounded-full bg-ink/6\",\n                  \"transition-layout duration-slow ease-spring motion-reduce:transition-none\",\n                  !indicator.ready && \"opacity-0\",\n                )}\n                style={{\n                  width: indicator.width,\n                  transform: `translateX(${indicator.left}px)`,\n                }}\n              />\n              {activeItems.map((item) => (\n                <li\n                  key={item.href}\n                  ref={(node) => {\n                    itemRefs.current[item.href] = node;\n                  }}\n                  className={cn(\"relative z-10\", item.className)}\n                >\n                  {itemLink(item, false)}\n                </li>\n              ))}\n            </ul>\n          ) : (\n            <Link\n              href={view.href}\n              aria-label={view.label}\n              onClick={(event: React.MouseEvent) => {\n                if (onViewChange) {\n                  event.preventDefault();\n                  onViewChange(view.href);\n                }\n              }}\n              className=\"grid size-11 place-items-center rounded-full text-ink outline-none focus-visible:ring-focus\"\n            >\n              {view.icon}\n            </Link>\n          )}\n        </div>\n      );\n    });\n\n    const stage = keepCanvas ? (\n      <div\n        ref={stageRef}\n        aria-hidden=\"true\"\n        className={cn(\n          \"pointer-events-none absolute overflow-hidden\",\n          !fluidShell && \"hidden\",\n        )}\n        style={{\n          top: -DROP_HEIGHT,\n          bottom: collapsed ? -PANEL_REACH : -CANVAS_SLACK,\n          left: -SLACK_X,\n          right: -SLACK_X,\n        }}\n      >\n        <canvas ref={canvasRef} />\n      </div>\n    ) : null;\n\n    const panelNode = collapsed ? (\n      <div\n        ref={panelRef}\n        id={panelId}\n        className={cn(\n          \"absolute top-full right-0 left-0 z-10 mt-3 max-h-[min(520px,calc(100dvh-5rem))] rounded-[28px] p-2\",\n          fluidShell\n            ? \"transition-opacity duration-fast motion-reduce:transition-none\"\n            : \"border border-border bg-surface/95 shadow-raised backdrop-blur-xl\",\n          menuOpen && \"overflow-y-auto\",\n          !menuOpen &&\n            (fluidShell ? \"pointer-events-none overflow-hidden\" : \"hidden\"),\n        )}\n      >\n        <div ref={panelBodyRef} className={cn(fluidShell && \"opacity-0\")}>\n          <ul className=\"flex flex-col gap-0.5\">\n            {activeItems.map((item) => (\n              <li key={item.href}>{itemLink(item, true)}</li>\n            ))}\n          </ul>\n          {hasSatellites ? (\n            <div className=\"mt-2 flex flex-col gap-1 border-border border-t pt-2\">\n              {[...leftSats, ...rightSats].map((satellite) => (\n                // biome-ignore lint/a11y/useSemanticElements: a fieldset is for form controls; this is a named grouping of nav controls inside a landmark\n                <div\n                  key={satellite.id}\n                  role=\"group\"\n                  aria-label={satellite.label}\n                  className=\"flex min-h-11 items-center px-2\"\n                >\n                  {satellite.children}\n                </div>\n              ))}\n            </div>\n          ) : null}\n        </div>\n      </div>\n    ) : null;\n\n    return (\n      <nav\n        ref={(node) => {\n          navRef.current = node;\n          if (typeof forwardedRef === \"function\") forwardedRef(node);\n          else if (forwardedRef) forwardedRef.current = node;\n        }}\n        aria-label={ariaLabel}\n        data-fluid={fluidShell ? \"on\" : \"off\"}\n        className={cn(\n          \"relative items-center\",\n          /* Satellites need real corners to fly to, and the body needs to stay\n           * centred no matter how unbalanced the two ends are, which is what a\n           * flex row cannot give. */\n          hasSatellites && !collapsed\n            ? \"grid w-full grid-cols-[1fr_auto_1fr] gap-2 sm:gap-4\"\n            : collapsed\n              ? \"flex w-full justify-between gap-2\"\n              : \"flex gap-4\",\n          collapseBelow && !collapsed && COLLAPSE_GUARD[collapseBelow],\n          className,\n        )}\n        style={navStyle}\n        {...props}\n      >\n        {stage}\n        {panelNode}\n\n        {collapsed ? (\n          <>\n            {brandNode}\n            {viewNodes}\n          </>\n        ) : hasSatellites ? (\n          <>\n            <div className=\"flex items-center gap-2 justify-self-start sm:gap-4\">\n              {brandNode}\n              {leftSats.map(satelliteNode)}\n            </div>\n            <div className=\"flex items-center gap-4 justify-self-center\">\n              {viewNodes}\n            </div>\n            <div className=\"flex items-center gap-2 justify-self-end sm:gap-4\">\n              {rightSats.map(satelliteNode)}\n            </div>\n          </>\n        ) : (\n          <>\n            {brandNode}\n            {viewNodes}\n          </>\n        )}\n      </nav>\n    );\n  },\n);\nSulaNav.displayName = \"SulaNav\";\n"
    }
  ]
}