/* global React */

const TBIcon = {
  // Stage icons — pipeline mode.
  draft: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <rect x="4" y="4" width="16" height="12" rx="2"/>
      <circle cx="9" cy="10" r="2"/>
      <path d="M4 16l4.5-4 3 3 4-3 4.5 4"/>
      <path d="M7 20h10"/>
    </svg>
  ),
  concept: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="m12 3 2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8 6.7 19.2l1.1-5.9L3.5 9.2l5.9-.8z"/>
    </svg>
  ),
  render: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M3 12a9 9 0 0 1 14.5-7.1"/>
      <path d="M21 12a9 9 0 0 1-14.5 7.1"/>
      <path d="M17 5h4V1"/>
      <path d="M7 19H3v4"/>
    </svg>
  ),
  completed: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="12" cy="12" r="9.5"/>
      <path d="M7.5 12.5l3.2 3.2L17 9.5"/>
    </svg>
  ),
  // Status mode icons.
  shots: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <rect x="3" y="3" width="18" height="18" rx="2.5"/>
      <path d="M7.5 3v18"/>
      <path d="M16.5 3v18"/>
      <path d="M3 12h18"/>
      <path d="M3 7.5h4.5"/>
      <path d="M3 16.5h4.5"/>
      <path d="M16.5 7.5H21"/>
      <path d="M16.5 16.5H21"/>
    </svg>
  ),
  calendar: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <rect x="3.5" y="5.5" width="17" height="15" rx="2.2"/>
      <path d="M3.5 10h17"/>
      <path d="M8 3.5v3.5"/><path d="M16 3.5v3.5"/>
      <rect x="10.5" y="13" width="3" height="3" rx="0.6" fill="currentColor" stroke="none"/>
    </svg>
  ),
  hourglass: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M7 3.5h10"/>
      <path d="M7 20.5h10"/>
      <path d="M7.5 3.5c0 4.2 4.5 6.7 4.5 8.5s-4.5 4.3-4.5 8.5"/>
      <path d="M16.5 3.5c0 4.2-4.5 6.7-4.5 8.5s4.5 4.3 4.5 8.5"/>
      <path d="M9 6.5h6"/>
    </svg>
  ),
  trendUp: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M4.5 16.5L11 10l3 3 5.5-5.5"/>
      <path d="M14.5 7.5h5v5"/>
    </svg>
  ),
  trendFlat: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M4.5 12h13"/>
      <path d="M15.5 8.5L19 12l-3.5 3.5"/>
    </svg>
  ),
  trendDown: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M4.5 7.5L11 14l3-3 5.5 5.5"/>
      <path d="M14.5 16.5h5v-5"/>
    </svg>
  ),
  // Quality mode icons.
  eye: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/>
      <circle cx="12" cy="12" r="3"/>
    </svg>
  ),
  notes: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M21 12a9 9 0 1 1-3.5-7.1L21 3v6h-6"/>
      <path d="M9 11h6"/>
      <path d="M9 14h4"/>
    </svg>
  ),
  x: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="12" cy="12" r="9.5"/>
      <path d="M8 8l8 8M16 8l-8 8"/>
    </svg>
  ),
  // t05g — new icon set.
  // Bold checkmark — used for the Pipeline "Delivered" column.
  checkBold: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
      <path d="M4 12.5l5 5L20 6.5"/>
    </svg>
  ),
  // Clapperboard / film slate — Vid. Approved (Pipeline + Video).
  clapper: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <rect x="3" y="9" width="18" height="11" rx="1.5"/>
      <path d="M3 9l1.5-3.2 3.6 1.2-1.5 3.2z"/>
      <path d="M8.1 7l3.6-1.3 1.6 3.2-3.7 1.3z"/>
      <path d="M13.4 5.7l3.6-1.3 1.6 3.2-3.6 1.3z"/>
      <path d="M18.6 4.4L21.5 6l-1 2.7-2.5-1z" fill="currentColor" stroke="none"/>
    </svg>
  ),
  // Painter's palette — Concept WIP (Image).
  palette: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M12 3a9 9 0 1 0 0 18c1.6 0 2.5-.9 2.5-2 0-1.4-1.5-1.5-1.5-2.5 0-1 .9-1.5 2-1.5h2.5A3.5 3.5 0 0 0 21 11.5C21 7 17 3 12 3z"/>
      <circle cx="7.5" cy="10.5" r="1.1" fill="currentColor" stroke="none"/>
      <circle cx="9.5" cy="6.5"  r="1.1" fill="currentColor" stroke="none"/>
      <circle cx="14.5" cy="6.5" r="1.1" fill="currentColor" stroke="none"/>
      <circle cx="17"   cy="10"  r="1.1" fill="currentColor" stroke="none"/>
    </svg>
  ),
  // Three stacked layers — Image queue.
  layers: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M12 3.5l9 4.5-9 4.5-9-4.5z"/>
      <path d="M3 12l9 4.5L21 12"/>
      <path d="M3 16l9 4.5L21 16"/>
    </svg>
  ),
  // 4K resolution badge — upward arrow + "4K" text below. Uses currentColor.
  fourK: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M12 3v8M8 7l4-4 4 4"/>
      <text x="12" y="20" textAnchor="middle" fontSize="7.5" fontFamily="var(--font-display, sans-serif)" fontWeight="700" fill="currentColor" stroke="none">4K</text>
    </svg>
  ),
  // Spinning render indicator — three curved arrows around a centre.
  spinner: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
      <path d="M12 4a8 8 0 0 1 6.9 4"/>
      <path d="M19 7v3.5h-3.5"/>
      <path d="M20 12a8 8 0 0 1-3.5 6.6"/>
      <path d="M16.5 21l-1.5-3 3-.5"/>
      <path d="M12 20a8 8 0 0 1-6.9-4"/>
      <path d="M5 17v-3.5h3.5"/>
    </svg>
  ),
  // ─── v01e — refreshed icon set ──────────────────────────────────────
  // Mode-tab glyphs (Pipeline / Image / Video / Project). 20×20, 1.5px
  // stroke, matched stroke linecaps so they sit cleanly inline with the
  // mode-tab labels.
  modePipeline: (
    <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="4.5" cy="12" r="2.5"/>
      <circle cx="12"  cy="12" r="2.5"/>
      <circle cx="19.5" cy="12" r="2.5"/>
      <path d="M7 12h2.5M14.5 12H17"/>
    </svg>
  ),
  modeImage: (
    <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <rect x="3" y="5" width="18" height="14" rx="2"/>
      <circle cx="8" cy="10" r="1.4"/>
      <path d="M3 17l5-4.5 4 3.5 4-3 5 4"/>
    </svg>
  ),
  modeVideo: (
    <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <rect x="3" y="9" width="18" height="11" rx="1.5"/>
      <path d="M3 9l1.5-3.2 3.6 1.2-1.5 3.2z"/>
      <path d="M8.1 7l3.6-1.3 1.6 3.2-3.7 1.3z"/>
      <path d="M13.4 5.7l3.6-1.3 1.6 3.2-3.6 1.3z"/>
    </svg>
  ),
  modeProject: (
    <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="12" cy="12" r="9"/>
      <circle cx="12" cy="12" r="5.5"/>
      <circle cx="12" cy="12" r="2" fill="currentColor" stroke="none"/>
    </svg>
  ),
  // Star with a checkmark embedded — Hero Approved.
  starCheck: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="m12 3.6 2.7 5.5 6.1.9-4.4 4.3 1.1 6.1L12 17.4l-5.5 2.9 1.1-6.1L3.2 10l6.1-.9z"/>
    </svg>
  ),
  // Play button inside a circle with a small check tag — Vid. Approved.
  playCheck: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <rect x="2.5" y="6" width="13.5" height="12" rx="2.5"/>
      <path d="m16 10.4 4.6-2.3a1 1 0 0 1 1.4.9v6a1 1 0 0 1-1.4.9L16 13.6z"/>
    </svg>
  ),
  // Upload-arrow — Delivered.
  uploadArrow: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
      <path d="m3.3 7.1 8.7 5 8.7-5"/>
      <path d="M12 22.1V12.1"/>
      <path d="m7.5 4.6 9 5.2"/>
    </svg>
  ),
  // Character / person bust — Asset mode (Characters columns).
  person: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="12" cy="8" r="4"/>
      <path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
    </svg>
  ),
  // Map-pin — Asset mode (Locations columns).
  pin: (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z"/>
      <circle cx="12" cy="10" r="2.6"/>
    </svg>
  ),
};

