/* ============================================================
   ClaraHAPanel — live tuning of the ClaraHA 3 ms latched parameters
   Expects: { protocolCtrl, connState, fwCompat, addLog, onApplyGroups? }
   onApplyGroups(groups, values) is the backend hook (EVK write; the
   integers come from ClaraHA.toLatchedGroups). Until it exists, Apply only
   logs which latch groups changed.
   ============================================================ */
(function () {
  const { useState, useEffect, useRef, useMemo, useCallback } = React;
  const H = window.ClaraHA;
  const C = window.HaControls;
  const { ParamSlider, Knob, HaToggle, Segmented, BandGraphEditor, fmtNum } = C;
  const P = H.BY_KEY;
  const NB = H.FIXED.n_bands;
  const EDGES = H.FIXED.band_edges_hz;
  const PREF_KEY = "femto-evk-claraha-view";

  const fmtHz = hz => (hz >= 1000 ? +(hz / 1000).toFixed(hz >= 10000 ? 0 : 1) + "k" : String(Math.round(hz)));
  const BAND_LABELS = EDGES.slice(0, -1).map((lo, i) => fmtHz(Math.sqrt(lo * EDGES[i + 1])));
  const MU_LABELS = Array.from({ length: H.FIXED.mu0_n_bands }, (_, i) => String(i + 1));
  const bandRange = b => `${fmtHz(EDGES[b])}–${fmtHz(EDGES[b + 1])} Hz`;
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
  const fmtDb = x => (x > 0 ? "+" : x < 0 ? "−" : "") + fmtNum(Math.abs(x), 1);
  const fmtMs = s => (!Number.isFinite(s) ? "∞" : s >= 1 ? fmtNum(s, 2) + " s" : fmtNum(s * 1000, 1) + " ms");

  function loadPref(def, key = PREF_KEY) { try { return localStorage.getItem(key) || def; } catch { return def; } }
  function savePref(v, key = PREF_KEY) { try { localStorage.setItem(key, v); } catch { /* storage unavailable */ } }
  const ADV_PREF_KEY = PREF_KEY + "-advanced";

  /* =================== Signal flow =================== */
  const FLOW = [
    { id: "stft",   label: "STFT" },
    { id: "rafc",   label: "RAFC",   sub: "Feedback cancel", mixKey: "rafc_remix_factor" },
    { id: "ainr",   label: "AINR",   sub: "Noise reduction", mixKey: "ainr_remix_factor" },
    { id: "wdrc",   label: "WDRC",   sub: "Compression" },
    { id: "fshift", label: "Shift",  sub: "Decorrelation", mixKey: "frequency_shifter_remix_factor" },
    { id: "istft",  label: "iSTFT" },
  ];
  function SignalFlow({ values, onJump }) {
    const W = 720, BH = 58, BW = 100, top = 8;
    const gap = (W - FLOW.length * BW) / (FLOW.length - 1);
    const unity = H.isUnity(values);
    return (
      <svg className="ha-flow" viewBox={`0 0 ${W} ${BH + top * 2}`} role="img" aria-label="Processing chain">
        <defs>
          <marker id="ha-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
            <path d="M0,0 L8,4 L0,8 z" className="ha-flow-arrowhead" />
          </marker>
        </defs>
        {FLOW.map((b, i) => {
          const x = i * (BW + gap);
          const mix = b.mixKey ? values[b.mixKey] : 1;
          const fixed = !b.sub;
          const status = b.mixKey ? (mix === 0 ? "bypassed" : Math.round(mix * 100) + "%") : b.id === "wdrc" ? (unity ? "unity curve" : "active") : "";
          const dim = b.mixKey ? mix === 0 : b.id === "wdrc" && unity;
          const strong = !fixed && !dim && mix > 0.5;
          return (
            <g key={b.id} className={"ha-flow-block" + (fixed ? " ha-flow-block--fixed" : " ha-flow-block--link") + (dim ? " ha-flow-block--dim" : "")}
              onClick={fixed ? undefined : () => onJump(b.id)} role={fixed ? undefined : "button"} tabIndex={fixed ? undefined : 0}
              onKeyDown={fixed ? undefined : e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onJump(b.id); } }}
              aria-label={fixed ? undefined : `${b.label} ${status}`} data-testid={"flow-" + b.id}>
              {i > 0 && <line x1={x - gap + 2} x2={x - 3} y1={top + BH / 2} y2={top + BH / 2} className="ha-flow-arrow" markerEnd="url(#ha-arrow)" />}
              <rect x={x} y={top} width={BW} height={BH} rx={12} className="ha-flow-rect"
                style={!fixed ? { fillOpacity: dim ? 0.06 : 0.14 + 0.86 * mix } : undefined} />
              <text x={x + BW / 2} y={top + (fixed ? BH / 2 + 5 : 24)} textAnchor="middle" className={"ha-flow-label" + (strong ? " ha-flow-label--on" : "")}>{b.label}</text>
              {!fixed && <text x={x + BW / 2} y={top + 42} textAnchor="middle" className={"ha-flow-sub" + (strong ? " ha-flow-label--on" : "")}>{status}</text>}
            </g>
          );
        })}
      </svg>
    );
  }

  /* =================== Plot helpers =================== */
  const lin = (d0, d1, r0, r1) => v => r0 + (v - d0) / (d1 - d0) * (r1 - r0);
  function ticks(lo, hi, step) { const out = []; for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(+v.toFixed(6)); return out; }

  const REGION_CLASS = { expansion: "ha-reg-exp", linear: "ha-reg-lin", compression: "ha-reg-comp", limiter: "ha-reg-lim" };
  const REGION_LABEL = { expansion: "Expansion", linear: "Linear", compression: "Compression", limiter: "Limiter" };

  /* =================== WDRC I/O curve =================== */
  function WdrcCurvePlot({ values, band }) {
    const [hover, setHover] = useState(null);
    const W = 380, Ht = 300, m = { l: 44, r: 14, t: 12, b: 36 };
    const c = H.bandCurve(values, band);
    const IN = [-100, 0];
    const xs = [];
    for (let x = IN[0]; x <= IN[1] + 1e-9; x += 0.5) xs.push(x);
    const ys = xs.map(x => H.curveOut(c, x));
    const yMax = Math.max(0, Math.ceil(Math.max(...ys) / 10) * 10);
    const yMin = Math.min(-100, Math.max(-160, Math.floor(Math.min(...ys) / 10) * 10));
    const X = lin(IN[0], IN[1], m.l, W - m.r), Y = lin(yMin, yMax, Ht - m.b, m.t);

    // Contiguous region runs along the input axis
    const runs = [];
    xs.forEach(x => {
      const r = H.regionAt(c, x);
      if (!runs.length || runs[runs.length - 1].r !== r) runs.push({ r, x0: x, x1: x });
      else runs[runs.length - 1].x1 = x;
    });
    const knees = [];
    runs.slice(1).forEach(run => knees.push({ x: run.x0, r: run.r }));
    const kneeLabel = r => ({ linear: "Te", compression: "Tc", limiter: "MPO", expansion: "Te" })[r];
    const path = xs.map((x, i) => `${i ? "L" : "M"}${X(x).toFixed(1)},${Y(clamp(ys[i], yMin, yMax)).toFixed(1)}`).join(" ");

    const onMove = e => {
      const rect = e.currentTarget.getBoundingClientRect();
      const x = IN[0] + ((e.clientX - rect.left) / rect.width * W - m.l) / (W - m.l - m.r) * (IN[1] - IN[0]);
      if (x < IN[0] || x > IN[1]) { setHover(null); return; }
      const xi = Math.round(x);
      setHover({ x: xi, y: H.curveOut(c, xi), r: H.regionAt(c, xi) });
    };

    return (
      <div className="ha-plot" data-testid="wdrc-curve">
        <div className="ha-plot-head">
          <span className="ha-plot-title">Input / output · band {band + 1}</span>
          <span className="ha-plot-meta">
            {hover ? `${fmtDb(hover.x)} → ${fmtDb(hover.y)} dBFS (${fmtDb(hover.y - hover.x)} dB) · ${REGION_LABEL[hover.r]}` : bandRange(band)}
          </span>
        </div>
        <svg viewBox={`0 0 ${W} ${Ht}`} className="ha-plot-svg" onPointerMove={onMove} onPointerLeave={() => setHover(null)}>
          {runs.map((run, i) => (
            <rect key={i} x={X(run.x0)} y={m.t} width={Math.max(0, X(Math.min(run.x1 + 0.5, IN[1])) - X(run.x0))} height={Ht - m.t - m.b} className={REGION_CLASS[run.r]} />
          ))}
          {ticks(yMin, yMax, 20).map(t => (
            <g key={"y" + t}>
              <line x1={m.l} x2={W - m.r} y1={Y(t)} y2={Y(t)} className="ha-grid" />
              <text x={m.l - 6} y={Y(t) + 4} textAnchor="end" className="ha-axis-lbl">{t}</text>
            </g>
          ))}
          {ticks(IN[0], IN[1], 20).map(t => (
            <g key={"x" + t}>
              <line y1={m.t} y2={Ht - m.b} x1={X(t)} x2={X(t)} className="ha-grid" />
              <text x={X(t)} y={Ht - m.b + 15} textAnchor="middle" className="ha-axis-lbl">{t}</text>
            </g>
          ))}
          <line x1={X(Math.max(IN[0], yMin))} y1={Y(Math.max(IN[0], yMin))} x2={X(Math.min(IN[1], yMax))} y2={Y(Math.min(IN[1], yMax))} className="ha-unity" />
          {yMax > 0 && <>
            <line x1={m.l} x2={W - m.r} y1={Y(0)} y2={Y(0)} className="ha-clip" />
            <text x={m.l + 4} y={Y(0) - 4} className="ha-clip-lbl">0 dBFS · clips</text>
          </>}
          <path d={path} className="ha-curve" />
          {knees.map((k, i) => {
            const y = H.curveOut(c, k.x);
            return (
              <g key={i}>
                <circle cx={X(k.x)} cy={Y(clamp(y, yMin, yMax))} r={4} className="ha-knee" />
                <text x={X(k.x) + 6} y={Y(clamp(y, yMin, yMax)) - 7} className="ha-knee-lbl">{kneeLabel(k.r)}</text>
              </g>
            );
          })}
          {hover && <>
            <line x1={X(hover.x)} x2={X(hover.x)} y1={m.t} y2={Ht - m.b} className="ha-cross" />
            <circle cx={X(hover.x)} cy={Y(clamp(hover.y, yMin, yMax))} r={4} className="ha-cross-dot" />
          </>}
          <text x={(m.l + W - m.r) / 2} y={Ht - 4} textAnchor="middle" className="ha-axis-title">Input level (dBFS at SPU input)</text>
          <text x={12} y={(m.t + Ht - m.b) / 2} textAnchor="middle" transform={`rotate(-90 12 ${(m.t + Ht - m.b) / 2})`} className="ha-axis-title">Output (dBFS)</text>
        </svg>
        <div className="ha-legend">
          {Object.keys(REGION_CLASS).map(r => <span key={r} className="ha-legend-item"><span className={"ha-legend-sw " + REGION_CLASS[r]} />{REGION_LABEL[r]}</span>)}
          <span className="ha-legend-item"><span className="ha-legend-line ha-legend-line--dash" />Unity</span>
        </div>
      </div>
    );
  }

  /* =================== Gain vs frequency =================== */
  const LEVELS = [
    { db: -60, label: "Soft (−60)",   cls: "ha-line-soft" },
    { db: -40, label: "Medium (−40)", cls: "ha-line-mid" },
    { db: -20, label: "Loud (−20)",   cls: "ha-line-loud" },
  ];
  function GainFreqPlot({ values, band, onSelect, compact }) {
    const W = 520, Ht = compact ? 200 : 240, m = { l: 40, r: 12, t: 12, b: 32 };
    const F0 = EDGES[0], F1 = EDGES[EDGES.length - 1];
    const lx = lin(Math.log(F0), Math.log(F1), m.l, W - m.r);
    const X = f => lx(Math.log(f));
    const gains = LEVELS.map(L => Array.from({ length: NB }, (_, b) => H.curveOut(H.bandCurve(values, b), L.db) - L.db));
    const all = gains.flat();
    const gMax = Math.max(10, Math.ceil(Math.max(...all) / 10) * 10);
    const gMin = Math.min(-10, Math.floor(Math.min(...all) / 10) * 10);
    const Y = lin(gMin, gMax, Ht - m.b, m.t);
    const step = (gMax - gMin) > 60 ? 20 : 10;
    const stepPath = g => g.map((v, b) => `${b ? "L" : "M"}${X(EDGES[b]).toFixed(1)},${Y(v).toFixed(1)} L${X(EDGES[b + 1]).toFixed(1)},${Y(v).toFixed(1)}`).join(" ");
    const onClick = e => {
      if (!onSelect) return;
      const rect = e.currentTarget.getBoundingClientRect();
      const px = (e.clientX - rect.left) / rect.width * W;
      const f = Math.exp(Math.log(F0) + (px - m.l) / (W - m.l - m.r) * (Math.log(F1) - Math.log(F0)));
      const b = EDGES.findIndex((e2, i) => i < NB && f >= e2 && f < EDGES[i + 1]);
      if (b >= 0) onSelect(b);
    };
    const fTicks = [63, 125, 250, 500, 1000, 2000, 4000, 8000];
    return (
      <div className="ha-plot" data-testid="gain-freq">
        <div className="ha-plot-head">
          <span className="ha-plot-title">Gain vs frequency</span>
          <span className="ha-plot-meta">per-band level at the SPU input{onSelect ? " · click to select a band" : ""}</span>
        </div>
        <svg viewBox={`0 0 ${W} ${Ht}`} className={"ha-plot-svg" + (onSelect ? " ha-plot-svg--click" : "")} onClick={onClick}>
          {band != null && <rect x={X(EDGES[band])} width={X(EDGES[band + 1]) - X(EDGES[band])} y={m.t} height={Ht - m.t - m.b} className="ha-band-hl" />}
          {ticks(gMin, gMax, step).map(t => (
            <g key={t}>
              <line x1={m.l} x2={W - m.r} y1={Y(t)} y2={Y(t)} className={t === 0 ? "ha-grid ha-grid--zero" : "ha-grid"} />
              <text x={m.l - 6} y={Y(t) + 4} textAnchor="end" className="ha-axis-lbl">{t > 0 ? "+" + t : t}</text>
            </g>
          ))}
          {fTicks.map(f => (
            <g key={f}>
              <line x1={X(f)} x2={X(f)} y1={m.t} y2={Ht - m.b} className="ha-grid" />
              <text x={X(f)} y={Ht - m.b + 15} textAnchor="middle" className="ha-axis-lbl">{fmtHz(f)}</text>
            </g>
          ))}
          {gains.map((g, i) => <path key={i} d={stepPath(g)} className={"ha-line " + LEVELS[i].cls} />)}
          <text x={(m.l + W - m.r) / 2} y={Ht - 3} textAnchor="middle" className="ha-axis-title">Frequency (Hz)</text>
          <text x={11} y={(m.t + Ht - m.b) / 2} textAnchor="middle" transform={`rotate(-90 11 ${(m.t + Ht - m.b) / 2})`} className="ha-axis-title">Gain (dB)</text>
        </svg>
        <div className="ha-legend">
          {LEVELS.map(L => <span key={L.db} className="ha-legend-item"><span className={"ha-legend-line " + L.cls} />{L.label} dBFS</span>)}
        </div>
      </div>
    );
  }

  /* =================== Attack / release step response ===================
     Illustrative one-pole detector (y += α(x − y) per 3 ms frame, attack α
     while rising, release α while falling) on a −60 → −20 → −60 dB step. */
  function TimeConstPlot({ attack, release }) {
    const W = 1000, Ht = 200, m = { l: 40, r: 12, t: 12, b: 30 };
    const aA = H.alphaFromTime(attack), aR = H.alphaFromTime(release);
    const pre = 20, hold = Math.max(60, Math.min(600, attack * 1000 * 6)), tail = Math.max(100, Math.min(1500, release * 1000 * 6));
    const T = pre + hold + tail, dtMs = H.DT * 1000;
    const pts = [];
    let y = -60;
    for (let t = 0; t <= T; t += dtMs) {
      const x = t < pre || t >= pre + hold ? -60 : -20;
      y += (x > y ? aA : aR) * (x - y);
      pts.push([t, x, y]);
    }
    const X = lin(0, T, m.l, W - m.r), Y = lin(-65, -15, Ht - m.b, m.t);
    const inPath = pts.map(([t, x], i) => `${i ? "L" : "M"}${X(t).toFixed(1)},${Y(x).toFixed(1)}`).join(" ");
    const detPath = pts.map(([t, , d], i) => `${i ? "L" : "M"}${X(t).toFixed(1)},${Y(d).toFixed(1)}`).join(" ");
    const tStep = T > 1000 ? 500 : T > 400 ? 100 : 50;
    return (
      <div className="ha-plot" data-testid="time-const">
        <div className="ha-plot-head">
          <span className="ha-plot-title">Detector step response</span>
          <span className="ha-plot-meta">α attack {fmtNum(aA)} · α release {fmtNum(aR)} per 3 ms frame</span>
        </div>
        <svg viewBox={`0 0 ${W} ${Ht}`} className="ha-plot-svg">
          {[-60, -40, -20].map(t => (
            <g key={t}>
              <line x1={m.l} x2={W - m.r} y1={Y(t)} y2={Y(t)} className="ha-grid" />
              <text x={m.l - 6} y={Y(t) + 4} textAnchor="end" className="ha-axis-lbl">{t}</text>
            </g>
          ))}
          {ticks(0, T, tStep).map(t => (
            <text key={t} x={X(t)} y={Ht - m.b + 15} textAnchor="middle" className="ha-axis-lbl">{t}</text>
          ))}
          <path d={inPath} className="ha-line ha-line-input" />
          <path d={detPath} className="ha-line ha-line-mid ha-line-det" />
          <text x={(m.l + W - m.r) / 2} y={Ht - 2} textAnchor="middle" className="ha-axis-title">Time (ms)</text>
        </svg>
        <div className="ha-legend">
          <span className="ha-legend-item"><span className="ha-legend-line ha-line-input" />Input level</span>
          <span className="ha-legend-item"><span className="ha-legend-line ha-line-mid" />Detector (illustrative)</span>
        </div>
      </div>
    );
  }

  /* =================== Card shell =================== */
  function Card({ title, sub, children, cardRef, actions, testId }) {
    return (
      <section className="ha-card" ref={cardRef} data-testid={testId}>
        <div className="ha-card-head">
          <div>
            <div className="ha-card-title">{title}</div>
            {sub && <div className="ha-card-sub">{sub}</div>}
          </div>
          {actions && <div className="ha-card-actions">{actions}</div>}
        </div>
        {children}
      </section>
    );
  }

  /* =================== WDRC per-parameter row ===================
     "All bands" slider: when the bands are equal it sets them all; when they
     differ it moves every band together (offset for dB values, scale for
     time constants), preserving the per-band shape. */
  function WdrcRow({ param, values, setParam, band, setBand, flags }) {
    const arr = values[param.key];
    const uniform = arr.every(x => x === arr[0]);
    const isLog = param.scale === "log";
    const mean = uniform ? arr[0] : isLog ? Math.exp(arr.reduce((s, x) => s + Math.log(x), 0) / arr.length) : arr.reduce((s, x) => s + x, 0) / arr.length;
    const onAll = v => {
      if (uniform) setParam(param.key, arr.map(() => v));
      else setParam(param.key, arr.map(x => clamp(C.snap(param, isLog ? x * v / mean : x + (v - mean)), param.bounds[0], param.bounds[1])));
    };
    return (
      <div className="ha-wrow" data-testid={"wrow-" + param.key}>
        <ParamSlider param={param} value={mean} onChange={onAll}
          label={param.label + " · all bands"}
          readout={uniform ? null : "bands differ — moves all together"} />
        <div className="ha-wrow-graph">
          <BandGraphEditor param={param} values={arr} onChange={v => setParam(param.key, v)} labels={BAND_LABELS}
            selected={band} onSelect={setBand} errorBands={flags.err} warnBands={flags.warn} />
          {!uniform && (
            <button className="fm-btn fm-btn-ghost fm-btn-sm ha-flatten" onClick={() => setParam(param.key, arr.map(() => arr[band]))}
              title={`Set every band to band ${band + 1}'s value`}>Flatten to band {band + 1}</button>
          )}
        </div>
        <div className="ha-param-hint">{param.help}</div>
      </div>
    );
  }

  /* =================== Guided tuning =================== */
  function BandNav({ band, setBand }) {
    return (
      <div className="ha-band-nav">
        <button className="fm-btn-icon" onClick={() => setBand(b => Math.max(0, b - 1))} disabled={band === 0} aria-label="Previous band">‹</button>
        <span className="ha-band-nav-lbl" data-testid="band-label">Band {band + 1} · {bandRange(band)}</span>
        <button className="fm-btn-icon" onClick={() => setBand(b => Math.min(NB - 1, b + 1))} disabled={band === NB - 1} aria-label="Next band">›</button>
      </div>
    );
  }

  const blockState = (values, key) => (values[key] === 0 ? "bypassed" : values[key] === 1 ? "on" : `${Math.round(values[key] * 100)}% mix`);

  /* Leave-at-default parameters, folded away with a reset. Expands only on
     click, so it never changes the layout during a drag. */
  function ExpertFold({ keys, values, setValues, renderParams }) {
    const changed = keys.filter(k => !H.sameValue(values[k], H.PRESETS.base.values[k]));
    return (
      <details className="ha-expert" data-testid="expert-fold">
        <summary className="ha-expert-sum">
          <span className="ha-expert-title">Expert — leave at defaults unless you have a reason</span>
          <span className={"ha-expert-count" + (changed.length ? " ha-expert-count--on" : "")}>
            {changed.length ? `${changed.length} changed from default` : "at defaults"}
          </span>
        </summary>
        <div className="ha-expert-body">
          <div className="ha-expert-actions">
            <span className="ha-param-hint">{keys.map(k => P[k].label).join(" · ")}</span>
            <button className="fm-btn fm-btn-ghost fm-btn-sm" disabled={!changed.length} data-testid="expert-reset"
              onClick={() => setValues(v => H.resetKeys(v, keys))}>Reset to defaults</button>
          </div>
          {renderParams(keys)}
        </div>
      </details>
    );
  }

  function Checklist({ tests, checked, onToggle, stepId }) {
    return (
      <div className="ha-checklist" data-testid={"checklist-" + stepId}>
        <div className="ha-eyebrow">Test</div>
        {tests.map((t, i) => (
          <label key={i} className="ha-check">
            <input type="checkbox" checked={!!checked[i]} onChange={() => onToggle(i)} />
            <span>{t}</span>
          </label>
        ))}
      </div>
    );
  }

  const BLOCK_LABEL = { rafc_remix_factor: "RAFC", ainr_remix_factor: "AINR", frequency_shifter_remix_factor: "Shifter" };
  const BLOCK_ID = { rafc_remix_factor: "rafc", ainr_remix_factor: "ainr", frequency_shifter_remix_factor: "fshift" };
  const mixSub = v => (v === 0 ? "Bypassed" : v === 1 ? "On · 100%" : `On · ${Math.round(v * 100)}% mix`);

  /* Per-block bypass switches for the step's test. On = processing; off
     bypasses (mix 0) and switching back on restores the previous mix, so a
     blend survives an A/B. Fixed width, so the changing label moves nothing. */
  function StepBlocks({ step, values, setBypass }) {
    if (!step.blocks || !step.blocks.length) return null;
    return (
      <div className="ha-step-blocks" data-testid={"blocks-" + step.id}>
        {step.blocks.map(k => (
          <HaToggle key={k} checked={values[k] > 0} onChange={on => setBypass(k, !on)}
            label={BLOCK_LABEL[k]} sub={mixSub(values[k])} testId={"block-" + BLOCK_ID[k]} />
        ))}
        <span className="ha-param-hint">{step.blocksNote}</span>
      </div>
    );
  }

  /* Step 5: the whole chain at a glance, plus apply/export. Contains no
     sliders, so its changing summaries never move anything under the cursor. */
  function ReviewSummary({ values, check, dirty, groupLabel, actions }) {
    const outs = Array.from({ length: NB }, (_, b) => H.curveOut(H.bandCurve(values, b), 0));
    const peak = Math.max(...outs), peakBand = outs.indexOf(peak);
    const changedFromBase = H.SCHEMA.filter(p => !H.sameValue(values[p.key], H.PRESETS.base.values[p.key]));
    const issues = [...check.errors.map(e => ({ ...e, lvl: "err" })), ...check.warnings.map(w => ({ ...w, lvl: "warn" }))];
    return (
      <div className="ha-review" data-testid="review">
        <div className="ha-review-grid">
          <div className="ha-review-item"><span className="ha-eyebrow">RAFC</span><span>{blockState(values, "rafc_remix_factor")}</span></div>
          <div className="ha-review-item"><span className="ha-eyebrow">AINR</span><span>{blockState(values, "ainr_remix_factor")}</span></div>
          <div className="ha-review-item"><span className="ha-eyebrow">Shifter</span><span>{blockState(values, "frequency_shifter_remix_factor")}</span></div>
          <div className="ha-review-item"><span className="ha-eyebrow">WDRC</span><span>{H.isUnity(values) ? "unity curve" : "active"}</span></div>
          <div className="ha-review-item">
            <span className="ha-eyebrow">Full-scale input →</span>
            <span className={peak > 0 ? "ha-review-bad" : ""}>{fmtDb(peak)} dBFS peak (band {peakBand + 1})</span>
          </div>
          <div className="ha-review-item" title={dirty.map(groupLabel).join(", ") || undefined}>
            <span className="ha-eyebrow">Pending</span><span>{dirty.length ? `${dirty.length} of ${H.GROUPS.length} latch groups` : "nothing"}</span>
          </div>
        </div>
        <div className="ha-review-sec">
          <div className="ha-eyebrow">Changed from Base ({changedFromBase.length})</div>
          <div className="ha-param-hint">{changedFromBase.length ? changedFromBase.map(p => p.label).join(" · ") : "Nothing — this is the Base preset."}</div>
        </div>
        <div className="ha-review-sec">
          <div className="ha-eyebrow">Issues ({issues.length})</div>
          {issues.length
            ? issues.map((m, i) => <div key={i} className={"ha-msg " + (m.lvl === "err" ? "ha-msg--err" : "ha-msg--warn")}>{m.msg}</div>)
            : <div className="ha-param-hint">None.</div>}
        </div>
        <div className="ha-review-actions">{actions}</div>
      </div>
    );
  }

  /* =================== Panel =================== */
  function ClaraHAPanel({ protocolCtrl, connState, fwCompat, addLog, onApplyGroups }) {
    const [values, setValues]   = useState(() => H.presetValues("base"));
    const [applied, setApplied] = useState(null); // last values handed to the backend; null = never
    const [view, setViewState]  = useState(() => loadPref("simple"));
    const [live, setLive]       = useState(false);
    const [band, setBand]       = useState(9);
    const [wdrcTab, setWdrcTab] = useState("curve");
    const [advMode, setAdvModeState] = useState(() => loadPref("guided", ADV_PREF_KEY)); // "guided" | "all"
    const [stepId, setStepId]   = useState(H.STEPS[0].id);
    const [checks, setChecks]   = useState({}); // { stepId: [bool, ...] } — in memory only
    const guidedRef = useRef(null);
    const [notice, setNotice]   = useState(null); // { kind: 'error'|'ok', text }
    const [busy, setBusy]       = useState(false);
    const basePresetRef = useRef("base");
    const fileRef = useRef(null);
    const refs = { rafc: useRef(null), ainr: useRef(null), wdrc: useRef(null), fshift: useRef(null) };

    const isOpen = connState === "open";
    const preset = useMemo(() => H.matchPreset(values), [values]);
    const check  = useMemo(() => H.validate(values), [values]);
    const dirty  = useMemo(() => H.diffGroups(applied, values), [applied, values]);
    const advanced = view === "advanced";
    // Out-of-recommended-range warnings are shown on the slider itself (amber
    // track and number box), so the issues list only carries the rest.
    const errors = check.errors;
    const warnings = check.warnings.filter(w => w.kind !== "rec");
    const [showIssues, setShowIssues] = useState(false);
    const issuesRef = useRef(null);
    useEffect(() => {
      if (!showIssues) return;
      const close = e => { if (issuesRef.current && !issuesRef.current.contains(e.target)) setShowIssues(false); };
      const esc = e => { if (e.key === "Escape") setShowIssues(false); };
      document.addEventListener("pointerdown", close);
      document.addEventListener("keydown", esc);
      return () => { document.removeEventListener("pointerdown", close); document.removeEventListener("keydown", esc); };
    }, [showIssues]);
    useEffect(() => { if (!errors.length && !warnings.length) setShowIssues(false); }, [errors.length, warnings.length]);

    const setView = v => { setViewState(v); savePref(v); };
    const setAdvMode = v => { setAdvModeState(v); savePref(v, ADV_PREF_KEY); };
    const guided = advanced && advMode === "guided";
    const setParam = useCallback((key, val) => setValues(v => ({ ...v, [key]: val })), []);
    // Bypass = mix 0, remembering the mix it had so un-bypassing restores a blend.
    const lastMixRef = useRef({});
    const setBypass = useCallback((key, bypass) => setValues(v => {
      if (bypass) {
        if (v[key] > 0) lastMixRef.current[key] = v[key];
        return { ...v, [key]: 0 };
      }
      return { ...v, [key]: lastMixRef.current[key] || 1 };
    }), []);

    const flagsFor = key => {
      const pick = list => list.filter(e => e.bands && (e.key === key || (e.key === "wdrc_band_output_gain" && key === "wdrc_band_input_gain" && /60 dB/.test(e.msg)))).flatMap(e => e.bands);
      return { err: pick(check.errors), warn: pick(check.warnings) };
    };

    /* ---- Apply (stub until the float->int + EVK backend exists) ---- */
    const apply = useCallback(async () => {
      if (!check.ok || !dirty.length || busy) return;
      const snapshot = H.clone(values);
      const groups = dirty;
      setBusy(true);
      try {
        if (onApplyGroups) await onApplyGroups(groups, snapshot);
        else addLog && addLog(`ClaraHA: ${groups.length} latch group${groups.length > 1 ? "s" : ""} changed — ${groups.join(", ")} (not sent: EVK backend not wired yet)`);
        setApplied(snapshot);
      } catch (e) {
        setNotice({ kind: "error", text: "Apply failed: " + e.message });
      } finally {
        setBusy(false);
      }
    }, [check.ok, dirty, busy, values, onApplyGroups, addLog]);

    const applyRef = useRef(apply);
    applyRef.current = apply;
    useEffect(() => {
      if (!live || !check.ok || !dirty.length) return;
      const t = setTimeout(() => applyRef.current(), 150);
      return () => clearTimeout(t);
    }, [live, values, check.ok, dirty.length]);

    /* ---- Presets, import, export ---- */
    const choosePreset = id => {
      if (id === "custom") return;
      basePresetRef.current = id;
      setValues(H.presetValues(id));
      setNotice(null);
    };
    const reset = () => { setValues(H.presetValues(basePresetRef.current)); setNotice(null); };
    const download = (filename, text, type) => {
      const url = URL.createObjectURL(new Blob([text], { type }));
      const a = document.createElement("a");
      a.href = url; a.download = filename;
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
    };
    const exportYaml = () => {
      const name = preset ? `claraha_3ms_${preset}` : "claraha_3ms_custom";
      download(name + ".yaml", H.toYaml(values, preset ? H.PRESETS[preset].label + " preset" : "custom configuration"), "text/yaml");
    };
    // Integer latch groups: the same file float_to_int_converter.py writes.
    const exportLatched = () => {
      try {
        download("latched_params.json", H.toLatchedJson(values), "application/json");
      } catch (e) {
        setNotice({ kind: "error", text: "Cannot convert: " + e.message });
      }
    };
    const onImport = async e => {
      const f = e.target.files && e.target.files[0];
      e.target.value = "";
      if (!f) return;
      try {
        const next = H.parseConfig(await f.text());
        setValues(next);
        setNotice({ kind: "ok", text: `Loaded ${f.name}` });
      } catch (err) {
        setNotice({ kind: "error", text: `Could not load ${f.name}: ${err.message}` });
      }
    };

    // Signal-flow click: open that block's step (guided) or card (all parameters).
    const STEP_OF_BLOCK = { wdrc: "fit", rafc: "feedback", fshift: "feedback", ainr: "noise" };
    const jump = id => {
      const toGuided = advanced ? advMode === "guided" : loadPref("guided", ADV_PREF_KEY) === "guided";
      if (toGuided) setStepId(STEP_OF_BLOCK[id]);
      const target = toGuided ? guidedRef : refs[id];
      const go = () => target && target.current && target.current.scrollIntoView({ behavior: "smooth", block: "start" });
      if (!advanced) { setView("advanced"); setTimeout(go, 50); } else go();
    };

    /* ---- One control per parameter, shared by both Advanced layouts ---- */
    const readoutFor = k => ({
      rafc_ref_power_smoothing_factor: "τ ≈ " + fmtMs(H.timeFromSmoothing(values.rafc_ref_power_smoothing_factor)),
      rafc_norm_gain_smoothing_factor: `τ ≈ ${fmtMs(H.timeFromSmoothing(values.rafc_norm_gain_smoothing_factor))} · β = ${H.normBetaInt(values.rafc_norm_gain_smoothing_factor)}`,
      frequency_shifter_start_freq: `→ bin ${H.startBin(values.frequency_shifter_start_freq)} (${fmtNum(H.snapToBin(values.frequency_shifter_start_freq), 1)} Hz)`,
    })[k];
    const muEditor = () => (
      <div className="ha-sub" key="mu">
        <div className="ha-sub-head">
          <span className="ha-param-label">{P.rafc_per_band_step_size.label} per band</span>
          <span className="ha-param-readout">Low → high frequency · snaps to powers of two · drag to paint</span>
        </div>
        <BandGraphEditor param={P.rafc_per_band_step_size} values={values.rafc_per_band_step_size}
          onChange={v => setParam("rafc_per_band_step_size", v)} labels={MU_LABELS}
          height={110} testId="mu-graph" />
        <div className="ha-param-hint">{P.rafc_per_band_step_size.help}</div>
      </div>
    );
    const renderParams = keys => {
      const scalars = keys.filter(k => !P[k].perBand);
      const lists = keys.filter(k => P[k].perBand);
      return <>
        {scalars.length > 0 && (
          <div className="ha-grid2">
            {scalars.map(k => <ParamSlider key={k} param={P[k]} value={values[k]} onChange={v => setParam(k, v)} readout={readoutFor(k)} hint={P[k].help} />)}
          </div>
        )}
        {lists.includes("wdrc_attack_times") && <TimeConstPlot attack={values.wdrc_attack_times[band]} release={values.wdrc_release_times[band]} />}
        {lists.map(k => k === "rafc_per_band_step_size" ? muEditor()
          : <WdrcRow key={k} param={P[k]} values={values} setParam={setParam} band={band} setBand={setBand} flags={flagsFor(k)} />)}
      </>;
    };

    /* ---- Guided steps ---- */
    const stepIdx = H.STEPS.findIndex(s => s.id === stepId);
    const step = H.STEPS[stepIdx];
    const stepDone = s => s.tests.every((_, i) => checks[s.id] && checks[s.id][i]);
    const toggleCheck = (sid, i) => setChecks(c => {
      const list = (c[sid] || []).slice(); list[i] = !list[i];
      return { ...c, [sid]: list };
    });
    const goStep = i => { setStepId(H.STEPS[i].id); guidedRef.current && guidedRef.current.scrollIntoView({ block: "start" }); };

    /* ---- Simple-view derived controls ---- */
    const og = values.wdrc_band_output_gain;
    const volume = Math.round(og.reduce((s, x) => s + x, 0) / NB * 2) / 2;
    const volParam = P.wdrc_band_output_gain;
    const setVolume = v => {
      const d = v - volume;
      setParam("wdrc_band_output_gain", og.map(x => clamp(x + d, volParam.bounds[0], volParam.bounds[1])));
    };
    const ratios = values.wdrc_compression_ratio;
    const compMode = ratios.every(r => r === ratios[0]) ? ({ 1: "off", 2: "mild", 3: "strong" })[ratios[0]] || "custom" : "custom";
    const nrParam = { ...P.ainr_remix_factor, label: "Noise reduction", units: "%", display: 100, step: 0.01 };

    const groupLabel = id => H.GROUPS.find(g => g.id === id).label;
    const presetOptions = [
      ...Object.entries(H.PRESETS).map(([id, p]) => ({ id, label: p.label, title: p.desc })),
      { id: "custom", label: "Custom", ghost: true, disabled: preset !== null, title: "Edited values that match no preset" },
    ];

    return (
      <div className="ha-wrap" data-testid="claraha-panel">
        {/* Header */}
        <div className="ha-header">
          <div className="ha-header-left">
            <div className="ha-title">ClaraHA 3 ms · latched parameters</div>
            <div className="ha-meta">
              16 kHz · 48-sample hop · 24 WDRC bands · 8 latch groups ·{" "}
              {isOpen ? <span className="ha-conn ha-conn--on">EVK connected — sending not wired yet</span> : <span className="ha-conn">No device — preview mode</span>}
            </div>
          </div>
          <div className="ha-header-actions">
            {advanced && (
              <Segmented ariaLabel="Advanced layout" small value={advMode} onChange={setAdvMode} testId="adv-mode"
                options={[{ id: "guided", label: "Guided" }, { id: "all", label: "All parameters" }]} />
            )}
            <Segmented ariaLabel="View" small options={[{ id: "simple", label: "Simple" }, { id: "advanced", label: "Advanced" }]} value={view} onChange={setView} testId="view-toggle" />
          </div>
        </div>

        {/* Preset + apply bar */}
        <div className="ha-bar">
          <div className="ha-bar-group">
            <span className="ha-eyebrow">Preset</span>
            <Segmented ariaLabel="Preset" options={presetOptions} value={preset || "custom"} onChange={choosePreset} testId="preset" />
          </div>
          <div className="ha-bar-group ha-bar-group--right">
            <button className="fm-btn fm-btn-ghost fm-btn-sm" onClick={reset} title={`Revert to the ${H.PRESETS[basePresetRef.current].label} preset`}>Reset</button>
            <button className="fm-btn fm-btn-ghost fm-btn-sm" onClick={() => fileRef.current.click()}>Import</button>
            <input ref={fileRef} type="file" accept=".yaml,.yml,.json" style={{ display: "none" }} onChange={onImport} data-testid="import-input" />
            <button className="fm-btn fm-btn-ghost fm-btn-sm" onClick={exportYaml} data-testid="export-yaml"
              title="Floating-point config, the input to float_to_int_converter.py">Export YAML</button>
            <button className="fm-btn fm-btn-ghost fm-btn-sm" onClick={exportLatched} disabled={!check.ok} data-testid="export-latched"
              title={check.ok ? "Integer latch groups for the SPU — the same file float_to_int_converter.py writes" : "Fix the errors above to export"}>
              Export latched_params.json
            </button>
          </div>
        </div>

        <div className="ha-apply">
          <HaToggle checked={live} onChange={setLive} label="Live apply" sub="Send changed groups as you edit" testId="live-toggle" />
          <div className="ha-dirty" data-testid="dirty-groups" title={dirty.map(groupLabel).join(", ") || undefined}>
            {dirty.length === 0
              ? <span className="ha-dirty-none">All changes applied</span>
              : <>
                  <span className="ha-eyebrow">{applied === null ? "Not yet applied" : "Pending"} ({dirty.length})</span>
                  {dirty.map(id => <span key={id} className="ha-chip" data-group={id}>{groupLabel(id)}</span>)}
                </>}
          </div>
          <div className="ha-issues" ref={issuesRef}>
            <button type="button" data-testid="issues-btn" aria-expanded={showIssues}
              className={"ha-issues-btn" + (errors.length ? " ha-issues-btn--err" : warnings.length ? " ha-issues-btn--warn" : "")}
              disabled={!errors.length && !warnings.length} onClick={() => setShowIssues(s => !s)}>
              {errors.length || warnings.length
                ? [errors.length && `${errors.length} error${errors.length > 1 ? "s" : ""}`, warnings.length && `${warnings.length} warning${warnings.length > 1 ? "s" : ""}`].filter(Boolean).join(" · ")
                : "No issues"}
            </button>
            {/* Overlay, not in the page flow: issues come and go while a
                slider is dragged and must never move the controls. */}
            {showIssues && (errors.length > 0 || warnings.length > 0) && (
              <div className="ha-issues-pop" role="dialog" aria-label="Issues" data-testid="messages">
                {errors.map((e, i) => <div key={"e" + i} className="ha-msg ha-msg--err" data-testid="msg-error">{e.msg}</div>)}
                {warnings.map((w, i) => <div key={"w" + i} className="ha-msg ha-msg--warn">{w.msg}</div>)}
              </div>
            )}
          </div>
          <button className="fm-btn fm-btn-primary" disabled={!check.ok || !dirty.length || busy || live} onClick={apply} data-testid="apply-btn"
            title={!check.ok ? "Fix the errors first (see the issues button)" : undefined}>
            Apply{dirty.length ? ` (${dirty.length})` : ""}
          </button>
        </div>

        {notice && (
          <div className="ha-msgs">
            <div className={"ha-msg " + (notice.kind === "error" ? "ha-msg--err" : "ha-msg--ok")}>
              {notice.text}<button className="audio-ctrl-dismiss" onClick={() => setNotice(null)}>✕</button>
            </div>
          </div>
        )}

        <div className="ha-flow-card"><SignalFlow values={values} onJump={jump} /></div>

        {/* Quick controls (both views) */}
        <div className="ha-row">
          <Card title="Quick controls" sub="The everyday settings. Advanced view exposes every parameter." testId="quick-controls">
            <div className="ha-quick">
              <Knob value={volume} min={volParam.ui[0]} max={volParam.ui[1]} step={0.5} onChange={setVolume}
                label="Volume" format={v => fmtDb(v) + " dB"} testId="volume-knob" />
              <div className="ha-quick-controls">
                <ParamSlider param={nrParam} value={values.ainr_remix_factor} onChange={v => setParam("ainr_remix_factor", v)} testId="quick-nr" />
                <div className="ha-quick-toggles">
                  <HaToggle checked={values.rafc_remix_factor > 0} onChange={c => setBypass("rafc_remix_factor", !c)}
                    label="Feedback cancellation" sub={mixSub(values.rafc_remix_factor)} testId="quick-rafc" />
                  <HaToggle checked={values.frequency_shifter_remix_factor > 0} onChange={c => setBypass("frequency_shifter_remix_factor", !c)}
                    label="Feedback decorrelation" sub={mixSub(values.frequency_shifter_remix_factor)} testId="quick-fshift" />
                </div>
                <div className="ha-param">
                  <div className="ha-param-head"><span className="ha-param-label">Compression</span></div>
                  <Segmented ariaLabel="Compression" testId="quick-comp" value={compMode}
                    onChange={id => setParam("wdrc_compression_ratio", Array(NB).fill({ off: 1, mild: 2, strong: 3 }[id]))}
                    options={[{ id: "off", label: "Off" }, { id: "mild", label: "Mild 2:1" }, { id: "strong", label: "Strong 3:1" },
                      ...(compMode === "custom" ? [{ id: "custom", label: "Custom", ghost: true, disabled: true }] : [])]} />
                </div>
              </div>
            </div>
            <div className="ha-param-hint">Volume shifts every band's output gain together, keeping the per-band shape. Double-click the knob to centre it.</div>
          </Card>
          {!advanced && (
            <Card title="Response" sub="How much gain each band applies at soft, medium and loud input levels.">
              <GainFreqPlot values={values} compact />
            </Card>
          )}
        </div>

        {guided && (
          <section className="ha-card ha-guided" ref={guidedRef} data-testid="guided">
            <div className="ha-guided-head">
              <Segmented ariaLabel="Tuning step" testId="step-rail" value={stepId} onChange={setStepId}
                options={H.STEPS.map((s, i) => ({ id: s.id, label: `${i + 1} ${s.title}${stepDone(s) ? " ✓" : ""}` }))} />
              <span className="ha-param-hint">Do on the host first: align the reference delay, set the upstream gain, fixed EQ.</span>
            </div>
            <div className="ha-step-head">
              <div>
                <div className="ha-step-title" data-testid="step-title">Step {stepIdx + 1} of {H.STEPS.length} · {step.heading}</div>
                <div className="ha-card-sub">{step.goal}</div>
              </div>
              {step.plots.length > 0 && <BandNav band={band} setBand={setBand} />}
            </div>
            <StepBlocks step={step} values={values} setBypass={setBypass} />
            {step.plots.length > 0 && (
              <div className={"ha-plots" + (step.plots.length === 1 ? " ha-plots--one" : "")}>
                {step.plots.includes("curve") && <WdrcCurvePlot values={values} band={band} />}
                {step.plots.includes("gain") && <GainFreqPlot values={values} band={band} onSelect={setBand} />}
              </div>
            )}
            {renderParams(step.primary)}
            {step.id === "noise" && (
              <div className="ha-step-setup">
                <button className="fm-btn fm-btn-ghost fm-btn-sm" onClick={() => setParam("ainr_remix_factor", H.PRESETS.tv_mode.values.ainr_remix_factor)}>
                  Use the TV-mode amount ({H.PRESETS.tv_mode.values.ainr_remix_factor})
                </button>
                <span className="ha-param-hint">The TV preset's lighter denoising, for when full strength sounds processed.</span>
              </div>
            )}
            {step.review && (
              <ReviewSummary values={values} check={check} dirty={dirty} groupLabel={groupLabel} actions={<>
                <button className="fm-btn fm-btn-ghost fm-btn-sm" onClick={exportYaml}>Export YAML</button>
                <button className="fm-btn fm-btn-ghost fm-btn-sm" onClick={exportLatched} disabled={!check.ok}>Export latched_params.json</button>
                <button className="fm-btn fm-btn-primary fm-btn-sm" onClick={apply} disabled={!check.ok || !dirty.length || busy || live}>
                  Apply{dirty.length ? ` (${dirty.length})` : ""}
                </button>
              </>} />
            )}
            {step.expert.length > 0 && <ExpertFold key={step.id} keys={step.expert} values={values} setValues={setValues} renderParams={renderParams} />}
            <Checklist tests={step.tests} checked={checks[step.id] || []} onToggle={i => toggleCheck(step.id, i)} stepId={step.id} />
            <div className="ha-step-nav">
              <button className="fm-btn fm-btn-ghost fm-btn-sm" disabled={stepIdx === 0} onClick={() => goStep(stepIdx - 1)} data-testid="step-prev">‹ Previous</button>
              <button className="fm-btn fm-btn-primary fm-btn-sm" disabled={stepIdx === H.STEPS.length - 1} onClick={() => goStep(stepIdx + 1)} data-testid="step-next">
                {stepIdx < H.STEPS.length - 1 ? `Next: ${H.STEPS[stepIdx + 1].title} ›` : "Done"}
              </button>
            </div>
          </section>
        )}

        {advanced && advMode === "all" && <>
          {/* Remix factors */}
          <Card title="Remix factors" sub="Wet/dry blend per block: 1 = processed, 0 = bypassed." cardRef={refs.ainr} testId="card-remix">
            <div className="ha-grid2">
              <ParamSlider param={P.rafc_remix_factor} value={values.rafc_remix_factor} onChange={v => setParam("rafc_remix_factor", v)} hint={P.rafc_remix_factor.help} />
              <ParamSlider param={P.ainr_remix_factor} value={values.ainr_remix_factor} onChange={v => setParam("ainr_remix_factor", v)} hint={P.ainr_remix_factor.help} />
              <ParamSlider param={P.frequency_shifter_remix_factor} value={values.frequency_shifter_remix_factor} onChange={v => setParam("frequency_shifter_remix_factor", v)} hint={P.frequency_shifter_remix_factor.help} />
              <ParamSlider param={P.wdrc_input_crossfade_factor} value={values.wdrc_input_crossfade_factor} onChange={v => setParam("wdrc_input_crossfade_factor", v)} hint={P.wdrc_input_crossfade_factor.help} />
            </div>
          </Card>

          {/* RAFC */}
          <Card title="Feedback cancellation (RAFC)" sub="Normalised adaptive filter that models the receiver → mic path and subtracts it. Shaded track = recommended range." cardRef={refs.rafc} testId="card-rafc">
            <div className="ha-grid2">
              <ParamSlider param={P.rafc_ref_power_smoothing_factor} value={values.rafc_ref_power_smoothing_factor}
                onChange={v => setParam("rafc_ref_power_smoothing_factor", v)}
                readout={"τ ≈ " + fmtMs(H.timeFromSmoothing(values.rafc_ref_power_smoothing_factor))} hint={P.rafc_ref_power_smoothing_factor.help} />
              <ParamSlider param={P.rafc_stability_constant} value={values.rafc_stability_constant}
                onChange={v => setParam("rafc_stability_constant", v)} hint={P.rafc_stability_constant.help} />
              <ParamSlider param={P.rafc_norm_gain_smoothing_factor} value={values.rafc_norm_gain_smoothing_factor}
                onChange={v => setParam("rafc_norm_gain_smoothing_factor", v)}
                readout={`τ ≈ ${fmtMs(H.timeFromSmoothing(values.rafc_norm_gain_smoothing_factor))} · β = ${H.normBetaInt(values.rafc_norm_gain_smoothing_factor)}`}
                hint={P.rafc_norm_gain_smoothing_factor.help} />
              <ParamSlider param={P.rafc_norm_stability_constant} value={values.rafc_norm_stability_constant}
                onChange={v => setParam("rafc_norm_stability_constant", v)} hint={P.rafc_norm_stability_constant.help} />
            </div>
            {muEditor()}
          </Card>

          {/* Frequency shifter */}
          <Card title="Frequency shifter" sub="A small frequency shift above a start frequency breaks the loop's phase coherence, giving more gain before feedback." cardRef={refs.fshift} testId="card-fshift">
            <div className="ha-grid2">
              <ParamSlider param={P.frequency_shifter_start_freq} value={values.frequency_shifter_start_freq}
                onChange={v => setParam("frequency_shifter_start_freq", v)}
                readout={`→ bin ${H.startBin(values.frequency_shifter_start_freq)} (${fmtNum(H.snapToBin(values.frequency_shifter_start_freq), 1)} Hz)`}
                hint={P.frequency_shifter_start_freq.help} />
              <ParamSlider param={P.frequency_shifter_shift} value={values.frequency_shifter_shift}
                onChange={v => setParam("frequency_shifter_shift", v)} hint={P.frequency_shifter_shift.help} />
            </div>
          </Card>

          {/* WDRC */}
          <Card title="Compression (WDRC)" sub="24-band compressor/expander. Thresholds are peak dBFS at the SPU input." cardRef={refs.wdrc} testId="card-wdrc"
            actions={<BandNav band={band} setBand={setBand} />}>
            <div className="ha-plots">
              <WdrcCurvePlot values={values} band={band} />
              <GainFreqPlot values={values} band={band} onSelect={setBand} />
            </div>
            <Segmented ariaLabel="WDRC section" value={wdrcTab} onChange={setWdrcTab} testId="wdrc-tab"
              options={[{ id: "curve", label: "Curve" }, { id: "gain", label: "Gain" }, { id: "timing", label: "Timing" }]} />
            {wdrcTab === "curve" && <>
              {["wdrc_expansion_threshold", "wdrc_expansion_ratio", "wdrc_compression_threshold", "wdrc_compression_ratio", "wdrc_limiter_threshold"].map(k => (
                <WdrcRow key={k} param={P[k]} values={values} setParam={setParam} band={band} setBand={setBand} flags={flagsFor(k)} />
              ))}
              <div className="ha-param-hint">This build has no upper-linear region: compression continues up to the limiter.</div>
            </>}
            {wdrcTab === "gain" && ["wdrc_band_input_gain", "wdrc_band_output_gain"].map(k => (
              <WdrcRow key={k} param={P[k]} values={values} setParam={setParam} band={band} setBand={setBand} flags={flagsFor(k)} />
            ))}
            {wdrcTab === "timing" && <>
              <TimeConstPlot attack={values.wdrc_attack_times[band]} release={values.wdrc_release_times[band]} />
              {["wdrc_attack_times", "wdrc_release_times"].map(k => (
                <WdrcRow key={k} param={P[k]} values={values} setParam={setParam} band={band} setBand={setBand} flags={flagsFor(k)} />
              ))}
            </>}
          </Card>
        </>}
      </div>
    );
  }

  window.ClaraHAPanel = ClaraHAPanel;
})();
