// animations.jsx — text animation primitives used by templates in video mode.
//
// Two components, both no-ops when `enabled` is false (so we can call them
// unconditionally and only "turn on" in video mode):
//
//   <Rise enabled delay={0.2}>...</Rise>
//     Slide up + fade in. Wraps inline-block so existing inline styles still
//     apply to the child.
//
//   <Scramble enabled value="39°28′12″N" delay={0.4} duration={1.0}/>
//     Digit-scrambler. Cycles random digits left-to-right then settles to
//     the real value. Non-digit characters (°, ′, ″, N/S/E/W, space) stay
//     put so the layout doesn't jitter.
//
// CSS keyframes are injected once on first load.

(function injectAnimStyles() {
  if (document.getElementById('tpl-anim-styles')) return;
  const s = document.createElement('style');
  s.id = 'tpl-anim-styles';
  s.textContent = `
    @keyframes tpl-rise {
      from { opacity: 0; transform: translateY(10px); filter: blur(2px); }
      to   { opacity: 1; transform: translateY(0);    filter: blur(0); }
    }
    .tpl-rise {
      display: inline-block;
      animation: tpl-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
      will-change: opacity, transform;
    }
    /* Magazine "page pull": the cover holds, then lifts up + tilts off the top
       to reveal the footage. 2D transform only (translate/rotate/scale) so it
       bakes into the video export; the hold is baked into the keyframes (no
       animation-delay) so the exporter's currentTime scrub maps cleanly. */
    @keyframes tpl-peel {
      0%, 20%  { transform: translateY(0%) rotate(0deg) scale(1); }
      100%     { transform: translateY(-118%) rotate(-5deg) scale(1.05); }
    }
    .tpl-peel {
      animation: tpl-peel 1.9s cubic-bezier(0.7, 0, 0.2, 1) both;
      transform-origin: 50% 0%;
      will-change: transform;
    }
  `;
  document.head.appendChild(s);
})();

// ─────────────────────────────────────────────────────────────────────────
// Bake clock — lets the video exporter render the intro animations
// deterministically at an arbitrary time t (offline) instead of playing them
// on the wall clock. Inactive by default, so components animate live as usual.
//   window.__bakeBegin(fps) — enter bake mode
//   window.__bakeSeek(t)    — set current bake time (seconds) + re-render subs
//   window.__bakeEnd()      — leave bake mode (back to live)
// JS-driven animations (Scramble, CompassArrow) read this. CSS animations
// (Rise) are scrubbed by the exporter via the Web Animations API currentTime.
// ─────────────────────────────────────────────────────────────────────────
window.__bake = window.__bake || { active: false, t: 0, fps: 30, subs: new Set() };
function __bakeNotify() { window.__bake.subs.forEach((fn) => { try { fn(); } catch (e) {} }); }
window.__bakeBegin = (fps) => { window.__bake.active = true; window.__bake.fps = fps || 30; window.__bake.t = 0; __bakeNotify(); };
window.__bakeSeek = (t) => { window.__bake.t = t; __bakeNotify(); };
window.__bakeEnd = () => { window.__bake.active = false; __bakeNotify(); };

function useBakeClock() {
  const [, force] = React.useReducer((x) => x + 1, 0);
  React.useEffect(() => {
    window.__bake.subs.add(force);
    return () => { window.__bake.subs.delete(force); };
  }, [force]);
  return window.__bake.active ? window.__bake.t : null;
}

// Deterministic digit-scramble state at bake time t — mirrors Scramble's live
// reveal (left-to-right over `duration` after `delay`; non-digits stay put).
// Unrevealed digits use a seeded pseudo-random so each baked frame differs
// (looks scrambled) but is reproducible.
function scrambleAt(v, t, delay, duration, fps) {
  if (!v) return '';
  const frac = Math.max(0, Math.min(1, (t - delay) / duration));
  const reveal = Math.floor(frac * v.length);
  const frame = Math.floor(t * (fps || 30));
  return v.split('').map((ch, i) => {
    if (i < reveal) return ch;
    if (/\d/.test(ch)) {
      const r = Math.sin((i + 1) * 12.9898 + frame * 78.233) * 43758.5453;
      return String(Math.floor((r - Math.floor(r)) * 10));
    }
    return ch;
  }).join('');
}