function TBDonut({ arcs: arcsProp, size = 144, focusedId = null, animationKey = "", sw = 11 }) {
  const clamp = (x) => Math.max(0, Math.min(100, x));
  const sourceArcs = (arcsProp || []).map(a => ({
    id: a.id,
    pct: clamp(a.pct || 0),
    gradient: a.gradient,
    highlight: a.highlight || "color-mix(in srgb, var(--donut-sheen-base) 50%, transparent)",
  }));
  // Grow-from-zero animation: on first mount and on every animationKey change
  // (mode switch), reset all arc lengths to 0, then on the next paint frame set
  // them to their target pct so the stroke-dashoffset CSS transition fires.
  //
  // v05m — switched from 2x requestAnimationFrame to setTimeout(60ms). The
  // 2x-RAF approach was unreliable on cold mount: React 18's concurrent
  // rendering plus browser scheduling could collapse the "render 0% →
  // paint → render target" sequence into a single paint, leaving CSS with
  // no prior style to transition from. The result was the donut appearing
  // to start at a non-zero percentage and jump partway before completing.
  // setTimeout(60ms) reliably puts ~3-4 frames between the 0% commit and
  // the target commit, so the browser definitely paints 0% first and CSS
  // has a real "before" style to transition from.
  const [displayPcts, setDisplayPcts] = React.useState(() => sourceArcs.map(() => 0));
  React.useEffect(() => {
    setDisplayPcts(sourceArcs.map(() => 0));
    const t = setTimeout(() => {
      setDisplayPcts(sourceArcs.map(a => a.pct));
    }, 60);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [animationKey]);
  // Update displayed pcts when underlying numbers change but mode hasn't (data refresh).
  React.useEffect(() => {
    if (sourceArcs.length !== displayPcts.length) {
      setDisplayPcts(sourceArcs.map(a => a.pct));
      return;
    }
    // If any pct changed, sync without resetting to 0 (smooth update).
    const changed = sourceArcs.some((a, i) => a.pct !== displayPcts[i]);
    if (changed) setDisplayPcts(sourceArcs.map(a => a.pct));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sourceArcs.map(a => a.pct).join("|")]);

  // ---- THE RAMP RUNS ALONG EACH BAR (16 Sep 2026) ------------------------------------
  // Hugo, twice: "the gradients are still not there anymore in the donut ... going from
  // slightly darker to brighter at the tip".
  // MEASURED: the stops were right (read back from the live page). The DIRECTION was wrong.
  // Every arc gradient is x1=0 y1=0 x2=0 y2=1 on the circle's own bounding box - which is the
  // WHOLE ring - so it runs top to bottom of the donut, not along the bar. A bar only ever sees
  // the slice of that ramp at its own height, and any bar whose tip is past six o'clock climbs
  // back up toward the dark end, so its tip comes out DIMMER than its middle. No choice of
  // colours can fix a ramp that points the wrong way.
  // With the switch on, each arc gets its own copy of its gradient (same stops, via href) laid
  // along the chord from the arc's start to its end, in the circle's own coordinates - so the
  // bar is darkest where it starts and brightest at its tip. A linear gradient can only order
  // points monotonically over a half-turn; past that the chord clamps at both ends, which still
  // reads as dark-at-the-start, bright-at-the-tip. The end is held short of a full turn so the
  // chord never collapses to a point.
  // OPT-IN through --donut-ramp-along, so Paradise Found and every other skin keep the vertical
  // ramp they were designed with. A MutationObserver re-reads it when the theme changes.
  //
  // 16 Sep 2026 - the SHAPE of the glow belongs to the skin too (Hugo: "MAKE SURE NOTHING IS HARD
  // CODED"). The lengths, step counts and fade below used to be typed in here; they are now tokens
  // (--donut-bleed-span ...) with these numbers as the fallback, so a new skin can shape them.
  const DONUT_LOOK_DEFAULTS = {
    along: false, gutter: false, bleedSpan: 96, bleedSteps: 14, falloff: 1.7,
    leadSpan: 44, leadSteps: 8, leadStrength: 0.8, tipSpan: 14,
  };
  const readDonutLook = () => {
    const out = { ...DONUT_LOOK_DEFAULTS };
    try {
      const cs = getComputedStyle(document.body);
      const num = (name, fallback) => { const v = parseFloat(cs.getPropertyValue(name)); return Number.isFinite(v) ? v : fallback; };
      out.along = cs.getPropertyValue("--donut-ramp-along").trim() === "1";
      out.gutter = cs.getPropertyValue("--sk-donut-profile").trim() === "gutter";
      out.bleedSpan = num("--donut-bleed-span", out.bleedSpan);
      out.bleedSteps = Math.max(1, Math.round(num("--donut-bleed-steps", out.bleedSteps)));
      out.falloff = num("--donut-bleed-falloff", out.falloff);
      out.leadSpan = num("--donut-bleed-lead-span", out.leadSpan);
      out.leadSteps = Math.max(1, Math.round(num("--donut-bleed-lead-steps", out.leadSteps)));
      out.leadStrength = num("--donut-bleed-lead-strength", out.leadStrength);
      out.tipSpan = num("--donut-endglow-span", out.tipSpan);
    } catch (_) {}
    return out;
  };
  const sameLook = (a, b) => Object.keys(DONUT_LOOK_DEFAULTS).every((k) => a[k] === b[k]);
  const [look, setLook] = React.useState(readDonutLook);
  const rampAlong = look.along;
  // Leave a full coloured floor between the sculpted walls. Grow outwards so
  // the inner lip does not consume either the centre or the progress colour.
  const recessed = look.gutter && sw === 11; // mobile's explicit 17-unit profile stays unchanged
  sw = recessed ? 8 : sw;
  const arcCap = recessed ? "butt" : "round";
  React.useEffect(() => {
    const sync = () => { const next = readDonutLook(); setLook((prev) => (sameLook(prev, next) ? prev : next)); };
    sync();
    // 17 Sep 2026 — a project switch changes body's class/style several times in a row; reading the
    // computed style on each change forced a full style pass mid-switch (35–45 ms). Changes are
    // gathered and read once, in the next frame, when the browser computes the style anyway.
    let raf = 0;
    const schedule = () => {
      if (raf) return;
      if (typeof requestAnimationFrame !== "function") { sync(); return; }
      raf = requestAnimationFrame(() => { raf = 0; sync(); });
    };
    const mo = new MutationObserver(schedule);
    mo.observe(document.body, { attributes: true, attributeFilter: ["data-theme", "data-skin-family", "class", "style"] });
    return () => { mo.disconnect(); if (raf) cancelAnimationFrame(raf); };
  }, []);

  const arcs = sourceArcs.map((a, i) => ({ ...a, pct: displayPcts[i] != null ? displayPcts[i] : 0 }));
  // Sort by pct desc — longest first for drawing order; shorter arcs sit on top.
  const sorted = [...arcs].sort((a, b) => b.pct - a.pct);
  const r = recessed ? 79 : 78;
  // Annular mask band derived from the stroke width so a thicker ring (mobile)
  // isn't clipped by the mask.
  const maskOuter = r + sw / 2 + 0.6;
  const maskInner = r - sw / 2 - 0.6;
  const c = 2 * Math.PI * r;
  const off = (p) => c * (1 - p / 100);
  const alongId = (arc) => `${arc.gradient}__along__${arc.id}`;
  const paintFor = (arc) => (rampAlong ? `url(#${alongId(arc)})` : `url(#${arc.gradient})`);

  // ---- THE TRACK BLEED (16 Sep 2026) -------------------------------------------------
  // Hugo, pointing at a zoom of the mockup: "Dont you see the purple colour of the bottom
  // bar is bleeding onto the BORDERS of the empty part?" and "the whole thing is glowing,
  // it should only be gradual and the end of the bar that glows".
  //
  // In the mockup the empty part of the ring is TWO THIN LINES with darkness between them,
  // and near the lit arc those two LINES take the arc's colour and fade along the track
  // over roughly seventy degrees. The space between them stays dark. Our donut instead put
  // a soft haze over the whole band, which is why it never looked like his picture.
  //
  // So the bleed is painted where it actually belongs: on the two border radii, not across
  // the band. A stroke cannot fade along its own length in SVG, so each line is drawn as a
  // short run of segments whose opacity falls away step by step; a small blur smooths the
  // joins. Only the LONGEST arc bleeds - it is the only one whose end touches empty track,
  // every shorter arc ends on top of a longer one. There is a second, shorter run just
  // BEFORE twelve o'clock, which is the "purple glow bleeding to the left over the empty
  // area" at the start of the bars.
  const hairRadii = [r - sw / 2, r + sw / 2 + 0.2];   // the empty track's two border lines
  const bleeds = [];
  const pushRun = (paint, fromDeg, spanDeg, steps, scale, back) => {
    if (!paint) return;
    hairRadii.forEach((rr, ri) => {
      const CC = 2 * Math.PI * rr;
      const step = spanDeg / steps;
      for (let i = 0; i < steps; i++) {
        const a1 = back ? fromDeg - step * (i + 1) : fromDeg + step * i;
        bleeds.push({
          key: `bl-${back ? "b" : "f"}-${ri}-${i}`,
          rr, CC, a1, len: CC * (step / 360), paint,
          o: Math.pow(1 - i / steps, look.falloff) * scale,
        });
      }
    });
  };
  const longest = sorted[0];
  if (longest && longest.pct > 0 && longest.pct < 100) pushRun(paintFor(longest), 3.6 * longest.pct, look.bleedSpan, look.bleedSteps, 1, false);
  const firstUp = sorted[sorted.length - 1];
  if (firstUp && firstUp.pct > 0) pushRun(`url(#${firstUp.gradient})`, 360, look.leadSpan, look.leadSteps, look.leadStrength, true);

  // ---- THE END GLOW ------------------------------------------------------------------
  // "only ... the end of the bar that glows". A short bright copy of the last stretch of
  // the longest arc, blurred, added in screen so it only ever adds light.
  const endGlow = (() => {
    if (!longest || !(longest.pct > 0) || longest.pct >= 100) return null;
    const endDeg = 3.6 * longest.pct;
    const span = Math.min(look.tipSpan, endDeg);
    return { paint: paintFor(longest), a1: endDeg - span, len: c * (span / 360) };
  })();
  // Opacity helper — when a stage is focused, gently dim the others (still
  // legible, not disappeared) and keep the focused one at full opacity.
  const arcOpacity = (id) => {
    if (!focusedId) return 1;
    return id === focusedId ? 1 : 0.5;
  };
  return (
    <svg className="tb-donut" width={size} height={size} viewBox="0 0 200 200" shapeRendering="geometricPrecision">
      <defs>
        {/* A recessed channel is opt-in skin paint; radii follow the live ring width. */}
        <linearGradient id="donutGutterRim" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" style={{ stopColor: "var(--sk-donut-gutter-shadow-1, transparent)" }}/>
          <stop offset="48%" style={{ stopColor: "var(--sk-donut-gutter-rim-1, transparent)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--sk-donut-gutter-light-1, transparent)" }}/>
        </linearGradient>
        <linearGradient id="donutGutterInner" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" style={{ stopColor: "var(--sk-donut-gutter-light-1, transparent)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--sk-donut-gutter-shadow-1, transparent)" }}/>
        </linearGradient>
        {/* A channel has opposing sloped walls, not two outlines. The mouth
            catches light above; its outer wall casts soft shade onto the floor. */}
        <linearGradient id="donutChannelOuterWall" x1="25" y1="20" x2="160" y2="180" gradientUnits="userSpaceOnUse">
          <stop offset="0%" style={{stopColor:"var(--sk-donut-gutter-shadow-1, transparent)"}}/>
          <stop offset="45%" style={{stopColor:"var(--sk-donut-gutter-rim-1, transparent)"}}/>
          <stop offset="100%" style={{stopColor:"var(--sk-donut-gutter-light-1, transparent)"}}/>
        </linearGradient>
        <linearGradient id="donutChannelInnerWall" x1="25" y1="20" x2="160" y2="180" gradientUnits="userSpaceOnUse">
          <stop offset="0%" style={{stopColor:"var(--sk-donut-gutter-light-1, transparent)"}}/>
          <stop offset="48%" style={{stopColor:"var(--sk-donut-gutter-rim-1, transparent)"}}/>
          <stop offset="100%" style={{stopColor:"var(--sk-donut-gutter-shadow-1, transparent)"}}/>
        </linearGradient>
        <linearGradient id="donutChannelLip" x1="25" y1="20" x2="160" y2="180" gradientUnits="userSpaceOnUse">
          <stop offset="0%" style={{stopColor:"color-mix(in srgb, var(--sk-donut-gutter-light-1, transparent) 55%, var(--sk-light))"}}/>
          <stop offset="55%" style={{stopColor:"color-mix(in srgb, var(--sk-donut-gutter-light-1, transparent) 75%, var(--sk-light))"}} stopOpacity="0.95"/>
          <stop offset="100%" style={{stopColor:"var(--sk-donut-gutter-shadow-1, transparent)"}} stopOpacity="0.25"/>
        </linearGradient>
        <linearGradient id="donutChannelOcclusion" x1="25" y1="20" x2="160" y2="180" gradientUnits="userSpaceOnUse">
          <stop offset="0%" style={{stopColor:"var(--sk-donut-gutter-shadow-1, transparent)"}} stopOpacity="0.8"/>
          <stop offset="65%" style={{stopColor:"var(--sk-donut-gutter-shadow-1, transparent)"}} stopOpacity="0.3"/>
          <stop offset="100%" style={{stopColor:"var(--sk-donut-gutter-shadow-1, transparent)"}} stopOpacity="0"/>
        </linearGradient>
        <filter id="donutChannelSoft" x="-10%" y="-10%" width="120%" height="120%">
          <feGaussianBlur stdDeviation="0.85"/>
        </filter>
        <clipPath id="donutChannelCentreClip">
          <circle cx="100" cy="100" r={r - sw / 2}/>
        </clipPath>
        <linearGradient id="arcFirstPass" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--donut-first-pass-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--donut-first-pass-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--donut-first-pass-hi)" }}/>
        </linearGradient>
        <linearGradient id="arcConcept" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--donut-concept-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--donut-concept-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--donut-concept-hi)" }}/>
        </linearGradient>
        <linearGradient id="arcRender" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--donut-render-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--donut-render-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--donut-render-hi)" }}/>
        </linearGradient>
        <linearGradient id="arcCompleted" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--donut-completed-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--donut-completed-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--donut-completed-hi)" }}/>
        </linearGradient>
        {/* Gold metallic — used for 4K upscale / delivered arcs (Fix 3, t05b). */}
        <linearGradient id="arcUpscale" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--donut-upscale-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--donut-upscale-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--donut-upscale-hi)" }}/>
        </linearGradient>
        {/* Muted cream/tan — base ring for Project mode 2-segment donut (Fix 4, t05b). */}
        <linearGradient id="arcRemaining" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "color-mix(in srgb, var(--card-border) 45%, transparent)" }}/>
          <stop offset="100%" style={{ stopColor: "color-mix(in srgb, color-mix(in srgb, var(--card-border) 60%, var(--mix-light)) 55%, transparent)" }}/>
        </linearGradient>
        {/* Status-mode gradients */}
        <linearGradient id="arcShots" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--arc-shots-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--arc-shots-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--arc-shots-hi)" }}/>
        </linearGradient>
        <linearGradient id="arcDays" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--arc-days-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--arc-days-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--arc-days-hi)" }}/>
        </linearGradient>
        <linearGradient id="arcHours" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--arc-hours-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--arc-hours-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--arc-hours-hi)" }}/>
        </linearGradient>
        <linearGradient id="arcStatus" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--arc-status-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--arc-status-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--arc-status-hi)" }}/>
        </linearGradient>
        {/* Quality-mode gradients */}
        <linearGradient id="arcApproved" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--arc-status-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--arc-status-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--arc-status-hi)" }}/>
        </linearGradient>
        <linearGradient id="arcReview" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--arc-review-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--arc-review-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--arc-review-hi)" }}/>
        </linearGradient>
        <linearGradient id="arcNotes" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--arc-notes-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--arc-notes-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--arc-notes-hi)" }}/>
        </linearGradient>
        <linearGradient id="arcRejected" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   style={{ stopColor: "var(--arc-rejected-lo)" }}/>
          <stop offset="55%"  style={{ stopColor: "var(--arc-rejected-mid)" }}/>
          <stop offset="100%" style={{ stopColor: "var(--arc-rejected-hi)" }}/>
        </linearGradient>
        {/* Shadow-only filter — wider/softer; outputs blurred alpha only (not source).
            Combined with a per-arc mask so the shadow renders ONLY where a longer arc
            lies underneath, never on the empty dark track. */}
        {/* 16 Sep 2026 - the shade takes its colour from --donut-join-shadow (black by
            default, as before) so a skin can recolour it; SourceAlpha alone is always black. */}
        <filter id="arcShadowOnly" x="-30%" y="-30%" width="160%" height="160%">
          <feGaussianBlur in="SourceAlpha" stdDeviation="2.6"/>
          <feComponentTransfer result="joinAlpha"><feFuncA type="linear" slope="0.55"/></feComponentTransfer>
          <feFlood style={{ floodColor: "var(--donut-join-shadow)" }}/>
          <feComposite in2="joinAlpha" operator="in"/>
        </filter>
        {/* SVG-level AA blur — applied to the entire donut group at the END so all
            ring edges are rasterized with sub-pixel softening. CSS filter:blur
            wasn't taking effect; this SVG-internal filter does. */}
        <filter id="donutAA" x="-5%" y="-5%" width="110%" height="110%">
          <feGaussianBlur stdDeviation="0.45"/>
        </filter>
        {/* Outer-glow filter for the focused arc — only used when focusedId is set. */}
        <filter id="arcFocusGlow" x="-30%" y="-30%" width="160%" height="160%">
          <feGaussianBlur in="SourceAlpha" stdDeviation="2.5"/>
          <feComponentTransfer result="glowAlpha"><feFuncA type="linear" slope="1.5"/></feComponentTransfer>
          <feFlood style={{ floodColor: "var(--donut-focus-glow)" }}/>
          <feComposite in2="glowAlpha" operator="in"/>
          <feMerge>
            <feMergeNode/>
            <feMergeNode in="SourceGraphic"/>
          </feMerge>
        </filter>
        {/* Annular mask — keeps everything inside the ring band */}
        <mask id="ringMask">
          <rect width="200" height="200" fill="black"/>
          <circle cx="100" cy="100" r={maskOuter} fill="white"/>
          <circle cx="100" cy="100" r={maskInner} fill="black"/>
        </mask>
        {/* Per-arc shadow masks: silhouette of all arcs longer than this one. */}
        {sorted.map((arc, i) => (
          <mask key={`m-${arc.id}`} id={`shadowMask-${arc.id}`}>
            <rect width="200" height="200" fill="black"/>
            {sorted.slice(0, i).map(la => (
              <circle key={la.id} cx="100" cy="100" r={r} fill="none" stroke="white" strokeWidth={sw}
                strokeLinecap={arcCap} strokeDasharray={c} strokeDashoffset={off(la.pct)}
                transform="rotate(-90 100 100)"/>
            ))}
          </mask>
        ))}
        {rampAlong && sorted.map((arc) => {
          const A = (Math.min(Math.max(arc.pct, 0.5), 94) * 3.6 * Math.PI) / 180;
          return (
            <linearGradient key={`al-${arc.id}`} id={alongId(arc)} href={`#${arc.gradient}`}
                            gradientUnits="userSpaceOnUse"
                            x1={100 + r} y1={100}
                            x2={100 + r * Math.cos(A)} y2={100 + r * Math.sin(A)}/>
          );
        })}
      </defs>


      {recessed ? <g className="tb-donut-gutter tb-donut-channel-relief" aria-hidden="true">
        <circle cx="100" cy="100.5" r={r + sw / 2 + 1.3} fill="none" stroke="url(#donutChannelOcclusion)" strokeWidth="2.8" filter="url(#donutChannelSoft)" opacity="0.35"/>
        <circle cx="100" cy="100" r={r} fill="none" stroke="url(#donutChannelOuterWall)" strokeWidth={sw + 4}/>
      </g> : <g className="tb-donut-gutter" aria-hidden="true">
        <circle cx="100" cy="100" r={r} fill="none" stroke="url(#donutGutterRim)" strokeWidth={sw + (recessed ? 2 : 7)}/>
        <circle cx="100" cy="100" r={r} fill="none" style={{ stroke: "var(--sk-donut-gutter-shadow-1, transparent)" }} strokeWidth={sw + (recessed ? 0.5 : 2)}/>
      </g>}
      <g mask="url(#ringMask)" filter={recessed ? undefined : "url(#donutAA)"}>
        {/* dark track */}
        <circle cx="100" cy="100" r={r} fill="none" style={{ stroke: "var(--donut-track)" }} strokeWidth={sw}/>

        {/* Render arcs longest-first; for each non-longest arc, a soft shadow is drawn
            above the longer arcs (clipped to their silhouette) BEFORE the arc itself.
            Z-order is preserved when focusing — the focused arc does NOT pop above
            the smaller arcs (those still sit on top). Smooth opacity transitions on
            focus/unfocus. */}
        {sorted.map((arc, i) => (
          <React.Fragment key={arc.id}>
            {!recessed && i > 0 && arc.pct > 0 && (
              <g filter="url(#arcShadowOnly)" mask={`url(#shadowMask-${arc.id})`}
                 style={{ transition: "opacity var(--dur-5) ease" }}
                 opacity={arcOpacity(arc.id)}>
                <circle cx="100" cy="100" r={r} fill="none" stroke="black" strokeWidth={sw}
                        strokeLinecap="round" strokeDasharray={c} strokeDashoffset={off(arc.pct)}
                        style={{ transition: "stroke-dashoffset var(--dur-arc) var(--ease-pop)" }}
                        transform="rotate(-90 100 100)"/>
              </g>
            )}
            <circle cx="100" cy="100" r={r} fill="none" stroke={paintFor(arc)} strokeWidth={sw}
                    strokeLinecap={arcCap} strokeDasharray={c} strokeDashoffset={off(arc.pct)}
                    opacity={arcOpacity(arc.id)}
                    style={{ transition: "opacity var(--dur-5) ease, stroke-dashoffset var(--dur-arc) var(--ease-pop)" }}
                    transform="rotate(-90 100 100)"/>
          </React.Fragment>
        ))}

        {/* Crisp outer-edge highlights — drawn on top of all arcs.
            16 Sep 2026: each one also carries its ARC's name, so a skin can tint the
            specular per arc. A neon tube's catch-light is a pale version of the tube's
            OWN colour (measured #6EE5FE on cyan, #F188FD on magenta), not cream — one
            white value reads as a pinstripe drawn over the band rather than light on
            it. A skin that sets nothing keeps the single global value it always had. */}
        {sorted.map(arc => (
          <circle key={`hl-${arc.id}`} className={"tb-donut-sheen tb-donut-sheen--" + arc.gradient} cx="100" cy="100" r={r + 2.6} fill="none"
                  strokeWidth="1" strokeLinecap="butt"
                  opacity={arcOpacity(arc.id)}
                  style={{ stroke: arc.highlight, transition: "opacity var(--dur-5) ease, stroke-dashoffset var(--dur-arc) var(--ease-pop)" }}
                  strokeDasharray={c} strokeDashoffset={off(arc.pct)} transform="rotate(-90 100 100)"/>
        ))}
      </g>

      {recessed ? <g className="tb-donut-gutter tb-donut-channel-relief" aria-hidden="true">
        {/* Contact shade falls into the centre, clipped before the colour starts. */}
        <g clipPath="url(#donutChannelCentreClip)">
          <circle cx="100.5" cy="101" r={r - sw / 2 - 0.5} fill="none" stroke="url(#donutChannelOcclusion)" strokeWidth="2.4" filter="url(#donutChannelSoft)" opacity="0.5"/>
        </g>
        <g mask="url(#ringMask)">
          <circle cx="100" cy="100.6" r={r + sw / 2 + 0.3} fill="none" stroke="url(#donutChannelOcclusion)" strokeWidth="2.2" filter="url(#donutChannelSoft)"/>
        </g>
        <circle cx="100" cy="100" r={r + sw / 2 + 1.1} fill="none" stroke="url(#donutChannelOuterWall)" strokeWidth="2.2"/>
        <circle cx="100" cy="100" r={r - sw / 2 - 0.9} fill="none" stroke="url(#donutChannelInnerWall)" strokeWidth="1.8"/>
        <circle cx="100" cy="100" r={r + sw / 2 + 2.5} fill="none" stroke="url(#donutChannelLip)" strokeWidth="1.2"/>
        <circle cx="100" cy="100" r={r - sw / 2 - 2} fill="none" stroke="url(#donutChannelInnerWall)" strokeWidth="0.8" opacity="0.9"/>
      </g> : <g className="tb-donut-gutter" aria-hidden="true">
        <circle cx="100" cy="100" r={r + sw / 2 + 0.5} fill="none" stroke="url(#donutGutterRim)" strokeWidth={recessed ? 0.8 : 1.5}/>
        <circle cx="100" cy="100" r={r - sw / 2 - 0.5} fill="none" stroke="url(#donutGutterInner)" strokeWidth={recessed ? 0.8 : 2}/>
      </g>}
      {/* TRACK BLEED — the arc's colour carried along the empty track's two border lines,
          fading step by step. Invisible at --donut-bleed 0, which every other skin uses. */}
      <g aria-hidden="true" style={{ opacity: "var(--donut-bleed, 0)", filter: "blur(var(--donut-bleed-blur, 1.5px))", mixBlendMode: "var(--donut-glow-blend, screen)" }}>
        {bleeds.map((b) => (
          <circle key={b.key} cx="100" cy="100" r={b.rr} fill="none"
                  stroke={b.paint} strokeLinecap="butt"
                  style={{ strokeWidth: "var(--donut-bleed-stroke, 1.9px)" }}
                  opacity={b.o}
                  strokeDasharray={`${b.len} ${b.CC}`} strokeDashoffset={-b.CC * (b.a1 / 360)}
                  transform="rotate(-90 100 100)"/>
        ))}
      </g>

      {/* END GLOW — the tip of the bar, and only the tip. */}
      {endGlow && (
        <g aria-hidden="true" style={{ opacity: "var(--donut-endglow, 0)", filter: "blur(var(--donut-endglow-blur, 4px))", mixBlendMode: "var(--donut-glow-blend, screen)" }}>
          <circle cx="100" cy="100" r={r} fill="none" stroke={endGlow.paint}
                  strokeWidth={sw} strokeLinecap={arcCap}
                  strokeDasharray={`${endGlow.len} ${c}`} strokeDashoffset={-c * (endGlow.a1 / 360)}
                  transform="rotate(-90 100 100)"/>
        </g>
      )}

      {/* OUTER BLOOM — the only layer in this donut that is outside BOTH clippers.
          Everything in the masked group below is cut twice over: ringMask leaves 0.6 user
          units (0.85 device px) outside the arc's painted edge, and donutAA's filter
          region (-5%/110% of the bbox) leaves 7.3px — so a 24px drop-shadow on an arc is
          amputated to a hairline and can never reach the track beside it. The per-arc
          halo cannot help either: it is BLACK and masked to the silhouette of the longer
          arcs, built expressly so it "never [renders] on the empty dark track".
          So the spill has to come from here. It paints AFTER the arcs, in screen, so it
          only ever ADDS light — beside each arc it lands on the empty track, which is the
          bleed the mockup has. Invisible at --donut-bloom 0, which every other skin uses. */}
      <g aria-hidden="true" style={{ opacity: "var(--donut-bloom, 0)", filter: "blur(var(--donut-bloom-blur, 5px))", mixBlendMode: "var(--donut-glow-blend, screen)" }}>
        {sorted.map((arc) => (
          <circle key={`bloom-${arc.id}`} cx="100" cy="100" r={r} fill="none"
                  stroke={paintFor(arc)} strokeWidth={sw} strokeLinecap={arcCap}
                  strokeDasharray={c} strokeDashoffset={off(arc.pct)}
                  transform="rotate(-90 100 100)"/>
        ))}
      </g>
    </svg>
  );
}

function TBStat({ icon, label, value, sub, title, variant, isActive, isDimmed, onClick }) {
  // Only auto-shrink genuinely LONG status text (e.g. "On Track", 8 chars) so it
  // fits without wrapping. Short status words ("Behind", "Ahead") and any value
  // with a digit ("16.5h", "120%") render at the full display size like the other
  // stats, so the Health column matches its neighbours. The fixed value-line height
  // in CSS keeps the column stack the same height even when "On Track" does shrink.
  const isWordValue = typeof value === "string" && value.length > 7 && !/\d/.test(value);
  return (
    <button
      type="button"
      className={"tb-stat" + (isActive ? " is-active" : "") + (isDimmed ? " is-dimmed" : "")}
      title={title || undefined}
      onClick={onClick}
      aria-pressed={isActive}
    >
      <div className={`tb-chip tb-chip--${variant}`}>{icon}</div>
      <div className="tb-stack">
        <div className="tb-stat-label">{label}</div>
        <div className={"tb-stat-num " + (isWordValue ? "tb-stat-num--md" : "tb-stat-num--lg")}>{value}</div>
        {sub != null && <div className="tb-stat-sub">{sub}</div>}
      </div>
    </button>
  );
}

// t17 — small pulsing live-status dot. Reads window.__sseStatus mirrored
// by App.jsx every render. Polls quickly via state-bumping interval so
// the indicator updates without coupling TopBar to the full auth state.
// t22 — augmented with the currently-online user count from
// window.__onlineUsers (also mirrored by App.jsx).
function TBLiveDot() {
  const [status, setStatus] = React.useState(() => window.__sseStatus || "connecting");
  const [online, setOnline] = React.useState(() => window.__onlineUsers || []);
  // v01z / v05m — track watcher + calendar config so the dot can label
  // SYNC OFF / FOLDER SYNC / LIVE per spec. Probe results are cached
  // on `window.__folderConfigured` / `window.__calendarConfigured`
  // so subsequent mounts of TBLiveDot (e.g. when the user navigates
  // away from Overview and back) read the cached value immediately —
  // no first-paint of "SYNC OFF" → second-paint of "FOLDER SYNC"
  // flash that shifts the N-Online pill next to it.
  const [folderConfigured, setFolderConfigured] = React.useState(() => !!window.__folderConfigured);
  const [calendarConfigured, setCalendarConfigured] = React.useState(() => !!window.__calendarConfigured);
  React.useEffect(() => {
    const id = setInterval(() => {
      const s = window.__sseStatus || "connecting";
      setStatus(prev => prev === s ? prev : s);
      const o = window.__onlineUsers || [];
      setOnline(prev => prev.length === o.length ? prev : o);
    }, 500);
    return () => clearInterval(id);
  }, []);
  React.useEffect(() => {
    const fetcher = window.authFetch || fetch;
    // Only probe if we don't already have a cached answer. The first
    // TBLiveDot to ever mount fires both probes; every subsequent
    // mount in the same session re-uses the cached values.
    if (window.__calendarConfigured === undefined) {
      fetcher("/api/calendar/milestones")
        .then(r => r.ok ? r.json() : null)
        .then(d => {
          const v = !!(d && d.configured);
          window.__calendarConfigured = v;
          setCalendarConfigured(v);
        })
        .catch(() => {});
    }
    if (window.__folderConfigured === undefined) {
      fetcher("/api/folder/diff")
        .then(r => r.ok ? r.json() : null)
        .then(d => {
          const v = !!d;
          window.__folderConfigured = v;
          setFolderConfigured(v);
        })
        .catch(() => {});
    }
  }, []);
  // v01z — three-tier label per the v01z spec:
  //   SYNC OFF   — neither watcher nor calendar are configured
  //   FOLDER SYNC — folder watcher active, calendar not connected
  //   LIVE        — both watcher + calendar active
  // The SSE state still gates these (no syncing claims if the
  // realtime channel is offline).
  let label, liveTooltip;
  if (status !== "live") {
    label = status === "connecting" ? "CONNECTING" : "SYNC OFF";
    liveTooltip = status === "connecting"
      ? "Connecting to real-time stream…"
      : "Folder watcher is inactive. Start the watcher to auto-import new renders.";
  } else if (folderConfigured && calendarConfigured) {
    label = "LIVE";
    liveTooltip = "Folder watcher + calendar sync both active.";
  } else if (folderConfigured) {
    label = "FOLDER SYNC";
    liveTooltip = "Folder watcher active. Connect Google Calendar to enable full LIVE mode.";
  } else {
    // v07zz220 — we're inside the status==="live" branch, so the real-time SSE
    // channel + DB sync ARE active. The folder watcher just isn't running here
    // (it can't on the cloud deployment — there's no disk to watch). Don't
    // mislabel that as "SYNC OFF" — the app IS live.
    label = "LIVE";
    liveTooltip = "Real-time updates are live. (The folder watcher that auto-imports new renders runs on the local machine; it doesn't run on the cloud deployment.)";
  }
  // Hugo update — online pill behaviour:
  //   0 others ("just you")   → "JUST YOU"
  //   1+ others               → "N ONLINE" (count INCLUDES you)
  // Hover slides out round avatars (initials or Crew image) from
  // behind the pill to the right with an overlapping stagger.
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const me = userCtx && userCtx.user;
  const otherCount = online.length;
  const totalCount = otherCount + 1; // include self
  const onlineLabel = otherCount === 0 ? "JUST YOU" : `${totalCount} ONLINE`;
  // v07zz14 — Trigger a one-shot pop+flash animation whenever the
  // online count INCREASES (someone new came online). Decreases don't
  // trigger — leaving the room shouldn't draw the eye.
  const [pop, setPop] = React.useState(false);
  const prevCountRef = React.useRef(otherCount);
  React.useEffect(() => {
    const prev = prevCountRef.current;
    if (otherCount > prev) {
      setPop(false);
      // Next tick → set true so the animation restarts even if it's
      // currently mid-flight.
      requestAnimationFrame(() => {
        setPop(true);
        setTimeout(() => setPop(false), 760);
      });
    }
    prevCountRef.current = otherCount;
  }, [otherCount]);
  // Compute initials + optional avatar image from the crew roster.
  const crewList = (window.__appData && window.__appData.crew) || [];
  const initialsOf = (name) => {
    if (!name) return "??";
    const parts = String(name).trim().split(/\s+/).filter(Boolean);
    if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
    return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
  };
  const crewImageOf = (name) => {
    if (!name) return null;
    const lower = String(name).toLowerCase();
    const hit = crewList.find(c => (c.name || "").toLowerCase() === lower);
    return (hit && hit.image) || null;
  };
  // Avatars list: me first, then online others.
  const avatars = [
    {
      key: "self",
      name: (me && me.name) || "You",
      initials: initialsOf((me && me.name) || "You"),
      image: (me && me.image) || crewImageOf(me && me.name),
      isMe: true,
    },
    ...online.map((u, i) => ({
      key: u.id || `u${i}`,
      name: u.name || `User ${u.id || i + 1}`,
      initials: initialsOf(u.name),
      image: crewImageOf(u.name),
      isMe: false,
    })),
  ];
  return (
    <>
      <span className={`tb-live tb-live--${status}`} title={liveTooltip}>
        <span className="tb-live-dot"/>
        <span className="tb-live-label">{label}</span>
      </span>
      {/* v07zr — Always render the online-group wrapper so its
          vertical footprint is reserved on first paint. Without
          this, the pill appears once the status transitions to
          "live" (post-/api/online fetch) and pushes the timeline
          bar up. With visibility:hidden while !live, layout stays
          stable and the pill fades in when ready. */}
      <span
        className="tb-online-group"
        style={status === "live" ? null : { visibility: "hidden", pointerEvents: "none" }}
        aria-hidden={status !== "live"}
      >
        <span className={"tb-online" + (pop ? " tb-online--pop" : "")}>
          <span className="tb-online-dot"/>
          {onlineLabel}
        </span>
        <span className="tb-online-avatars" aria-hidden="true">
          {avatars.map((a, i) => (
            <span
              key={a.key}
              className={"tb-online-avatar" + (a.isMe ? " tb-online-avatar--me" : "")}
              style={{ ['--ai']: i, ...(a.image ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(a.image, 160) : a.image})`, backgroundSize: "cover", backgroundPosition: "center" } : {}) }}
              title={a.name + (a.isMe ? " (you)" : "")}
            >
              {!a.image && a.initials}
            </span>
          ))}
        </span>
      </span>
    </>
  );
}

function TopBar() {
  const rootRef = React.useRef(null);
  const [square, setSquare] = React.useState(290);
  // t18b / v07zz251 — Hours Worked (Project tab). The server caches Rize 15min,
  // but the FIRST request after a server restart can catch Rize cold (warming up
  // → no hours yet / transient API error). The old code fetched ONCE on mount, so
  // if it caught that window the stat stuck on "—/check Rize token" forever. Now
  // it retries while hours aren't ready, re-fetches on focus, and polls slowly —
  // so it self-heals once Rize warms up (the endpoint returns real hours fine).
  const [hoursData, setHoursData] = React.useState(null);
  const hoursResyncRef = React.useRef(null);
  React.useEffect(() => {
    let alive = true, tries = 0, retry = null;
    // 17 Sep 2026 — the hours belong to a PROJECT (Trøpé kept showing Paradise Found's 516.1h).
    // `shownFor` is the project the card holds numbers for, `askedFor` the project of the latest
    // request. A reply for a project that is no longer active is dropped.
    const activeId = () => window.__activeProjectId || null;
    let shownFor = activeId(), askedFor = null;
    const load = () => {
      const forProject = activeId();
      askedFor = forProject;
      (window.authFetch || fetch)("/api/project/hours")
        .then(r => {
          return r.ok ? r.json() : null;
        })
        .then((d) => {
          if (!alive) return;
          const now = activeId();
          // (17 Sep 2026 review) only the project we ASKED for decides. When the server resolves another
          // one (a project it does not know, e.g. Railway after a local switch to Trope, falls back to the
          // default), its answer is still the one to show, as the rest of the page shows. Comparing the
          // server's X-Active-Project header too left the card on "loading" for good.
          if (forProject !== now) {
            // the project changed without an event (the saved preference adopted at login):
            // ask again for the one that is active now, unless that request is already out
            if (askedFor !== now) { tries = 0; load(); }
            return;
          }
          if (shownFor !== now) {
            // first answer for this project: never keep another project's numbers
            shownFor = now;
            setHoursData(d);
            if (!(d && Number.isFinite(d.hoursTotal)) && !(d && d.configured === false) && tries < 6) { tries++; retry = setTimeout(load, 4000); }
            return;
          }
          // v999b — NEVER let a bad response replace a good one. services/rizeSync
          // CACHES its error sentinel ({configured:true, error, hoursTotal:null}), so
          // a single Rize hiccup is then served to every caller until a background
          // refresh succeeds. The old code called setHoursData(d) unconditionally, so
          // a card already showing "390.9h" flipped to "—/check Rize token" the moment
          // one poll caught that window — and only retried 6 times over 24s before
          // waiting 5 minutes. Reports never showed it because it renders from a
          // one-shot snapshot with a cost.hours_total fallback. Keep the last known
          // good numbers and let the retries heal it quietly in the background.
          const good = d && Number.isFinite(d.hoursTotal);
          setHoursData(prev => (good || !(prev && Number.isFinite(prev.hoursTotal))) ? d : prev);
          // a project that is not tracked answers the same every time; only a cold Rize is worth a retry
          if (!good && !(d && d.configured === false) && tries < 6) { tries++; retry = setTimeout(load, 4000); }
        })
        .catch(() => {
          if (!alive) return;
          const now = activeId();
          if (forProject !== now) { if (askedFor !== now) { tries = 0; load(); } return; }
          if (tries < 6) { tries++; retry = setTimeout(load, 4000); }
        });
    };
    load();
    const onFocus = () => { tries = 0; load(); };
    const poll = setInterval(() => { tries = 0; load(); }, 5 * 60 * 1000);
    // a project switch (not an episode or album switch) empties the card and asks again
    const restart = () => {
      if (retry) { clearTimeout(retry); retry = null; }
      tries = 0;
      shownFor = activeId();
      setHoursData(null);
      load();
    };
    const onProject = (e) => {
      const det = (e && e.detail) || {};
      if (Object.prototype.hasOwnProperty.call(det, "episode") || Object.prototype.hasOwnProperty.call(det, "group")) return;
      restart();
    };
    // a change that came without the event (the saved preference adopted at login) is caught when
    // the bar renders for the new project
    hoursResyncRef.current = () => { if (alive && shownFor !== activeId()) restart(); };
    window.addEventListener("focus", onFocus);
    window.addEventListener("filmtracker:project-changed", onProject);
    return () => {
      alive = false;
      hoursResyncRef.current = null;
      if (retry) clearTimeout(retry);
      clearInterval(poll);
      window.removeEventListener("focus", onFocus);
      window.removeEventListener("filmtracker:project-changed", onProject);
    };
  }, []);
  const activeForHours = window.__activeProjectId || null;
  React.useEffect(() => { if (hoursResyncRef.current) hoursResyncRef.current(); }, [activeForHours]);
  // t05h / t07b — pending-notes total feeds the Project mode "Pending
  // Notes" card. Sums notes-table unresolved (window.__pendingNotesTotal,
  // refreshed by App.jsx on every /api/notes/counts call) PLUS any
  // unresolved video-review comments on the latest review
  // (window.__reviewPendingNotes, set by ReviewPage). We poll a state
  // var every 500ms so the card updates after notes / review comments
  // are added or resolved without coupling TopBar to either pipeline.
  const [pendingNotesTotal, setPendingNotesTotal] = React.useState(() => (window.__pendingNotesTotal || 0) + (window.__reviewPendingNotes || 0));
  React.useEffect(() => {
    const id = setInterval(() => {
      const n = (window.__pendingNotesTotal || 0) + (window.__reviewPendingNotes || 0);
      setPendingNotesTotal(prev => prev === n ? prev : n);
    }, 500);
    return () => clearInterval(id);
  }, []);
  // Four switchable modes for the topbar columns (t05b):
  //   "pipeline" → overall milestone counts (First Pass / Hero Approved / Video Approved / Delivered)
  //   "image"    → image pipeline (First Pass / Concept WIP / Hero / Hero Approved)
  //   "video"    → video pipeline (Video WIP / In Rendering / Video Approved / 4K Upscale)
  //   "shots"    → shot milestones (First Pass / Frame Hero / Video Hero / 4K Upscale)
  //   "asset"    → asset health (Characters first-pass/approved, Locations first-pass/approved)
  //   "project"  → production health (Days Elapsed / Days Remaining / Total Shots / Completion %)
  // Persisted to localStorage so the chosen tab survives reloads + navigation,
  // matching the per-view persistence pattern used elsewhere (paradise-* keys).
  const TB_MODE_KEY = "paradise-topbar-mode";
  const TB_VALID_MODES = ["pipeline", "image", "video", "shots", "asset", "project"];
  const [mode, setModeRaw] = React.useState(() => {
    try {
      const saved = localStorage.getItem(TB_MODE_KEY);
      if (saved && TB_VALID_MODES.indexOf(saved) !== -1) return saved;
    } catch (_) {}
    return "pipeline";
  });
  const setMode = React.useCallback((next) => {
    setModeRaw(next);
    try { localStorage.setItem(TB_MODE_KEY, next); } catch (_) {}
  }, []);
  // Click-to-highlight: the active column dims the others. In pipeline mode it
  // also focuses the corresponding donut arc.
  const [activeStage, setActiveStage] = React.useState(null);
  // Reset focus when switching modes (column ids differ between modes).
  React.useEffect(() => { setActiveStage(null); }, [mode]);

  React.useLayoutEffect(() => {
    const right = rootRef.current && rootRef.current.querySelector('.tb-right');
    if (!right) return;
    const measure = () => {
      // v07zz282 — offsetHeight, NOT getBoundingClientRect: the rect is in
      // REAL (zoom-multiplied) pixels while --tb-square is consumed as
      // layout px. Under App scale ≠ 100% the mismatch fed back on itself
      // (wider square → narrower right column → wrapped/taller content →
      // bigger square …) until the donut ballooned to ~686px and crushed
      // the stats. offsetHeight returns layout px — zoom-independent.
      // The clamp is a backstop so no future regression can spiral.
      const h = Math.round(right.offsetHeight);
      if (h > 0) setSquare(Math.min(Math.max(h, 200), 400));
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(right);
    return () => ro.disconnect();
  }, []);
  // v07zz283 — mobile tier: the topbar stacks vertically (donut above a
  // 2×2 stat grid), so the square stops tracking .tb-right's height and
  // the fixed 290px inline height comes off (CSS keys on data-ui-tier).
  const [uiTier, setUiTier] = React.useState(() => {
    try { return document.body.getAttribute("data-ui-tier") || ""; } catch (_) { return ""; }
  });
  React.useEffect(() => {
    const fn = () => { try { setUiTier(document.body.getAttribute("data-ui-tier") || ""); } catch (_) {} };
    window.addEventListener("paradise-ui-scale", fn);
    return () => window.removeEventListener("paradise-ui-scale", fn);
  }, []);
  const isMobileTb = uiTier === "s";
  const donut = Math.max(0, square - 8);
  const data = window.__appData || null;
  const shots = (data && data.shots) || [];
  // 15 Sep 2026 — 120 was Paradise Found's planned pilot count; a templated project with no
  // container counts what it has (0), so its donut never reads n/120.
  const epTotal = (data && data.episode && data.episode.shot_count) || shots.length || ((window.__isDefaultProject && !window.__isDefaultProject()) ? 0 : 120);

  // Compute pipeline stats from real shot stage_status — 4 telescoping stages.
  // First-pass is auto-derived from image presence (canonical rule from
  // ShotsPanel.jsx). Without this branch the topbar's cumulative counts
  // diverge from the per-shot pill: a shot whose only signal is an image
  // would render as FIRST-PASS in the row but would be missing from the
  // First Pass column up here.
  const hasImg = window.hasAnyImage || (() => false);
  const stageDone = (s, k) => {
    if (!s) return false;
    if (k === "first_pass" && hasImg(s)) return true;
    return !!(s.stage_status && s.stage_status[k] === "done");
  };
  const planned   = epTotal;
  const firstPass = shots.filter(s => stageDone(s, "first_pass")).length;
  const concept   = shots.filter(s => stageDone(s, "hero")).length;
  const rendering = shots.filter(s => stageDone(s, "video_prompt") && !stageDone(s, "video")).length;
  const completed = shots.filter(s => stageDone(s, "video")).length;
  const upscaled  = shots.filter(s => stageDone(s, "upscale")).length;

  // v1080 — overallPct is WEIGHTED now and computed below, after _counted (the shots it counts).

  const pctOf = (n) => planned ? Math.round((n / planned) * 100) : 0;
  const firstPassPct = pctOf(firstPass);
  const conceptPct   = pctOf(concept);
  const renderingPct = pctOf(rendering);
  const completedPct = pctOf(completed);

  // Cumulative milestone counts — once a shot passes a stage, it stays counted
  // in that stage's column even after progressing further. The user mental
  // model: "Hero Approved = shots that have been hero-approved", not "shots
  // currently sitting at hero waiting for the next step". stage_status flags
  // are telescoping (later flags imply earlier ones), so a single
  // stage_status[k] === "done" check gives the cumulative count.
  // v07zz358 — the "active" shot set for the overview Total Shots + every percentage: drop ARCHIVED,
  // drop OMITTED (cut from the edit — they shouldn't inflate the count), and count a LINKED pair as
  // ONE by excluding the continuation slave (the shot carrying `linked_to`; its master already
  // represents the pair). Numerators (cum*) use the SAME set so the percentages can't exceed 100%.
  // v07zz370 — also drop ARCHIVE-FOOTAGE placeholders: the shotlist already dims them as archived and
  // they never progress through stages, so counting them deflated the real production-progress %.
  const _counted = (s) => !!s && !s.is_archive && !s.archive_footage && !s.omitted && !s.linked_to;
  const totalActive = shots.filter(_counted).length;
  // v1080 — Overall Progress, WEIGHTED. Hugo: "the project shows only 63% completion even
  // though the upscale part is just literally the last 10%". It averaged 7 equal steps over
  // EVERY shot, archived and cut ones included (151 of 390 that never move — they held it at
  // 63%). Now: the counted shots only, with Frames / Video / Upscale weighted per episode
  // (default 50 / 40 / 10, the gear on the circle). Helpers: window.weightedProgress (App.jsx).
  const _wGroups = window.stageGroupWeights ? window.stageGroupWeights(data && data.episode) : { frames: 50, video: 40, upscale: 10 };
  const _countedShots = shots.filter(_counted);
  const _wp = window.weightedProgress ? window.weightedProgress(_countedShots, stageDone, _wGroups) : { overall: 0, frames: 0, video: 0, upscale: 0 };
  const overallPct = Math.round(_wp.overall);
  const progressTip = `Frames ${Math.round(_wp.frames)}% · Video ${Math.round(_wp.video)}% · Upscale ${Math.round(_wp.upscale)}%\nWeights ${_wGroups.frames} / ${_wGroups.video} / ${_wGroups.upscale} ${_wGroups.source === "saved" ? "(set in Settings)" : _wGroups.source === "schedule" ? "(from the schedule weeks)" : "(defaults)"} · ${_countedShots.length} shots (archived, cut and archive footage do not count)\nChange the weights in Settings ▸ Project · Progress weights`;
  const pctOfActive = (n) => totalActive ? Math.round((n / totalActive) * 100) : 0;
  const cumPrompt       = shots.filter(s => _counted(s) && stageDone(s, "prompt")).length;
  const cumFirstPass    = shots.filter(s => _counted(s) && stageDone(s, "first_pass")).length;
  const cumRefinement   = shots.filter(s => _counted(s) && stageDone(s, "refinement")).length;
  const cumHero         = shots.filter(s => _counted(s) && stageDone(s, "hero")).length;
  const cumVideoPrompt  = shots.filter(s => _counted(s) && stageDone(s, "video_prompt")).length;
  const cumVideo        = shots.filter(s => _counted(s) && stageDone(s, "video")).length;
  const cumUpscale      = shots.filter(s => _counted(s) && stageDone(s, "upscale")).length;
  // Live render-queue count — sourced from QueueBreakdown's module-scope
  // array via window.__videoQueue. Falls back to 0 if the panel hasn't
  // mounted yet. This is what the Video tab "Rendering" column reads, and
  // it is intentionally separate from cumVideoPrompt (the cumulative count
  // of shots that have ever entered the video stage) so that incrementing
  // Video WIP doesn't also increment Rendering.
  // v07zz276 — count only jobs ACTUALLY waiting/rendering, not the whole recent
  // feed. __imageQueue/__videoQueue are filtered by KIND only, so they include
  // completed/failed/cancelled jobs — that's why "In Queue" read 21 when nothing
  // was queued. Exclude terminal db_status so the count reflects the live queue.
  const _activeQueue = (arr) => (Array.isArray(arr) ? arr : []).filter(
    j => j && j.db_status !== "completed" && j.db_status !== "failed" && j.db_status !== "cancelled"
  ).length;
  const renderQueueCount = _activeQueue(window.__videoQueue);
  const imageQueueCount  = _activeQueue(window.__imageQueue);
  const completionPct   = totalActive ? Math.round((cumVideo / totalActive) * 100) : 0;
  const completedShots  = cumVideo;
  const numSequences    = ((data && data.sequences) || []).length;

  const sched = (data && data.schedule) || {};
  // v07zz60 — Hugo: "Scheduled dates seem to be hard coded and not
  // taken from the actual schedule. April 20th i guess was the
  // original start date, which we can keep, but deadline date should
  // be adjusted." Build the range string from project_start /
  // project_end. Falls back to schedule.range (or a generic "—")
  // only when those dates aren't set yet.
  const _fmtRangeDate = (iso) => {
    if (!iso) return null;
    const d = new Date(iso + (iso.length === 10 ? "T00:00:00" : ""));
    if (Number.isNaN(d.getTime())) return null;
    const m = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][d.getMonth()];
    return `${m} ${d.getDate()}, ${d.getFullYear()}`;
  };
  const _startLabel = _fmtRangeDate(sched.project_start);
  const _endLabel   = _fmtRangeDate(sched.project_end);
  // Compact "Apr 20 – Aug 5, 2026" when start + end share a year.
  let dateRange = sched.range || "—";
  if (_startLabel && _endLabel) {
    const sParts = _startLabel.split(", ");
    const eParts = _endLabel.split(", ");
    if (sParts[1] === eParts[1]) {
      dateRange = `${sParts[0]} – ${eParts[0]}, ${eParts[1]}`;
    } else {
      dateRange = `${_startLabel} – ${_endLabel}`;
    }
  }
  // v01c — date-driven progress. The timeline bar now reads project_start
  // / project_end from data/schedule.json (or backend-equivalent) and
  // shows the *time* elapsed, not the shot-completion %. Falls back to
  // the legacy 70-day window if either date is missing so the bar still
  // renders something while data is loading.
  const _startMs = sched.project_start ? new Date(sched.project_start).getTime() : null;
  const _endMs   = sched.project_end   ? new Date(sched.project_end).getTime()   : null;
  const _todayMs = Date.now();
  let elapsedPct = 0;
  let totalDays = 70;
  let daysElapsed = 0;
  // 15 Sep 2026 — no dates → no days (Hugo saw "53 days remaining" on a project with no schedule).
  let daysLeft = sched.days_left != null ? sched.days_left : null;
  if (_startMs != null && _endMs != null && _endMs > _startMs) {
    const totalMs   = _endMs - _startMs;
    const elapsedMs = Math.max(0, Math.min(totalMs, _todayMs - _startMs));
    elapsedPct  = Math.round((elapsedMs / totalMs) * 100);
    totalDays   = Math.max(1, Math.round(totalMs / 86400000));
    daysElapsed = Math.max(0, Math.round(elapsedMs / 86400000));
    daysLeft    = Math.max(0, totalDays - daysElapsed);
  } else if (daysLeft != null) {
    daysElapsed = totalDays - daysLeft;
    elapsedPct  = Math.round((daysElapsed / totalDays) * 100);
  }
  // Health: ahead / on-track / behind by comparing OVERALL pipeline progress
  // (overallPct — the donut headline %) against the share of the schedule already
  // elapsed. v07zz278 — was completionPct (delivered shots only), which flashed a
  // misleading "-47% behind" when every shot was through first pass but nothing was
  // delivered yet. Overall progress reflects real momentum across the whole pipeline.
  // Declared AFTER daysElapsed / totalDays to avoid a temporal-dead-zone error.
  // v1084 — Health follows the SCHEDULE, stage by stage. Hugo: "the project Health always shows
  // Behind because it takes how much percent of the project is done, doesnt look like it's
  // taking into consideration the milestones and stages ... this needs to be smarter". It used
  // to compare the % done with the % of calendar time gone (a straight line from start to
  // deadline). Now: the planned % on a day = the sum over Frames / Video / Upscale of its weight
  // x how far its schedule phases should be by then (0 before they start, 1 after they end, a
  // straight line in between). Health = how many days the plan is from today's real %: the day
  // the plan reached it, minus today. 3+ days either way = Ahead / Behind. With no stage phases
  // in the schedule it falls back to the old straight line.
  const _plan = window.scheduleGroupWeights ? window.scheduleGroupWeights(sched) : null;
  const _DAY = 86400000;
  const _planPctAt = (plan, ms) => {
    const tot = (_wGroups.frames + _wGroups.video + _wGroups.upscale) || 1;
    let p = 0;
    for (const k of ["frames", "video", "upscale"]) {
      const sp = plan.spans[k];
      const f = ms <= sp.start ? 0 : ms >= sp.end ? 1 : (ms - sp.start) / (sp.end - sp.start);
      p += (_wGroups[k] / tot) * f;
    }
    return p * 100;
  };
  // The day the plan reached today's real %, minus today: + ahead of the plan, - behind it.
  const _planDaysFor = (plan) => {
    if (!plan || !plan.spans) return null;
    const first = Math.min(plan.spans.frames.start, plan.spans.video.start, plan.spans.upscale.start);
    const last = Math.max(plan.spans.frames.end, plan.spans.video.end, plan.spans.upscale.end);
    let reached = last;
    for (let ms = first; ms <= last; ms += _DAY) { if (_planPctAt(plan, ms) >= _wp.overall - 0.05) { reached = ms; break; } }
    return Math.round((reached - _todayMs) / _DAY);
  };
  let expectedCompletionPct, planDays = null;
  if (_plan && _plan.spans) {
    expectedCompletionPct = Math.round(_planPctAt(_plan, _todayMs));
    planDays = _planDaysFor(_plan);
  } else {
    expectedCompletionPct = Math.round((daysElapsed / totalDays) * 100);
  }
  // v1086 — the same walk on the ORIGINAL plan (schedule.baseline: the 28 May dates, delivery
  // 5 Aug). Hugo: "show the health compared to the original deadline, but also against the
  // current revised schedule". The big word stays the schedule in force; the line under it
  // gives both, and the tooltip says it in full.
  const _baseSched = window.baselineSchedule ? window.baselineSchedule(sched) : null;
  const _basePlan = (_baseSched && window.scheduleGroupWeights) ? window.scheduleGroupWeights(_baseSched) : null;
  const baseDays = _planDaysFor(_basePlan);
  const completionDelta = overallPct - expectedCompletionPct;
  // 15 Sep 2026 — no start + deadline ⇒ no time progress and no health (Hugo: "sometimes we
  // dont have deadlines"). The bar then shows WORK progress, and the tile says so.
  const hasDates = _startMs != null && _endMs != null && _endMs > _startMs;
  const barPct = hasDates ? elapsedPct : overallPct;
  const healthLabel = !hasDates ? "No deadline"
    : planDays != null
    ? (planDays <= -3 ? "Behind" : planDays >= 3 ? "Ahead" : "On Track")
    : (completionDelta >= 5 ? "Ahead" : completionDelta <= -5 ? "Behind" : "On Track");
  // The line under the word has room for about 21 characters ("−16d now · −48d orig.").
  const _sd = (n) => (n === 0 ? "0d" : (n < 0 ? "−" : "+") + Math.abs(n) + "d");
  const healthSub = !hasDates
    ? "no dates set"
    : planDays == null
    ? `${completionDelta >= 0 ? "+" : ""}${completionDelta}% vs plan`
    : baseDays == null
      ? (planDays <= -1 ? `${-planDays}d behind` : planDays >= 1 ? `${planDays}d ahead` : "on schedule")
      : `${_sd(planDays)} now · ${_sd(baseDays)} orig.`;
  const _fmtDay = window.schedFmtDay || ((x) => String(x || ""));
  const _cap = (x) => x.charAt(0).toUpperCase() + x.slice(1);
  const _dLong = (n, what) => n <= -1 ? `${-n} day${n === -1 ? "" : "s"} behind ${what}` : n >= 1 ? `${n} day${n === 1 ? "" : "s"} ahead of ${what}` : `on ${what}`;
  const healthTip = planDays == null ? undefined : [
    _cap(_dLong(planDays, "the schedule")) + (sched.project_end ? ` now (delivery ${_fmtDay(sched.project_end)}).` : " now."),
    baseDays != null ? _cap(_dLong(baseDays, "the original plan")) + ` (delivery ${_fmtDay(_baseSched.project_end)}).` : null,
    "It compares today's weighted progress with where each plan said it would be today.",
  ].filter(Boolean).join(" ");
  // v1086 — hours against the quote. Hugo: "the hours should show how much over or under we
  // are compared to original quote." The quote is schedule.labor_commitment, which the server
  // sends only to people who can see the budget; hidden too while "Hide all money figures" is
  // on (screen sharing). The line has room for "+129h over (+36%)"; the tooltip has the rest.
  const _quoteH = Number(sched.labor_commitment) || 0;
  const _hTot = (hoursData && Number.isFinite(hoursData.hoursTotal)) ? hoursData.hoursTotal : null;
  const hoursOver = (_quoteH > 0 && _hTot != null && !window.__hideMoney) ? _hTot - _quoteH : null;
  const _hSign = (hoursOver != null && hoursOver < 0) ? "−" : "+";
  const hoursOverSub = hoursOver == null ? null
    : `${_hSign}${Math.round(Math.abs(hoursOver))}h ${hoursOver < 0 ? "under" : "over"} (${_hSign}${Math.round(Math.abs(hoursOver) / _quoteH * 100)}%)`;
  const hoursTip = hoursOver == null ? undefined
    : `${_hTot.toFixed(1)} h worked. The quote was ${_quoteH} h, so ${Math.abs(hoursOver).toFixed(1)} h ${hoursOver < 0 ? "under" : "over"} (${Math.round(_hTot / _quoteH * 100)}% of the quote).`
      + ((hoursData && Number.isFinite(hoursData.hoursThisWeek)) ? ` This week ${hoursData.hoursThisWeek.toFixed(1)} h.` : "");
  // 🔒 v07zz278 — no hardcoded contract figure; labor_commitment is stripped from
  // /api/schedule for non-budget roles, so fall back to a shot-scope estimate.
  const labor = sched.labor_commitment || Math.max(1, Math.round(planned * 3.3));
  const hoursBurned = Math.round(upscaled * 3.4 + completed * 2.6 + concept * 1.4 + firstPass * 0.6);
  const burnPct = Math.min(100, Math.round((hoursBurned / labor) * 100));
  const expectedPct = Math.min(100, Math.round((daysElapsed / totalDays) * 100));
  const onTrackDelta = overallPct - expectedPct;
  const status = healthLabel;   // v1084 — one schedule-aware answer everywhere
  const statusVariant = status === "Behind" ? "behind" : status === "Ahead" ? "ahead" : "ontrack";
  const statusIcon = statusVariant === "ahead" ? TBIcon.trendUp : statusVariant === "behind" ? TBIcon.trendDown : TBIcon.trendFlat;

  const remaining = Math.max(0, planned - completed);
  const archivedCount = shots.filter(s => s.is_archive).length;

  // Quality-mode counts — derived from real shot data so they shift as the
  // user clicks Hero / Completed / 4K buttons. Plausible breakdown.
  const approvedCount = concept + completed; // hero-locked + final-approved
  const reviewCount   = Math.max(0, firstPass - concept); // shots past first-pass, not yet hero-locked
  const notesCount    = Math.max(0, Math.round(planned * 0.12)); // active feedback rounds (mock)
  const rejectedCount = archivedCount;
  const approvedPct = pctOf(approvedCount);
  const reviewPct   = pctOf(reviewCount);
  const notesPct    = pctOf(notesCount);
  const rejectedPct = pctOf(rejectedCount);

  // ── Shots mode counts (cumulative milestones, mirrors the per-shot pill) ──
  // First Pass / Frame Hero / Video Hero / 4K Upscale. All reuse the same
  // stageDone() telescoping logic + cumulative columns already computed above,
  // so these never diverge from the Pipeline/Image/Video tabs.
  const shFirstPass = cumFirstPass; // image present OR stage_status.first_pass done
  const shFrameHero = cumHero;      // promoted hero frame
  const shVideoHero = cumVideo;     // hero video
  const sh4k        = cumUpscale;   // 4K upscale (image or video)

  // ── Asset mode counts ─────────────────────────────────────────────────
  // Characters + Locations split by status. Assets carry a `status` field
  // (first_pass | wip | retake | approved — see ASSET_STATUSES). We read the
  // raw arrays off window.__appData.assets, drop archived/deleted, and treat
  // any non-approved status as "in progress" (first_pass bucket) so the two
  // columns always sum to the live total. Missing status → first_pass.
  const _assetData = (data && data.assets) || {};
  const _liveAssets = (arr) => (Array.isArray(arr) ? arr : []).filter(a => a && !a.archived && !a.deleted);
  const _isApproved = (a) => String((a && a.status) || "").toLowerCase() === "approved";
  const characters = _liveAssets(_assetData.characters);
  const locations  = _liveAssets(_assetData.locations);
  const charApproved   = characters.filter(_isApproved).length;
  const charInProgress = characters.length - charApproved;
  const locApproved    = locations.filter(_isApproved).length;
  const locInProgress  = locations.length - locApproved;
  const charTotal = characters.length;
  const locTotal  = locations.length;
  const _pctOfDenom = (n, d) => (d ? Math.round((n / d) * 100) : 0);
  // Asset-mode donut: overall approval across characters + locations.
  const assetTotal    = charTotal + locTotal;
  const assetApproved = charApproved + locApproved;
  const assetApprovedPct = _pctOfDenom(assetApproved, assetTotal);

  // Mode definitions — each mode has its own column data AND its own donut
  // arcs. Columns map to arc ids so clicking a column always focuses the
  // matching arc on the donut.
  // ────────────────────────────────────────────────────────────────────
  // 4-mode tab system (t05b spec): Pipeline / Image / Video / Project.
  // No CSS changes — only column data + labels per mode. Donut visuals
  // unchanged; arcs adapted per mode using existing SVG gradients.
  // ────────────────────────────────────────────────────────────────────
  const MODES = {
    // Overall pipeline milestones — cumulative counts (a shot that's past
    // a milestone keeps being counted at that milestone, so progressing
    // a shot forward never decrements an earlier column).
    // t05g: column 1 renamed Total Shots (was First Pass — duplicated
    // with Image mode); icons swapped on Vid. Approved (clapperboard)
    // and Delivered (bold check).
    pipeline: {
      label: "Pipeline",
      columns: [
        // v01e — icon refresh: starCheck for Hero Approved, playCheck for Vid Approved, uploadArrow for Delivered. Total Shots keeps the existing filmstrip glyph.
        { id: "ts", arcId: "ts", variant: "total",     icon: TBIcon.shots,       label: "Total Shots",   value: totalActive,  sub: `${shots.length} incl. archive` },
        { id: "ha", arcId: "ha", variant: "completed", icon: TBIcon.starCheck,   label: "Frames Hero",  value: cumHero,      sub: `${pctOfActive(cumHero)}%` },
        { id: "va", arcId: "va", variant: "render",    icon: TBIcon.playCheck,   label: "Videos Hero",   value: cumVideo,     sub: `${pctOfActive(cumVideo)}%` },
        { id: "dl", arcId: "dl", variant: "upscale",   icon: TBIcon.uploadArrow, label: "Delivered",     value: cumUpscale,   sub: `${pctOfActive(cumUpscale)}%` },
      ],
      // v01d — Pipeline donut: 'Total Shots' (ts) excluded from arcs.
      // It was the 100% full-ring backdrop, which provided no signal of
      // its own and confused the cumulative-progress reading. Stat-row
      // column stays so the count is still visible above the donut.
      arcs: [
        { id: "ha", pct: pctOfActive(cumHero),      gradient: "arcCompleted", highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
        { id: "va", pct: pctOfActive(cumVideo),     gradient: "arcRender",    highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
        { id: "dl", pct: pctOfActive(cumUpscale),   gradient: "arcUpscale",   highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
      ],
    },
    // Image-only pipeline — three cumulative milestones (First Pass /
    // Concept WIP / Hero) plus a live "In Queue" stat sourced from the
    // image queue panel (window.__imageQueue). Mirrors the Video tab's
    // Rendering pattern. Hero is the terminal image milestone in the data
    // model, so there's no separate "Hero Approved" column.
    image: {
      label: "Image",
      columns: [
        { id: "fp", arcId: "fp", variant: "firstpass", icon: TBIcon.draft,     label: "First Pass",  value: cumFirstPass,  sub: `${pctOfActive(cumFirstPass)}%` },
        { id: "cw", arcId: "cw", variant: "concept",   icon: TBIcon.palette,   label: "Frames WIP",   value: cumRefinement, sub: `${pctOfActive(cumRefinement)}%` },
        { id: "h",  arcId: "h",  variant: "completed", icon: TBIcon.completed, label: "Frames Hero", value: cumHero,       sub: `${pctOfActive(cumHero)}%` },
        { id: "iq", arcId: "iq", variant: "render",    icon: TBIcon.layers,    label: "In Queue",    value: imageQueueCount, sub: imageQueueCount === 1 ? "1 in queue" : `${imageQueueCount} in queue` },
      ],
      // v01d — Image donut: 'In Queue' (iq) excluded from arcs.
      // Queue count is a live snapshot rather than a cumulative
      // milestone, so projecting it onto an arbitrary % made the donut
      // visually noisy. Column stays in the stat row to keep the live
      // queue number on screen.
      arcs: [
        { id: "fp", pct: pctOfActive(cumFirstPass),  gradient: "arcFirstPass", highlight: "color-mix(in srgb, var(--donut-sheen-base) 50%, transparent)" },
        { id: "cw", pct: pctOfActive(cumRefinement), gradient: "arcConcept",   highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
        { id: "h",  pct: pctOfActive(cumHero),       gradient: "arcCompleted", highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
      ],
    },
    // Video-only pipeline — Video WIP / Rendering / Vid. Approved / 4K
    // Upscale. Rendering is intentionally NOT cumulative — it reflects the
    // live render-farm queue (sourced from QueueBreakdown via
    // window.__videoQueue) so it goes up only when a new job is actually
    // submitted, and goes back down when the job finishes. The other three
    // are cumulative milestones.
    video: {
      label: "Video",
      columns: [
        { id: "vw", arcId: "vw", variant: "render",    icon: TBIcon.render,    label: "Videos WIP",    value: cumVideoPrompt,    sub: `${pctOfActive(cumVideoPrompt)}%` },
        { id: "ir", arcId: "ir", variant: "render",    icon: TBIcon.spinner,   label: "Rendering",     value: renderQueueCount,  sub: renderQueueCount === 1 ? "1 in queue" : `${renderQueueCount} in queue` },
        { id: "va", arcId: "va", variant: "completed", icon: TBIcon.playCheck, label: "Videos Hero",    value: cumVideo,          sub: `${pctOfActive(cumVideo)}%` },
        { id: "u4", arcId: "u4", variant: "upscale",   icon: TBIcon.fourK,     label: "4K Upscale",    value: cumUpscale,        sub: `${pctOfActive(cumUpscale)}%` },
      ],
      // v01d — Video donut: 'Rendering' (ir) excluded from arcs.
      // Same rationale as Image's 'In Queue' — live queue counts don't
      // meaningfully scale to a % of the shot list. Stat column stays.
      arcs: [
        { id: "vw", pct: pctOfActive(cumVideoPrompt),                                   gradient: "arcRender",    highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
        { id: "va", pct: pctOfActive(cumVideo),                                         gradient: "arcCompleted", highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
        { id: "u4", pct: pctOfActive(cumUpscale),                                       gradient: "arcUpscale",   highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
      ],
    },
    // t05h — Project mode reshuffled.
    //   Pending Notes  (most actionable — unresolved feedback across all shots)
    //   Hours Worked   (Rize placeholder until t18b activates)
    //   Completion     (% video-approved or upscaled)
    //   Health         (Ahead / On Track / Behind vs schedule)
    // Sequences column dropped — not actionable enough to deserve a slot.
    project: {
      label: "Project",
      columns: [
        { id: "pn", arcId: "pn", variant: "concept", icon: TBIcon.notes, label: "Pending Notes",
          value: pendingNotesTotal,
          sub: pendingNotesTotal === 1 ? "1 unresolved note" : `${pendingNotesTotal} unresolved notes` },
        { id: "hw", arcId: "hw", variant: "hours", icon: TBIcon.hourglass, label: "Hours Worked",
          value: (hoursData && Number.isFinite(hoursData.hoursTotal)) ? `${hoursData.hoursTotal.toFixed(1)}h` : "—",
          // v1086 — over / under the quote when it is known (hoursOverSub), else this week.
          title: hoursTip,
          sub: hoursOverSub != null ? hoursOverSub : !hoursData
            ? "loading…"
            : hoursData.configured
              ? (Number.isFinite(hoursData.hoursThisWeek)
                  ? `this week ${hoursData.hoursThisWeek.toFixed(1)}h`
                  : (hoursData.error ? "check Rize token" : "this week"))
              : ((window.__isDefaultProject && !window.__isDefaultProject()) ? "not tracked" : "not connected") },   // 15 Sep 2026 — Rize hours are Paradise Found's
        { id: "co", arcId: "co", variant: "completed", icon: TBIcon.checkBold, label: "Completion",
          value: `${completionPct}%`,
          sub: `${completedShots} / ${totalActive} shots` },
        { id: "he", arcId: "he",
          variant: `status-${healthLabel.replace(/\s+/g, "").toLowerCase()}`,
          icon: healthLabel === "Ahead" ? TBIcon.trendUp : healthLabel === "Behind" ? TBIcon.trendDown : TBIcon.trendFlat,
          label: "Health",
          value: healthLabel,
          sub: healthSub },
      ],
      // Two-segment donut: completed (leaf green) + remaining (cream/tan).
      // arc1 is the FULL ring in muted cream, arc2 is the completed slice on top.
      arcs: [
        { id: "remaining", pct: 100,           gradient: "arcRemaining", highlight: "color-mix(in srgb, var(--mix-light) 30%, transparent)" },
        { id: "co",        pct: completionPct, gradient: "arcCompleted", highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
      ],
    },
    // Shots mode — four cumulative shot milestones (First Pass / Frame Hero /
    // Video Hero / 4K Upscale). Reuses the same telescoping counts as the
    // Pipeline/Image/Video tabs so the numbers stay consistent everywhere.
    shots: {
      label: "Shots",
      columns: [
        { id: "fp", arcId: "fp", variant: "firstpass", icon: TBIcon.draft,     label: "First Pass",  value: shFirstPass, sub: `${pctOfActive(shFirstPass)}%` },
        { id: "fh", arcId: "fh", variant: "completed", icon: TBIcon.starCheck, label: "Frame Hero",  value: shFrameHero, sub: `${pctOfActive(shFrameHero)}%` },
        { id: "vh", arcId: "vh", variant: "render",    icon: TBIcon.playCheck, label: "Video Hero",  value: shVideoHero, sub: `${pctOfActive(shVideoHero)}%` },
        { id: "u4", arcId: "u4", variant: "upscale",   icon: TBIcon.fourK,     label: "4K Upscale",  value: sh4k,        sub: `${pctOfActive(sh4k)}%` },
      ],
      arcs: [
        { id: "fp", pct: pctOfActive(shFirstPass), gradient: "arcFirstPass", highlight: "color-mix(in srgb, var(--donut-sheen-base) 50%, transparent)" },
        { id: "fh", pct: pctOfActive(shFrameHero), gradient: "arcCompleted", highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
        { id: "vh", pct: pctOfActive(shVideoHero), gradient: "arcRender",    highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
        { id: "u4", pct: pctOfActive(sh4k),        gradient: "arcUpscale",   highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
      ],
    },
    // Asset mode — Characters + Locations, each split first-pass / approved.
    // Kept at four columns so the stat-row grid (and its fixed separators)
    // never shifts vs the other modes. Donut shows overall approval rate.
    asset: {
      label: "Asset",
      columns: [
        { id: "cfp", arcId: "cfp", variant: "firstpass", icon: TBIcon.person, label: "Characters",
          value: charInProgress, sub: charTotal ? `first pass · ${charTotal} total` : "first pass" },
        { id: "cap", arcId: "cap", variant: "completed", icon: TBIcon.person, label: "Characters",
          value: charApproved,   sub: charTotal ? `approved · ${_pctOfDenom(charApproved, charTotal)}%` : "approved" },
        // v07zz249 — Locations don't run through a first-pass stage (Hugo), so
        // the left location column shows the plain total, not "first pass".
        { id: "lfp", arcId: "lfp", variant: "concept",   icon: TBIcon.pin,    label: "Locations",
          value: locTotal,       sub: "total" },
        { id: "lap", arcId: "lap", variant: "upscale",   icon: TBIcon.pin,    label: "Locations",
          value: locApproved,    sub: locTotal ? `approved · ${_pctOfDenom(locApproved, locTotal)}%` : "approved" },
      ],
      // Two-segment donut (like Project): full muted ring + approved slice.
      arcs: [
        { id: "remaining", pct: 100,              gradient: "arcRemaining", highlight: "color-mix(in srgb, var(--mix-light) 30%, transparent)" },
        { id: "aap",       pct: assetApprovedPct, gradient: "arcCompleted", highlight: "color-mix(in srgb, var(--donut-sheen-base) 55%, transparent)" },
      ],
    },
  };
  const currentMode = MODES[mode] || MODES.pipeline;
  const stages = currentMode.columns;
  const arcs = currentMode.arcs;
  const focused = activeStage ? stages.find(s => s.id === activeStage) : null;
  // v1083 — what the middle of the circle shows in each mode (no column focused). Hugo: "when I
  // change mode, like Images, the donut percentage does not change ... if i'm on Images, it
  // should show me the percentage based on the image stage completion. same for videos."
  // Images = the Frames stages, Videos = the Video stages (weighted inside each group, same
  // shots), Assets = approved assets, Pipeline / Shots / Project = the weighted overall.
  const donutMain = mode === "image" ? { label: "Image Progress", pct: Math.round(_wp.frames) }
    : mode === "video" ? { label: "Video Progress", pct: Math.round(_wp.video) }
    : mode === "asset" ? { label: "Asset Progress", pct: assetApprovedPct }
    : { label: "Overall Progress", pct: overallPct };
  const toggleStage = (id) => setActiveStage(prev => prev === id ? null : id);
  // Donut focus — when a column is selected, pass its arcId to the donut so
  // the matching arc is emphasised (others dim, full opacity stays on this one).
  const focusedArcId = focused ? focused.arcId : null;

  // v01e — mode tabs gain inline SVG icons matching the prompt's spec
  // (3-node flow / framed landscape / clapperboard / target). Each
  // glyph is 20×20 1.5px stroke, declared on TBIcon above.
  const MODE_OPTIONS = [
    { id: "pipeline", label: "Pipeline", icon: TBIcon.modePipeline },
    { id: "image",    label: "Images",   icon: TBIcon.modeImage    },
    { id: "video",    label: "Videos",   icon: TBIcon.modeVideo    },
    { id: "shots",    label: "Shots",    icon: TBIcon.shots        },
    { id: "asset",    label: "Assets",   icon: TBIcon.person       },
    { id: "project",  label: "Project",  icon: TBIcon.modeProject  },
  ];

  // v07zz285 — MOBILE: a purpose-built compact card REPLACES the desktop
  // topbar entirely (CSS-squeezing the desktop DOM produced a mess —
  // floating donut hero, seam lines, spread-out stats). Same data, same
  // handlers (stage focus, mode switch), phone-native composition:
  // ring + headline row → 2×2 stat tiles → schedule bar → mode pills.
  if (isMobileTb) {
    // v07zz286 — MOBILE overview bar, revamped (Hugo: old one "too tall, too
    // crammed", modes "disgusting"). Three clean bands:
    //   1. HERO — the donut is the major feature (left), 2×2 stat tiles (right).
    //   2. MODES — a single flush segmented row (icon glyphs), gold active state.
    //   3. TIMELINE — the schedule bar spans the FULL width at the very bottom,
    //      dates + days-left + presence sitting on it.
    return (
      <div className="topbar tb-mobile" ref={rootRef}>
        <div className="tbm2-hero">
          <div className="tbm2-donut">
            <div className="tb-donut-darken tbm2-darken" aria-hidden="true"/>
            <TBDonut arcs={arcs} size={150} sw={17} focusedId={focusedArcId} animationKey={mode + "-m"}/>
            <div className="tbm2-ringlabel">
              {focused ? (
                <>
                  <div className="tbm2-ring-eyebrow">{focused.label}</div>
                  <div className="tbm2-ring-pct">{focused.value}</div>
                  <div className="tbm2-ring-sub">{focused.sub}</div>
                </>
              ) : (
                <>
                  <div className="tbm2-ring-eyebrow">Progress</div>
                  <div className="tbm2-ring-pct">{overallPct}<span>%</span></div>
                  <div className="tbm2-ring-sub">{completed} of {planned}</div>
                </>
              )}
            </div>
          </div>
          <div className="tbm2-stats">
            {stages.map(s => (
              <button
                key={s.id}
                type="button"
                className={"tbm2-stat"
                  + (activeStage === s.id ? " is-active" : "")
                  + (activeStage != null && activeStage !== s.id ? " is-dimmed" : "")}
                onClick={() => toggleStage(s.id)}
              >
                <span className={"tb-chip tbm2-stat-chip tb-chip--" + s.variant}>{s.icon}</span>
                <span className="tbm2-stat-body">
                  {/* asset-mode 2nd column reads "Approved" (the count is of
                      approved chars/locs) — restores the distinction Hugo lost */}
                  <span className="tbm2-stat-label">{({ cap: "Approved", lap: "Approved" }[s.id]) || s.label}</span>
                  <span className="tbm2-stat-val">{s.value}</span>
                </span>
              </button>
            ))}
          </div>
        </div>
        <div className="tbm2-modes" role="tablist" aria-label="View mode">
          {MODE_OPTIONS.map(m => (
            <button
              key={m.id}
              role="tab"
              aria-selected={mode === m.id}
              title={m.label}
              className={"tbm2-mode" + (mode === m.id ? " is-active" : "")}
              onClick={() => setMode(m.id)}
            >
              <span className="tbm2-mode-ico" aria-hidden="true">{m.icon}</span>
              <span className="tbm2-mode-lbl">{m.label}</span>
            </button>
          ))}
        </div>
        <div className="tbm2-timeline">
          <div className="tb-progress tb-progress--time" role="progressbar" aria-label={hasDates ? "Schedule elapsed" : "Work progress"} aria-valuenow={barPct} aria-valuemin="0" aria-valuemax="100" style={{ ['--tb-fill']: `${barPct}%`, ['--tb-fill-pct']: Math.max(1, barPct) }}>
            <div className="tb-progress-fill-bar"></div>
            {/* v07zz287 — single current-day chevron sitting UNDERNEATH the bar
                at the elapsed position (Hugo: "have one chevron still visible
                underneath the bar" / current-day marker was missing). */}
            <div className="tbm2-tl-marker" style={{ left: `${barPct}%` }} aria-hidden="true">
              <svg viewBox="0 0 9 5" width="9" height="5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><path d="M1 4l3.5-3 3.5 3"/></svg>
            </div>
          </div>
          <div className="tbm2-timeline-meta">
            <span className="tbm2-tl-dates">{dateRange}</span>
            <span className="tbm2-tl-days">{daysLeft != null ? daysLeft + "d left" : "No deadline"}</span>
            <span className="tbm-presence"><TBLiveDot/></span>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="topbar" ref={rootRef} style={{ ['--tb-square']: `${square}px`, height: 290, minHeight: 290, maxHeight: 290 }}>
      <div className="tb-left">
        <div className="tb-donut-wrap" style={{ width: donut, height: donut }}>
          {/* Subtle dark disk sits inside the ring strokes, above the glass and below the SVG. */}
          <div className="tb-donut-darken" aria-hidden="true"/>
          <TBDonut arcs={arcs} size={donut} focusedId={focusedArcId} animationKey={mode}/>
          <div className="tb-donut-label">
            {focused ? (
              <>
                <div className="tb-donut-eyebrow">{focused.label}</div>
                <div className="tb-donut-percent">{focused.value}</div>
                <div className="tb-donut-sub">{focused.sub}</div>
              </>
            ) : (
              <>
                {/* v1083 — label + % follow the mode (donutMain). No count line under it any more
                    (Hugo: "why is there videos dones and shots in the donut?"). */}
                <div className="tb-donut-eyebrow">{donutMain.label}</div>
                <div className="tb-donut-percent">{donutMain.pct}<span className="tb-pct-sign">%</span></div>
              </>
            )}
          </div>
          {/* v1080 — hover the middle of the circle for the Frames / Video / Upscale split.
              v1081 — the weights are edited in Settings ▸ PROJECT · PROGRESS WEIGHTS (Hugo did not want
              a gear on the circle). Absolute, so nothing moves (#20). */}
          {!focused && <div className="tb-donut-hover" title={progressTip} aria-hidden="true"/>}
        </div>
      </div>

      {/* 16 Sep 2026 - the divider between the donut and the stats.
          It used to be styles/topbar.css:76 `.topbar::after`, but that selector is declared
          TWICE in the same sheet: the first block (topbar.css:41) makes it a 1px-tall
          reflection strip and the second makes it a full-height 3px groove. Per-property
          source order leaves left=290px, width=3px from the second and height=1px from the
          first, and an over-constrained absolute box then drops `bottom` - so the groove
          computes to a 3x1 pixel speck and has never rendered, in ANY theme. Rather than
          unpick a selector two other effects depend on, the line gets its own element.
          Absolute, so it takes no grid track and cannot move anything. It paints NOTHING by
          default, so Paradise Found is unchanged; a skin gives it a background. */}
      <div className="tb-donut-sep" aria-hidden="true"/>

      <div className="tb-right">
        <div className="tb-right-top-spacer" aria-hidden="true"></div>
        <div className="tb-stats-row">
          {stages.map(s => (
            <TBStat
              key={s.id}
              variant={s.variant}
              icon={s.icon}
              label={s.label}
              value={s.value}
              sub={s.sub}
              title={s.title}
              isActive={activeStage === s.id}
              isDimmed={activeStage != null && activeStage !== s.id}
              onClick={() => toggleStage(s.id)}
            />
          ))}
          <span className="tb-col-sep" aria-hidden="true"></span>
        </div>

        <div className="tb-right-spacer" aria-hidden="true"></div>

        <div className="tb-bottom-group">
          {/* v01c — timeline bar driven by elapsed schedule time, not
              shot completion. Track is a faded-green → faded-red
              gradient (declared in topbar.css); fill bar is a glass
              overlay that intensifies toward the right; the percent
              label sits at the fill edge. */}
          <div className="tb-progress tb-progress--time" role="progressbar" aria-label={hasDates ? "Schedule elapsed" : "Work progress"} aria-valuenow={barPct} aria-valuemin="0" aria-valuemax="100" style={{ ['--tb-fill']: `${barPct}%`, ['--tb-fill-pct']: Math.max(1, barPct) }}>
            <div className="tb-progress-fill-bar"></div>
            <div className="tb-progress-marker" style={{ left: `${barPct}%` }} aria-hidden="true">
              <svg viewBox="0 0 8 4" width="8" height="4" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 1l3 2 3-2"/></svg>
              <svg viewBox="0 0 8 4" width="8" height="4" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 3l3-2 3 2"/></svg>
            </div>
            {/* v03b — percent badge removed; rounded leading edge + vibrant
                time-based gradient now live in topbar.css. */}
          </div>

          <div className="tb-meta-row">
            <div className="tb-meta-block">
              {/* v04a — revert to the static SCHEDULED label per Hugo's
                  feedback (v01z's auto-episode-status interpretation
                  was wrong). The live dot/TBLiveDot stays alongside. */}
              <div className="tb-meta-label">
                SCHEDULED
                <TBLiveDot/>
              </div>
              <div className="tb-meta-value">
                {dateRange}
                {daysLeft != null ? <span className="tb-meta-sub"> · {daysLeft} days remaining</span> : null}
              </div>
            </div>
            {/* Mode switcher — bottom-right of the topbar. */}
            <div className="tb-mode-row" role="tablist" aria-label="Topbar view">
              {MODE_OPTIONS.map(m => (
                <button
                  key={m.id}
                  role="tab"
                  aria-selected={mode === m.id}
                  className={"tb-mode-btn" + (mode === m.id ? " is-active" : "")}
                  onClick={() => setMode(m.id)}
                >
                  {/* v03b — mode tabs text-only; icons removed. */}
                  <span className="tb-mode-label">{m.label}</span>
                </button>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { TopBar });