function Rise({ enabled, delay = 0, children, as = 'span', style }) {
  const Tag = as;
  if (!enabled) return <Tag style={style}>{children}</Tag>;
  return (
    <Tag
      className="tpl-rise"
      style={{
        // The .tpl-rise CSS class sets display: inline-block (needed for
        // transform on a <span>). For block-level tags (div), force back
        // to display: block so children like AutoFitText can measure
        // parent.clientWidth against the real layout width, not just the
        // intrinsic content width.
        ...(as !== 'span' ? { display: 'block' } : null),
        animationDelay: `${delay}s`,
        ...style,
      }}
    >{children}</Tag>
  );
}

// Scramble — uses an internal interval to swap random digits in for the real
// ones; reveals left-to-right over `duration` seconds; holds the final value
// after. Restarts when `value` or `delay` changes.
function Scramble({ enabled, value, delay = 0, duration = 1.0, fps = 18 }) {
  const bakeT = useBakeClock();
  const [display, setDisplay] = React.useState(value || '');
  React.useEffect(() => {
    // While baking, the render path below computes the frame directly — skip
    // the live rAF loop (and don't fight it for the displayed value).
    if (!enabled || bakeT != null) { if (bakeT == null) setDisplay(value || ''); return; }
    const v = value || '';
    if (!v) { setDisplay(''); return; }
    // Random digit substitute that respects non-digit positions.
    const scrambled = () => v.replace(/\d/g, () => String(Math.floor(Math.random() * 10)));
    setDisplay(scrambled());
    const startAt = performance.now() + delay * 1000;
    const endAt   = startAt + duration * 1000;
    const interval = 1000 / fps;
    let last = 0;
    let raf;
    const tick = (now) => {
      if (now < startAt) {
        setDisplay(scrambled());
        raf = requestAnimationFrame(tick);
        return;
      }
      const frac = Math.min(1, (now - startAt) / (endAt - startAt));
      if (now - last >= interval) {
        last = now;
        // Reveal characters left-to-right; non-digits already match.
        const revealCount = Math.floor(frac * v.length);
        const out = v.split('').map((ch, i) => {
          if (i < revealCount) return ch;
          if (/\d/.test(ch)) return String(Math.floor(Math.random() * 10));
          return ch;
        }).join('');
        setDisplay(out);
      }
      if (frac < 1) raf = requestAnimationFrame(tick);
      else setDisplay(v);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [enabled, value, delay, duration, fps, bakeT]);
  if (enabled && bakeT != null) {
    return <>{scrambleAt(value || '', bakeT, delay, duration, window.__bake.fps)}</>;
  }
  return <>{display}</>;
}

window.Rise = Rise;
window.Scramble = Scramble;

// ─────────────────────────────────────────────────────────────────────────
// useResizable — drag-corner scale for a card. Stores scale in localStorage
// (per ratio if storageKey varies). Returns scale + handle onPointerDown +
// resizing flag. Resize math: distance from card center to pointer relative
// to its starting distance scales the card up/down.
// ─────────────────────────────────────────────────────────────────────────
function useResizable(storageKey, defaultScale = 1, minScale = 0.4, maxScale = 2.5) {
  const [scale, setScale] = React.useState(() => {
    if (!storageKey) return defaultScale;
    try {
      const v = localStorage.getItem(storageKey);
      if (v) {
        const n = parseFloat(v);
        if (!isNaN(n)) return Math.max(minScale, Math.min(maxScale, n));
      }
    } catch (e) {}
    return defaultScale;
  });
  React.useEffect(() => {
    if (!storageKey) return;
    try { localStorage.setItem(storageKey, String(scale)); } catch (e) {}
  }, [scale, storageKey]);
  // Re-read when storageKey changes (ratio switch).
  React.useEffect(() => {
    if (!storageKey) return;
    try {
      const v = localStorage.getItem(storageKey);
      const n = v ? parseFloat(v) : NaN;
      setScale(!isNaN(n) ? Math.max(minScale, Math.min(maxScale, n)) : defaultScale);
    } catch (e) { setScale(defaultScale); }
  }, [storageKey]);

  const [resizing, setResizing] = React.useState(false);

  const onPointerDown = (e) => {
    if (e.button != null && e.button !== 0) return;
    e.preventDefault();
    e.stopPropagation();
    const handle = e.currentTarget;
    // Walk up to the card (closest [data-resizable-card]).
    let card = handle.parentElement;
    while (card && !card.hasAttribute('data-resizable-card')) card = card.parentElement;
    if (!card) return;
    const cardRect = card.getBoundingClientRect();
    const cardCenterX = cardRect.left + cardRect.width / 2;
    const cardCenterY = cardRect.top + cardRect.height / 2;
    const startDist = Math.hypot(e.clientX - cardCenterX, e.clientY - cardCenterY);
    if (startDist < 1) return;
    const startScale = scale;
    setResizing(true);
    const onMove = (me) => {
      const dist = Math.hypot(me.clientX - cardCenterX, me.clientY - cardCenterY);
      const next = Math.max(minScale, Math.min(maxScale, startScale * (dist / startDist)));
      setScale(next);
    };
    const onUp = () => {
      setResizing(false);
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
    };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
  };

  return { scale, resizing, onPointerDown };
}

// ResizeHandle — small corner grip rendered inside a [data-resizable-card]
// element. Hidden by default, visible on hover. Bake pipeline can hide
// these via [data-no-export].
function ResizeHandle({ onPointerDown, color = '#61D4F2', bg = 'rgba(2,16,43,0.6)' }) {
  return (
    <div
      data-no-export="1"
      data-resize-handle="1"
      onPointerDown={onPointerDown}
      title="Drag to resize"
      style={{
        position: 'absolute',
        right: -4, bottom: -4,
        width: 14, height: 14,
        cursor: 'nwse-resize',
        background: bg,
        color: color,
        display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end',
        opacity: 0.35,
        transition: 'opacity .15s',
        zIndex: 5,
        touchAction: 'none',
      }}
      onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
      onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.35'; }}
    >
      <svg width="8" height="8" viewBox="0 0 8 8" style={{ display: 'block' }}>
        <path d="M 0 8 L 8 0 M 3 8 L 8 3 M 6 8 L 8 6" stroke={color} strokeWidth="1" />
      </svg>
    </div>
  );
}

window.useResizable = useResizable;
window.ResizeHandle = ResizeHandle;

// Map compass direction strings to degrees (0 = N, clockwise).
function dirToDegrees(dir) {
  if (dir == null) return 0;
  const raw = String(dir).trim();
  // 1. Numeric — accept "45", "180°", "67.5" etc., with leading sign.
  const num = parseFloat(raw.replace(/[^\d.\-]/g, ''));
  if (raw && !isNaN(num) && /\d/.test(raw)) {
    // Normalize to [0, 360).
    return ((num % 360) + 360) % 360;
  }
  // 2. Word form — normalize "north east", "south-east", "NORTH" before
  // stripping to N/E/S/W chars. Order matters: compound forms first.
  let s = raw.toUpperCase();
  s = s
    .replace(/NORTH[\s\-]*NORTH[\s\-]*EAST/g, 'NNE')
    .replace(/NORTH[\s\-]*NORTH[\s\-]*WEST/g, 'NNW')
    .replace(/SOUTH[\s\-]*SOUTH[\s\-]*EAST/g, 'SSE')
    .replace(/SOUTH[\s\-]*SOUTH[\s\-]*WEST/g, 'SSW')
    .replace(/EAST[\s\-]*NORTH[\s\-]*EAST/g, 'ENE')
    .replace(/EAST[\s\-]*SOUTH[\s\-]*EAST/g, 'ESE')
    .replace(/WEST[\s\-]*NORTH[\s\-]*WEST/g, 'WNW')
    .replace(/WEST[\s\-]*SOUTH[\s\-]*WEST/g, 'WSW')
    .replace(/NORTH[\s\-]*EAST/g, 'NE')
    .replace(/NORTH[\s\-]*WEST/g, 'NW')
    .replace(/SOUTH[\s\-]*EAST/g, 'SE')
    .replace(/SOUTH[\s\-]*WEST/g, 'SW')
    .replace(/NORTH/g, 'N')
    .replace(/SOUTH/g, 'S')
    .replace(/EAST/g, 'E')
    .replace(/WEST/g, 'W');
  // Strip everything except NESW and look up.
  const clean = s.replace(/[^NESW]/g, '');
  const map = {
    N:0, NNE:22.5, NE:45, ENE:67.5, E:90, ESE:112.5, SE:135, SSE:157.5,
    S:180, SSW:202.5, SW:225, WSW:247.5, W:270, WNW:292.5, NW:315, NNW:337.5,
  };
  return map[clean] ?? 0;
}

// CompassArrow — SVG compass with an animated needle that swings to the
// supplied direction. Uses Web Animations API on the needle <g> so the
// swing replays whenever `dir` changes.
function CompassArrow({ dir, size, navy = '#02102B', accent = '#0F6D96' }) {
  const groupRef = React.useRef(null);
  const lastDeg = React.useRef(0);
  const rafRef = React.useRef(null);
  const bakeT = useBakeClock();
  const deg = dirToDegrees(dir);
  // Live swing (wall-clock). Skipped while baking.
  React.useEffect(() => {
    if (bakeT != null) return;
    if (!groupRef.current) return;
    const from = lastDeg.current;
    // Shortest-path delta in [-180, 180).
    const diff = ((deg - from) % 360 + 540) % 360 - 180;
    const to = from + diff;
    const start = performance.now();
    const duration = 900;
    // Brand easing (cubic-bezier-out approximation).
    const easeOut = (t) => 1 - Math.pow(1 - t, 3);
    if (rafRef.current) cancelAnimationFrame(rafRef.current);
    const tick = (now) => {
      const t = Math.min(1, (now - start) / duration);
      const angle = from + (to - from) * easeOut(t);
      if (groupRef.current) {
        // Use SVG's native transform attribute with rotate(angle cx cy) so
        // the pivot is reliably the compass center regardless of any CSS
        // transform-box / transform-origin quirks.
        groupRef.current.setAttribute('transform', `rotate(${angle} 40 40)`);
      }
      if (t < 1) rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    lastDeg.current = to;
    return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
  }, [deg, bakeT]);
  // Bake: position the needle deterministically — swing 0 → deg over 0.9s with
  // the same easing — at the current bake time.
  React.useEffect(() => {
    if (bakeT == null || !groupRef.current) return;
    const easeOut = (t) => 1 - Math.pow(1 - t, 3);
    const prog = Math.max(0, Math.min(1, bakeT / 0.9));
    const angle = deg * easeOut(prog);
    groupRef.current.setAttribute('transform', `rotate(${angle} 40 40)`);
    lastDeg.current = angle;
  }, [bakeT, deg]);
  return (
    <svg width={size} height={size} viewBox="0 0 80 80" style={{ flex: '0 0 auto' }}>
      <circle cx="40" cy="40" r="38" fill="rgba(2,16,43,0.05)" stroke={navy} strokeWidth="1" />
      <circle cx="40" cy="40" r="30" fill="none" stroke={navy} strokeWidth="0.5" opacity="0.3" />
      {/* Tick marks on the cardinal directions */}
      <g stroke={navy} strokeWidth="0.8" opacity="0.45">
        <line x1="40" y1="3"  x2="40" y2="9" />
        <line x1="77" y1="40" x2="71" y2="40" />
        <line x1="40" y1="77" x2="40" y2="71" />
        <line x1="3"  y1="40" x2="9"  y2="40" />
      </g>
      <g ref={groupRef} transform="rotate(0 40 40)">
        <path d="M 40 10 L 46 30 L 40 26 L 34 30 Z" fill={accent} />
        <line x1="40" y1="30" x2="40" y2="60" stroke={accent} strokeWidth="1.5" />
      </g>
      <text x="40" y="20" textAnchor="middle" fontSize="6" fill={navy} fontFamily="Bai Jamjuree" fontWeight="700" letterSpacing="0.15em">N</text>
    </svg>
  );
}

window.CompassArrow = CompassArrow;
window.dirToDegrees = dirToDegrees;

// ─────────────────────────────────────────────────────────────────────────
// LocatorMap — a stylized "you are here" graphic (radar/chart aesthetic):
// faint graticule, range rings, bearing ticks, crosshair, and a centre marker.
// Not a real street map — an on-brand, fully self-contained, export-safe SVG.
// `stroke`/`accent` let templates theme it (cyan on dark, navy on cream).
// ─────────────────────────────────────────────────────────────────────────
function LocatorMap({ size, stroke = '#61D4F2', accent = '#61D4F2', faint = 0.26 }) {
  const ticks = [];
  for (let i = 0; i < 24; i++) {
    const a = (i / 24) * Math.PI * 2;
    const r1 = 88, r2 = i % 6 === 0 ? 78 : 84;
    ticks.push([100 + r1 * Math.sin(a), 100 - r1 * Math.cos(a), 100 + r2 * Math.sin(a), 100 - r2 * Math.cos(a)]);
  }
  return (
    <svg width={size} height={size} viewBox="0 0 200 200" style={{ display: 'block', overflow: 'visible' }} aria-hidden="true">
      <g stroke={stroke} strokeWidth="0.6" opacity={faint * 0.55}>
        {[40, 70, 100, 130, 160].map((x) => <line key={'v' + x} x1={x} y1="14" x2={x} y2="186" />)}
        {[40, 70, 100, 130, 160].map((y) => <line key={'h' + y} x1="14" y1={y} x2="186" y2={y} />)}
      </g>
      <g fill="none" stroke={stroke}>
        <circle cx="100" cy="100" r="30" strokeWidth="1" opacity={faint + 0.2} />
        <circle cx="100" cy="100" r="56" strokeWidth="1" opacity={faint + 0.1} />
        <circle cx="100" cy="100" r="88" strokeWidth="1" opacity={faint} strokeDasharray="2 5" />
      </g>
      <g stroke={stroke} strokeWidth="1" opacity={faint + 0.25}>
        {ticks.map((t, i) => <line key={i} x1={t[0]} y1={t[1]} x2={t[2]} y2={t[3]} />)}
      </g>
      <g stroke={accent} strokeWidth="1" opacity="0.5">
        <line x1="100" y1="8" x2="100" y2="92" />
        <line x1="100" y1="108" x2="100" y2="192" />
        <line x1="8" y1="100" x2="92" y2="100" />
        <line x1="108" y1="100" x2="192" y2="100" />
      </g>
      <circle cx="100" cy="100" r="13" fill="none" stroke={accent} strokeWidth="1.6" opacity="0.9" />
      <circle cx="100" cy="100" r="4.5" fill={accent} />
    </svg>
  );
}
window.LocatorMap = LocatorMap;

// ─────────────────────────────────────────────────────────────────────────
// MiniMap — a stylised "you-are-here" map for the Locator inset card: a faint
// graticule, an organic coastline/landmass, a tiny offshore island, and a
// crisp survey marker (accuracy ring + crosshair ticks + dot) on the coast.
// Colours are themed by the caller (ink = grid/labels, accent = coast/marker,
// land = landmass fill). No backdrop-filter — it must bake in html-to-image.
// ─────────────────────────────────────────────────────────────────────────
function MiniMap({ w = 124, h = 96, ink = '#F2EFEA', accent = '#F2EFEA', land = 'rgba(242,239,234,0.05)', faint = 0.3 }) {
  const px = 80, py = 40;            // marker (viewBox units), near the coast
  // A stylised street-map fragment: roads + a small block grid + a coastline,
  // all OPEN strokes clustered around the marker and thinning toward the lower
  // left. overflow:visible lets it bleed off the corner (clipped by the Card),
  // so there's no boxy edge and no url(#mask) ref (those break html-to-image).
  const coast = 'M-8,75 C20,68 33,54 51,57 C65,59 69,46 87,44 C103,42 115,50 134,44';
  return (
    <svg width={w} height={h} viewBox="0 0 124 96" style={{ display: 'block', overflow: 'visible' }} aria-hidden="true">
      {/* water fill above the coastline (barely-there tonal shift) */}
      <path d={`${coast} L134,-14 L-8,-14 Z`} fill={land} stroke="none" />
      {/* minor street grid — clustered near the marker, slightly rotated */}
      <g stroke={ink} strokeWidth="0.5" opacity={faint * 0.7} fill="none" strokeLinecap="round" transform="rotate(-12 80 40)">
        <line x1="46" y1="4" x2="140" y2="4" />
        <line x1="44" y1="21" x2="140" y2="21" />
        <line x1="43" y1="38" x2="138" y2="38" />
        <line x1="46" y1="55" x2="120" y2="55" />
        <line x1="52" y1="-12" x2="52" y2="68" />
        <line x1="74" y1="-12" x2="74" y2="74" />
        <line x1="96" y1="-12" x2="96" y2="72" />
        <line x1="118" y1="-12" x2="118" y2="62" />
      </g>
      {/* major roads — thicker, sweeping through */}
      <g stroke={accent} fill="none" strokeLinecap="round">
        <path d="M-8,30 C30,34 56,20 88,26 C108,30 122,24 134,28" strokeWidth="1.3" opacity={faint + 0.45} />
        <path d="M58,-8 C54,26 64,56 50,104" strokeWidth="1.1" opacity={faint + 0.38} />
        <path d="M2,60 C30,54 60,62 96,41 C112,33 124,31 134,31" strokeWidth="0.9" opacity={faint + 0.3} />
      </g>
      {/* coastline — open organic curve */}
      <path d={coast} fill="none" stroke={accent} strokeWidth="1.2" strokeOpacity={faint + 0.5} strokeLinecap="round" />
      {/* survey marker on the location */}
      <g stroke={accent} fill="none">
        <circle cx={px} cy={py} r="10" strokeWidth="0.9" opacity="0.6" />
        <line x1={px} y1={py - 13} x2={px} y2={py - 5} strokeWidth="1.1" opacity="0.85" />
        <line x1={px} y1={py + 5} x2={px} y2={py + 13} strokeWidth="1.1" opacity="0.85" />
        <line x1={px - 13} y1={py} x2={px - 5} y2={py} strokeWidth="1.1" opacity="0.85" />
        <line x1={px + 5} y1={py} x2={px + 13} y2={py} strokeWidth="1.1" opacity="0.85" />
      </g>
      <circle cx={px} cy={py} r="3" fill={accent} />
    </svg>
  );
}
window.MiniMap = MiniMap;

// ─────────────────────────────────────────────────────────────────────────
// AutoFitText — single-line headline that scales its own font-size DOWN to
// fit its parent's width. Use for big Heavitas display headlines so long
// place names (e.g. "WASHINGTON") shrink to fit rather than wrap.
// Pass baseSize (px) — that's the ceiling. minScale is the lower bound.
//
// Robust against:
//   - Flex/grid layouts (sets min-width: 0 up the ancestor chain so the
//     flex item can actually shrink below its content's intrinsic width;
//     default min-width:auto prevents this and makes the auto-fit no-op).
//   - Parent resizes (ResizeObserver re-fits whenever the available width
//     changes; handles tweaks, ratio switches, mode switches).
// ─────────────────────────────────────────────────────────────────────────
// Global trim applied to every AutoFitText headline — one knob to scale the
// predominant display type across all templates (2026-05-27: -15%). Exposed on
// window so non-AutoFitText headlines (e.g. the gallery caption) can match it.
const HEADLINE_SCALE = 0.85;
window.HEADLINE_SCALE = HEADLINE_SCALE;

function AutoFitText({ children, baseSize, minScale = 0.3, style, as = 'div' }) {
  const ref = React.useRef(null);
  const base = baseSize * HEADLINE_SCALE;   // scaled ceiling — fit-to-width still applies below

  const fit = React.useCallback(() => {
    const el = ref.current;
    if (!el || !el.parentElement) return;
    // Set min-width: 0 on self + the next few ancestors so flex/grid items
    // can shrink past their intrinsic content min-width. Without this,
    // a flex item containing nowrap text refuses to go narrower than the
    // text, so parent.clientWidth == textW and the fit becomes a no-op.
    el.style.minWidth = '0';
    let p = el.parentElement;
    for (let i = 0; i < 4 && p && p !== document.body; i++) {
      p.style.minWidth = '0';
      p = p.parentElement;
    }
    // Reset to base size and measure intrinsic text width at that size.
    el.style.fontSize = base + 'px';
    const containerW = el.parentElement.clientWidth || el.parentElement.offsetWidth;
    if (!containerW || containerW < 10) return;
    const textW = el.scrollWidth;
    if (textW > containerW) {
      const scale = Math.max(minScale, (containerW / textW) * 0.97);
      el.style.fontSize = (base * scale) + 'px';
    }
  }, [base, minScale]);

  React.useLayoutEffect(() => { fit(); });

  // Re-fit when the parent's width changes (ratio swap, tweak panel changes,
  // window resize, etc.).
  React.useEffect(() => {
    if (typeof ResizeObserver === 'undefined') return;
    const el = ref.current;
    if (!el || !el.parentElement) return;
    const ro = new ResizeObserver(() => fit());
    ro.observe(el.parentElement);
    return () => ro.disconnect();
  }, [fit]);

  const Tag = as;
  return (
    <Tag ref={ref} style={{ whiteSpace: 'nowrap', ...style, fontSize: base + 'px' }}>{children}</Tag>
  );
}

window.AutoFitText = AutoFitText;
