// force-workspace.jsx  v1  —  测力台工作区外壳（FORCE-WS-1a 骨架）
// Direction: docs/decisions/2026-07-10-force-workspace-redesign.md (§2.1 approved,
// §3' 修订, §4/§4' rulings) + the FINALIZED mockup _mockup-force-workspace.html
// (user 定稿 2026-07-10). Study both before touching this file.
//
// ── SCOPE (this slice = skeleton ONLY) ───────────────────────────────────────
//  This file owns the workspace CHROME: header (测力台工作区 + 类型 pills read from
//  ForceSessionSource.listTestTypes + per-type session-count badge + '+ 注册即入'
//  ghost pill) and the mode subnav (采集·分析 · 纵向 · 对比 · 剖析). It mounts the
//  EXISTING force surfaces UNCHANGED as {children} (app-view-renderers' ForceViewDispatch
//  keeps threading every panel's current props). Type + mode are DERIVED from the
//  current view id — the 10 legacy force view ids each map to a workspace (type,mode)
//  cell, so every old deep link / setView() call still lands in the right place.
//
//  NOT this slice (WS-1b / later, per direction §4'): the mockup's 采集/分析 split
//  (采集·分析 is combined here — it hosts the one existing type panel that already
//  contains both), the longitudinal athlete-compare overlay generalization, and the
//  分析面 formulaTip hovers. This shell is a pure re-frame; panel internals are byte-
//  untouched (cmj.jsx/sj.jsx/imtp.jsx/dsi.jsx unmodified).
//
// ── RED LINES ────────────────────────────────────────────────────────────────
//  The chrome only navigates; it computes nothing about an athlete and carries no
//  inference / scoring / advisory vocabulary (see the banned-string gate).
(function () {
  // ── view-id ⇆ (type, mode) mapping — the redirect layer ─────────────────────
  //  采集·分析 shares one pane per type today (upload + analysis live in the same
  //  panel), so cmj / sj / imtp AND the standalone cmj-session all resolve to the
  //  'analyze' mode. The mockup's 采集/分析 split is WS-1b (panel restructuring).
  const TYPE_BY_VIEW = {
    'cmj': 'cmj', 'sj': 'sj', 'imtp': 'imtp',
    'cmj-session': 'cmj',
    'cmj-longitudinal': 'cmj', 'sj-longitudinal': 'sj', 'imtp-longitudinal': 'imtp',
  };
  const MODE_BY_VIEW = {
    'cmj': 'analyze', 'sj': 'analyze', 'imtp': 'analyze',
    'cmj-session': 'analyze',
    'cmj-longitudinal': 'long', 'sj-longitudinal': 'long', 'imtp-longitudinal': 'long',
    'force-compare': 'compare',
    'dsi': 'profile',
  };
  // Cross-type modes carry no single type; pills dim (mockup behavior).
  const CROSS_TYPE_MODES = { compare: true, profile: true };

  // FORCE-WS-3a (2026-07-12): 采集 / 分析 split into two SIBLING modes (FORCE-EXIT
  // F3). Both resolve to the type's own view id (cmj/sj/imtp) — the collect↔analyze
  // distinction is the workspace `captureMode` (persisted via app.jsx `_forceCaptureMode`,
  // mirroring the `_forceView` idiom), NOT a new route. So the routeTable / deep links /
  // hash aliases stay byte-untouched (core/ is protected). 采集 mounts the existing
  // upload panel (unchanged); 分析 mounts the new ForceAnalysisFace (defined below).
  const MODES = [
    { id: 'collect', label: '采集' },
    { id: 'analyze', label: '分析' },
    { id: 'long', label: '纵向' },
    { id: 'compare', label: '对比', xt: '跨类型' },
    { id: 'profile', label: '剖析', xt: '跨类型' },
  ];
  // Approved 2026-07-22 information architecture: five implementation modes stay
  // addressable for compatibility, but the user-facing interface has only two tasks.
  // Capture is an action that creates a session; long/compare/profile are lenses of
  // one exploration task. This keeps the deep-link seam stable while shrinking the
  // navigation interface users must learn.
  const WORKSPACE_TASKS = [
    { id: 'session', label: '会话分析' },
    { id: 'explore', label: '纵向与对比' },
  ];
  const EXPLORE_MODE_IDS = ['long', 'compare', 'profile'];
  const EXPLORE_LABELS = { long: '纵向', compare: '运动员对比', profile: '力量剖析' };

  // (type, mode) → target view id. collect/analyze/long are per-type; compare/profile
  // are the shared cross-type surfaces.
  function viewFor(type, mode) {
    if (mode === 'compare') return 'force-compare';
    if (mode === 'profile') return 'dsi';
    if (mode === 'long') return type + '-longitudinal';
    return type; // collect OR analyze → the type's own view (captureMode distinguishes)
  }

  // ── FORCE-WS-2a (2026-07-11): 纵向多卡仪表盘 + 通用指标卡框架首版 ─────────────
  //  This replaces the WS-1b single-chart longitudinal view with a multi-card board
  //  (VALD ForceDecks Group Dashboard 形态), per direction §4''/§4''-1 五条裁决 +
  //  master-plan P1①, matching _mockup-force-workspace.html (纵向 pane) 1:1. ONE
  //  page-shared board (cross-type — the workspace type pills dim in 纵向, WS-1a).
  //  Each card = 级联指标选择（动作类型→一级 section→具体指标）+ 大数字 + 横向/趋势
  //  双视图（可同屏并存）+ Graph/Table + 阈值红点（默认 10% 可改）+ 布局持久化.
  //
  // ── UNIVERSAL CARD SEAM (跨板块基建项 1, ruling §4''-1 ⑤) ────────────────────
  //  ForceMetricCard is self-contained: its ONLY inputs are the card state + a plain
  //  ctx { roster, stores, compareIds, currentAthleteId, onChange, onRemove }. It
  //  reaches for NO workspace-only global, so P3 can mount it on the individual /
  //  team pages by supplying the same ctx. Extraction to its own file is a LATER
  //  step — it stays here this slice (do-not-over-abstract). SquadBody / TrendBody
  //  are its private view halves and travel with it.
  //
  // ── HIERARCHY FROM defs (no hardcoded metric tree, ruling §4'') ─────────────
  //  The 一级 (section) level is read LIVE from each type's defs `section` field —
  //  exactly what ForceSessionSource surfaces (CMJ defs carry it). SJ/IMTP defs have
  //  no `section` today, so a per-type fallback bucket label is applied, mirroring
  //  cmj.jsx's own SJ→'跳跃指标' / IMTP→'力量指标' section synthesis (cmj.jsx ~L4196/
  //  4202) — this labels the bucket only; the metrics themselves still come from defs.
  //  A 二级 (subSection) level is FUTURE — defs lack the field; the cascade is wired
  //  (lgbSectionsFor + the section <select>) so adding a `subSection` def field later
  //  is a one-place change.
  //
  // ── RED LINES ────────────────────────────────────────────────────────────────
  //  Raw values only (getMetricValue verbatim) · 无归一化 · 无排名 / 无加权评分 · the
  //  red-dot threshold is a TRANSPARENT FACT (个人均值 + % both shown, editable) ·
  //  session dates always visible · 不产出判读 / 建议 类结论（红线，见 banned-string gate）。
  const FWS_COMPARE_MAX = 4; // page-shared comparison-athlete cap (report 5c 容量)
  // Identity series for comparison athletes (cool/neutral, non-evaluative — current
  // athlete draws in --accent; these are fixed, NOT performance-encoded).
  const FWS_SERIES_COLORS = ['#16a34a', '#7a828e', '#a855f7', '#0891b2'];

  function fwsInputsForAthlete(stores, aid) {
    // Mirror force-report's inputsForAthlete: resolve each store's per-athlete
    // session array, keyed by test-type input. Local helper (not imported).
    return {
      cmj: (stores.cmj && stores.cmj[aid]) || [],
      sj: (stores.sj && stores.sj[aid]) || [],
      imtp: (stores.imtp && stores.imtp[aid]) || [],
    };
  }

  function fwsDateMs(x) {
    if (x == null) return null;
    const t = +new Date(x);
    return Number.isNaN(t) ? null : t; // NaN-date guard (pit #15)
  }

  // Section fallback for types whose defs carry no `section` (SJ/IMTP) — mirrors the
  // labels cmj.jsx already synthesizes. NOT a metric→section map (metrics come from
  // defs); only the bucket label is supplied when the field is absent.
  const LGB_SECTION_FALLBACK = { sj: '跳跃指标', imtp: '力量指标' };
  const LGB_TYPE_ICON = { cmj: '⬆', sj: '↥', imtp: '⇧' };
  // Default board (ruling §4''-1 ②): 跳跃高度 / mRSI / 峰值功率 as three CMJ cards.
  // Each spec lists candidate def keys resolved against LIVE defs; if none resolve
  // the card falls back to the type's first section's first metric (honest fallback).
  const LGB_DEFAULT_SPECS = [
    { type: 'cmj', keys: ['jumpHeight'], view: 'squad' },              // 跳跃高度
    { type: 'cmj', keys: ['rsiMod'], view: 'squad' },                 // mRSI (RSI-mod)
    { type: 'cmj', keys: ['peakPower', 'relPeakPower'], view: 'trend' }, // 峰值功率
  ];

  function lgbFSS() { return (typeof window !== 'undefined' && window.ForceSessionSource) || null; }
  function lgbTypes() { const F = lgbFSS(); return F ? F.listTestTypes() : []; }
  function lgbDefsFor(typeId) {
    const t = lgbTypes().find((x) => x.id === typeId);
    return (t && typeof t.defs === 'function') ? (t.defs() || []) : [];
  }
  // Ordered [{ section, defs:[def...] }] for a type — groups defs by their `section`
  // field (fallback bucket for section-less types). Insertion order = defs order.
  function lgbSectionsFor(typeId) {
    const defs = lgbDefsFor(typeId);
    const fallback = LGB_SECTION_FALLBACK[typeId] || '指标';
    const order = [], byName = {};
    defs.forEach((d) => {
      const sec = (d && d.section) || fallback; // ← 一级 read straight from defs
      if (!byName[sec]) { byName[sec] = { section: sec, defs: [] }; order.push(byName[sec]); }
      byName[sec].defs.push(d);
    });
    return order;
  }
  function lgbSectionEntry(typeId, section) {
    const secs = lgbSectionsFor(typeId);
    return secs.find((s) => s.section === section) || secs[0] || null;
  }
  // Lenient resolve for RENDER (falls back to the section's first metric so a card
  // always shows something). The strict load-time guard is lgbCardResolves below.
  function lgbResolveDef(card) {
    const se = lgbSectionEntry(card.type, card.section);
    if (!se) return null;
    return se.defs.find((d) => d.key === card.metricKey) || se.defs[0] || null;
  }
  // Strict keep-valid predicate: type/section/metric must resolve EXACTLY.
  function lgbCardResolves(c) {
    if (!c) return false;
    const se = lgbSectionEntry(c.type, c.section);
    if (!se || se.section !== c.section) return false;
    return se.defs.some((d) => d.key === c.metricKey);
  }
  function lgbValidCards(cards) {
    return Array.isArray(cards) ? cards.filter(lgbCardResolves) : [];
  }

  // Resolve the default 3-card board from LIVE defs (see LGB_DEFAULT_SPECS).
  function lgbDefaultCards() {
    const cards = [];
    LGB_DEFAULT_SPECS.forEach((spec) => {
      const secs = lgbSectionsFor(spec.type);
      if (!secs.length) return;
      let found = null;
      for (let i = 0; i < secs.length && !found; i++) {
        for (let j = 0; j < secs[i].defs.length && !found; j++) {
          if (spec.keys.indexOf(secs[i].defs[j].key) !== -1) {
            found = { section: secs[i].section, key: secs[i].defs[j].key };
          }
        }
      }
      if (!found) { // candidate keys absent → first section's first metric
        found = { section: secs[0].section, key: secs[0].defs[0] ? secs[0].defs[0].key : '' };
      }
      cards.push({ type: spec.type, section: found.section, metricKey: found.key, view: spec.view, disp: 'graph', sort: 'hl', thr: 10 });
    });
    return cards;
  }

  // ── per-athlete value access (getMetricValue only — raw, no derivation) ──────
  // 横向: an athlete's LATEST value + personal historical average for a metric.
  // personalAvg = mean of that athlete's ALL session values (NaN-guarded via
  // getMetricValue → null skip). 红点 = latest < personalAvg × (1 − thr/100).
  // ── FORCE-TRACE M3-C: the sealed M1 comparability rule applies to EVERY cross-session series
  //    on this board. The rule is CONSUMED from its single owner — ForceSessionSource.
  //    sessionComparability (GPT audit: no seventh drifting status-enum copy). Trace AVAILABILITY
  //    is the opposite: a badge only, never selection — a legacy session without a raw trace
  //    still carries valid metrics and stays in the series. ──
  function lgbComparable(session) {
    const F = lgbFSS();
    if (!F || typeof F.sessionComparability !== 'function') return true;   // no authority → never silently drop data
    return F.sessionComparability(session).comparable !== false;
  }

  function lgbAthleteLatestAvg(stores, aid, typeId, metricKey) {
    const F = lgbFSS();
    if (!F) return { latest: null, pavg: null, n: 0, excluded: 0 };
    const list = F.listSessions(fwsInputsForAthlete(stores, aid), typeId); // date-desc, NaN-safe
    let latest = null; let excluded = 0; const vals = [];
    list.forEach((r) => {
      if (!lgbComparable(r.session)) { excluded++; return; }   // M1: non-comparable never enters
      const v = F.getMetricValue(r.session, typeId, metricKey);
      if (v == null) return;            // missing / non-finite → skip (honest)
      if (latest === null) latest = v;  // first in date-desc order = most recent COMPARABLE value
      vals.push(v);
    });
    const pavg = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
    return { latest, pavg, n: vals.length, excluded };
  }
  function lgbSquadData(card, roster, stores) {
    const rows = [];
    let excluded = 0;                    // M3-C: non-comparable sessions skipped across the queue
    (roster || []).forEach((a) => {
      const s = lgbAthleteLatestAvg(stores, a.id, card.type, card.metricKey);
      excluded += s.excluded || 0;
      if (s.latest == null) return;     // no latest COMPARABLE value → not plotted (honest absence)
      const low = s.pavg != null && s.latest < s.pavg * (1 - card.thr / 100);
      rows.push({ id: a.id, name: a.name, latest: s.latest, pavg: s.pavg, low });
    });
    rows.sort((x, y) => card.sort === 'az'
      ? String(x.name).localeCompare(String(y.name))
      : y.latest - x.latest);
    const avg = rows.length ? rows.reduce((s, r) => s + r.latest, 0) / rows.length : null;
    return { rows, avg, nLow: rows.filter((r) => r.low).length, excluded };
  }
  // 趋势: chronological {ms, value} over an athlete's OWN session dates (real-date x).
  // M3-C: selection = comparability + metric existence ONLY; `excluded` counts the非可比 sessions
  // dropped, `legacy` counts included sessions WITHOUT a raw trace (availability badge — the
  // read model, when threaded, only annotates and never filters).
  function lgbTrendSeries(stores, aid, typeId, metricKey, readModel) {
    const F = lgbFSS();
    if (!F) return { pts: [], excluded: 0, legacy: 0 };
    const list = F.listSessions(fwsInputsForAthlete(stores, aid), typeId);
    const pts = []; let excluded = 0, legacy = 0;
    list.forEach((r) => {
      if (!lgbComparable(r.session)) { excluded++; return; }   // M1 comparability — the ONLY filter
      const ms = fwsDateMs(r.date);     // NaN-date guard (pit #15)
      const v = F.getMetricValue(r.session, typeId, metricKey);
      if (ms != null && v != null) {
        if (readModel && typeof readModel.deriveAvailability === 'function' && readModel.deriveAvailability(r.session) === 'none') legacy++;
        pts.push({ ms, value: v });
      }
    });
    pts.sort((a, b) => a.ms - b.ms);    // chronological
    return { pts: pts, excluded: excluded, legacy: legacy };
  }

  function lgbFmt(v, unit) {
    if (v == null || !isFinite(v)) return '—';
    const a = Math.abs(v);
    const s = a >= 100 ? String(Math.round(v)) : a >= 10 ? v.toFixed(1) : v.toFixed(2);
    return s + (unit ? ' ' + unit : '');
  }
  function lgbDateLabel(ms) {
    const d = new Date(ms);
    return String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
  }

  // Scoped stylesheet (injected once) — mirrors the mockup .lg-* + .tip 1:1. The tip
  // hover reuses the mockup / force-report .frp-tip pattern (dotted underline +
  // ::after data-tip bubble). Auto-fill grid = user ruling (列数随屏宽自适应).
  function lgbEnsureStyle() {
    if (typeof document === 'undefined' || document.getElementById('force-longitudinal-style')) return;
    const css = `
      .lgb-wrap { flex: 1; overflow: auto; padding: 14px 20px 24px; }
      .lgb-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 4px 0 12px; }
      .lgb-note { font-size: 11px; color: var(--muted); }
      .lgb-mini { font: inherit; font-size: 11.5px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); }
      .lgb-div { width: 1px; height: 18px; background: var(--border); }
      .lgb-chip { display: inline-flex; align-items: center; font: inherit; font-size: 11px; padding: 3px 11px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); background: var(--panel); cursor: pointer; }
      .lgb-chip.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
      .lgb-chip:disabled { opacity: .4; cursor: not-allowed; }
      .lgb-chip.self { cursor: default; }
      .lgb-btn { font: inherit; font-size: 12px; padding: 5px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); cursor: pointer; }
      .lgb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
      .lgb-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 13px 15px; min-width: 0; }
      .lgb-ch { display: flex; align-items: flex-start; gap: 8px; }
      .lgb-badge { width: 30px; height: 30px; border-radius: 7px; background: var(--bg); display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
      .lgb-sel { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
      .lgb-cascade { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
      .lgb-cascade select { font: inherit; font-size: 11px; padding: 2px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); max-width: 132px; }
      .lgb-who { font-size: 10px; color: var(--muted); }
      .lgb-tools { margin-left: auto; display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
      .lgb-vtab { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
      .lgb-vtab button { font: inherit; padding: 2px 9px; font-size: 11px; cursor: pointer; color: var(--muted); background: var(--panel); border: none; }
      .lgb-vtab button.on { background: var(--accent); color: #fff; font-weight: 600; }
      .lgb-ico { width: 24px; height: 24px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); font-size: 12px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
      .lgb-ctl { display: flex; align-items: center; gap: 8px; margin: 10px 0 2px; font-size: 11px; color: var(--muted); flex-wrap: wrap; }
      .lgb-vt { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-left: auto; }
      .lgb-vt button { font: inherit; padding: 2px 9px; font-size: 11px; cursor: pointer; color: var(--muted); background: var(--panel); border: none; }
      .lgb-vt button.on { background: var(--bg); color: var(--text); font-weight: 600; }
      .lgb-thr { font: inherit; font-size: 11px; width: 48px; padding: 2px 5px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); }
      .lgb-big { font-family: var(--font-mono); font-size: 23px; font-weight: 700; margin: 4px 0 2px; color: var(--text); }
      .lgb-big small { font-size: 11px; color: var(--muted); font-weight: 400; font-family: inherit; margin-left: 4px; }
      .lgb-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 10.5px; color: var(--muted); margin-top: 7px; align-items: center; }
      .lgb-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; vertical-align: 1px; margin-right: 4px; }
      .lgb-foot { font-size: 10px; color: var(--muted); margin-top: 6px; line-height: 1.5; }
      .lgb-empty { padding: 26px 0; text-align: center; color: var(--muted); font-size: 11.5px; }
      .lgb-tbl { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 6px; }
      .lgb-tbl td { padding: 3px 6px; border-bottom: 1px solid var(--panel-hi); }
      .lgb-tbl td.v { font-family: var(--font-mono); text-align: right; white-space: nowrap; }
      .lgb-tbl tr:last-child td { border-bottom: none; }
      .lgb-tip { border-bottom: 1px dotted var(--muted); cursor: help; position: relative; color: var(--muted); font-size: 11px; }
      .lgb-tip:hover::after { content: attr(data-tip); position: absolute; left: 0; bottom: calc(100% + 6px); z-index: 40; background: #1f242c; color: #f2f4f7; font-size: 11px; font-weight: 400; padding: 7px 10px; border-radius: 7px; white-space: pre-line; width: max-content; max-width: 280px; box-shadow: 0 6px 20px rgba(22,28,40,.25); line-height: 1.5; }
      .lgb-tip:hover::before { content: ''; position: absolute; left: 12px; bottom: calc(100% + 1px); border: 5px solid transparent; border-top-color: #1f242c; z-index: 40; }
    `;
    const el = document.createElement('style');
    el.id = 'force-longitudinal-style';
    el.textContent = css;
    document.head.appendChild(el);
  }

  // ── 横向 (squad) view — over the whole roster, each athlete's LATEST value ────
  function SquadBody({ card, index, roster, stores, unit, onChange }) {
    const data = lgbSquadData(card, roster, stores);
    const rows = data.rows, avg = data.avg, nLow = data.nLow, excluded = data.excluded;
    const patch = (p) => onChange(index, p);
    const ctl = (
      <div className="lgb-ctl">
        <span>排序</span>
        <select className="lgb-mini" style={{ padding: '2px 6px' }} value={card.sort} onChange={(e) => patch({ sort: e.target.value })}>
          <option value="hl">高 → 低</option>
          <option value="az">A → Z</option>
        </select>
        <span className="lgb-vt">
          <button type="button" className={card.disp === 'graph' ? 'on' : ''} onClick={() => patch({ disp: 'graph' })}>图</button>
          <button type="button" className={card.disp === 'table' ? 'on' : ''} onClick={() => patch({ disp: 'table' })}>表</button>
        </span>
      </div>
    );
    if (!rows.length) {
      return <div>{ctl}<div className="lgb-empty">队列中暂无该指标的最新可比结果{excluded > 0 ? '（' + excluded + ' 个不可比会话已排除）' : ''}</div></div>;
    }
    const big = <div className="lgb-big">{lgbFmt(avg, unit)}<small>队列均值</small></div>;
    let viz;
    if (card.disp === 'table') {
      viz = (
        <table className="lgb-tbl"><tbody>
          {rows.map((r) => (
            <tr key={r.id}>
              <td>{r.name}{r.low ? <span className="lgb-dot" style={{ background: 'var(--neg)', marginLeft: 5 }} /> : null}</td>
              <td className="v" style={{ color: r.low ? 'var(--neg)' : 'var(--text)' }}>{lgbFmt(r.latest, unit)}</td>
              <td className="v" style={{ color: 'var(--muted)' }}>均 {r.pavg == null ? '—' : lgbFmt(r.pavg, '')}</td>
            </tr>
          ))}
        </tbody></table>
      );
    } else {
      const W = 330, H = 96, PT = 12;
      const mx = (Math.max.apply(null, rows.map((r) => r.latest)) * 1.05) || 1;
      // M3-C small-sample visual: cap the bar width (a 1–2 athlete queue must not fill the card
      // with one giant bar) and center the capped group.
      const bw = Math.min(W / rows.length, 56);
      const x0 = 6 + Math.max(0, (W - bw * rows.length) / 2);
      const ay = PT + H - (avg / mx) * H;
      viz = (
        <svg viewBox={`0 0 ${W + 12} ${H + 22}`} style={{ width: '100%', marginTop: 6 }}>
          <line x1="6" y1={ay} x2={W + 6} y2={ay} stroke="var(--border-strong)" strokeDasharray="4 3" />
          <text x={W + 6} y={ay - 3} textAnchor="end" fontSize="8" fill="var(--muted)">均值</text>
          {rows.map((r, i) => {
            const h = Math.max(0, (r.latest / mx) * H);
            const bx = x0 + i * bw;
            return (
              <g key={r.id}>
                <rect x={bx + bw * 0.18} y={PT + H - h} width={bw * 0.64} height={h} rx="1.5" fill={r.low ? 'var(--neg)' : 'var(--accent)'}>
                  <title>{r.name} · {lgbFmt(r.latest, unit)}{r.pavg != null ? ' · 均 ' + lgbFmt(r.pavg, '') : ''}</title>
                </rect>
                {r.low ? <circle cx={bx + bw / 2} cy={PT + H - h - 5} r="2.4" fill="var(--neg)" /> : null}
              </g>
            );
          })}
        </svg>
      );
    }
    const legend = (
      <div className="lgb-legend">
        <span><span className="lgb-dot" style={{ background: 'var(--accent)' }} />最新可比结果</span>
        <span><span className="lgb-dot" style={{ background: 'var(--neg)' }} />最新低于个人均值 {card.thr}%（{nLow} 人）</span>
      </div>
    );
    // M3-C audit-fix (GPT F1): the labels state the COMPARABLE caliber honestly — a newer but
    // non-comparable session is skipped, and the card says so instead of claiming "全部会话".
    const foot = (
      <div className="lgb-foot">
        个人历史均值＝该指标可比会话均值（原始值，无归一化）· 队列 {rows.length} 人有最新可比结果
        {excluded > 0 ? ' · 不可比会话已排除（采样受限/时间轴异常 · ' + excluded + ' 个）' : ''}
      </div>
    );
    return <div>{ctl}{big}{viz}{legend}{foot}</div>;
  }

  // ── 趋势 (trend) view — 本人 + 页级共享对比运动员, identity-color polylines ────
  function TrendBody({ card, index, roster, stores, compareIds, currentAthleteId, unit, label, onChange, traceReadModel }) {
    const patch = (p) => onChange(index, p);
    const drawn = [];
    const cur = (roster || []).find((a) => a.id === currentAthleteId);
    if (cur) { const s = lgbTrendSeries(stores, cur.id, card.type, card.metricKey, traceReadModel); drawn.push({ athlete: cur, color: 'var(--accent)', isCurrent: true, pts: s.pts, excluded: s.excluded, legacy: s.legacy }); }
    (compareIds || []).forEach((id, i) => {
      if (id === currentAthleteId) return;
      const a = (roster || []).find((x) => x.id === id);
      if (a) { const s = lgbTrendSeries(stores, id, card.type, card.metricKey, traceReadModel); drawn.push({ athlete: a, color: FWS_SERIES_COLORS[i % FWS_SERIES_COLORS.length], isCurrent: false, pts: s.pts, excluded: s.excluded, legacy: s.legacy }); }
    });
    const ctl = (
      <div className="lgb-ctl">
        <span>本人 + 页级对比运动员</span>
        <span className="lgb-vt">
          <button type="button" className={card.disp === 'graph' ? 'on' : ''} onClick={() => patch({ disp: 'graph' })}>图</button>
          <button type="button" className={card.disp === 'table' ? 'on' : ''} onClick={() => patch({ disp: 'table' })}>表</button>
        </span>
      </div>
    );
    const curDrawn = drawn.find((d) => d.isCurrent);
    const curLatest = (curDrawn && curDrawn.pts.length) ? curDrawn.pts[curDrawn.pts.length - 1].value : null;
    const big = <div className="lgb-big">{lgbFmt(curLatest, unit)}<small>本人最新可比</small></div>;
    // ── M3-C small-sample honesty (n<3, D4 ruling) — PER SERIES (GPT audit F2: the contract
    //    covers every longitudinal series, not only 本人). Each n=1/2 series gets its own
    //    identity-colored note: n=1 → single point 非趋势; n=2 → the RAW difference, 差值非趋势. ──
    const smallNotes = drawn
      .filter((d) => d.pts.length > 0 && d.pts.length < 3)
      .map((d) => (
        <div key={d.athlete.id} className="lgb-note lgb-smalln" style={{ color: d.color }}>
          {d.athlete.name}{d.isCurrent ? '（本人）' : ''} · {d.pts.length === 1
            ? '单点 · 样本不足（n=1）· 非趋势'
            : '两点差值 Δ ' + lgbFmt(d.pts[1].value - d.pts[0].value, unit) + ' · 样本不足（n=2）· 差值非趋势'}
        </div>
      ));
    const anyExcluded = drawn.some((d) => d.excluded > 0);
    const anyLegacy = drawn.some((d) => d.legacy > 0);
    const foot = (
      <div className="lgb-foot">
        同指标同轴原始值 · 各自会话日期 · 身份色，无排名/无归一化
        {anyExcluded ? ' · 不可比会话已排除（采样受限/时间轴异常）' : ''}
        {anyLegacy ? ' · 序列含无原图旧会话（仅标注，不影响取数）' : ''}
      </div>
    );
    const hasData = drawn.some((d) => d.pts.length > 0);
    if (!hasData) {
      return <div>{ctl}{big}<div className="lgb-empty">所选运动员暂无该指标的会话数据</div>{foot}</div>;
    }
    let viz;
    if (card.disp === 'table') {
      viz = (
        <table className="lgb-tbl"><tbody>
          {drawn.map((d) => (
            <tr key={d.athlete.id}>
              <td style={{ color: d.color }}>{d.athlete.name}{d.isCurrent ? '（本人）' : ''}</td>
              <td className="v" style={{ color: 'var(--muted)' }}>{d.pts.length ? d.pts.map((p) => lgbDateLabel(p.ms) + ' ' + lgbFmt(p.value, '')).join(' · ') : '—'}</td>
            </tr>
          ))}
        </tbody></table>
      );
    } else {
      let msMin = Infinity, msMax = -Infinity, vMin = Infinity, vMax = -Infinity;
      drawn.forEach((d) => d.pts.forEach((p) => {
        if (p.ms < msMin) msMin = p.ms; if (p.ms > msMax) msMax = p.ms;
        if (p.value < vMin) vMin = p.value; if (p.value > vMax) vMax = p.value;
      }));
      const W = 330, H = 100, ML = 8, MT = 12;
      const vPad = (vMax - vMin) * 0.08 || (Math.abs(vMax) * 0.08 || 1);
      const yLo = vMin - vPad, yHi = vMax + vPad;
      const msSpan = (msMax - msMin) || 1;
      const xOf = (ms) => msMin === msMax ? ML + W / 2 : ML + ((ms - msMin) / msSpan) * W;
      const yOf = (v) => MT + H * (1 - (v - yLo) / ((yHi - yLo) || 1));
      viz = (
        <svg viewBox={`0 0 ${W + 12} ${H + 22}`} style={{ width: '100%', marginTop: 6 }}>
          <line x1={ML} y1={MT} x2={ML} y2={MT + H} stroke="var(--border)" />
          <line x1={ML} y1={MT + H} x2={ML + W} y2={MT + H} stroke="var(--border)" />
          <text x={ML} y={MT - 3} fontSize="8" fill="var(--muted)">{unit || label}</text>
          <text x={ML + W} y={MT + H + 14} textAnchor="end" fontSize="8" fill="var(--muted)">各自会话日期</text>
          {isFinite(msMin) ? <text x={ML} y={MT + H + 14} fontSize="8" fill="var(--muted)">{lgbDateLabel(msMin)}</text> : null}
          {drawn.map((d) => {
            if (!d.pts.length) return null;
            const dpath = d.pts.map((p, i) => (i ? 'L' : 'M') + xOf(p.ms).toFixed(1) + ',' + yOf(p.value).toFixed(1)).join('');
            const last = d.pts[d.pts.length - 1];
            return (
              <g key={d.athlete.id}>
                {/* M3-C: no connecting line below n=3 — two real points may not read as a trend */}
                {d.pts.length > 2 ? <path d={dpath} fill="none" stroke={d.color} strokeWidth={d.isCurrent ? 2 : 1.6} strokeLinejoin="round" /> : null}
                {d.pts.map((p, i) => <circle key={i} cx={xOf(p.ms).toFixed(1)} cy={yOf(p.value).toFixed(1)} r="2.4" fill={i === d.pts.length - 1 ? d.color : 'var(--panel)'} stroke={d.color} strokeWidth="1.3" />)}
                <text x={xOf(last.ms) + 5} y={yOf(last.value) + 3} fontSize="9" fill={d.color} fontWeight="600">{lgbFmt(last.value, '')}</text>
              </g>
            );
          })}
        </svg>
      );
    }
    const legend = (
      <div className="lgb-legend">
        {drawn.map((d) => (
          <span key={d.athlete.id}><span className="lgb-dot" style={{ background: d.color }} />{d.athlete.name}{d.isCurrent ? '（本人）' : ''}</span>
        ))}
      </div>
    );
    return <div>{ctl}{big}{smallNotes}{viz}{legend}{foot}</div>;
  }

  // ── ForceMetricCard — the reusable single-card renderer (seam described above) ─
  function ForceMetricCard({ card, index, roster, stores, compareIds, currentAthleteId, onChange, onRemove, traceReadModel }) {
    const [editingThr, setEditingThr] = React.useState(false);
    const types = lgbTypes();
    const sections = lgbSectionsFor(card.type);
    const secEntry = lgbSectionEntry(card.type, card.section);
    const def = lgbResolveDef(card);
    const unit = def ? (def.unit || '') : '';
    const label = def ? def.label : card.metricKey;
    const typeLabel = (types.find((t) => t.id === card.type) || {}).label || card.type;
    const patch = (p) => onChange(index, p);

    // Cascade — changing 动作类型 resets 一级 + 具体指标; changing 一级 resets 具体指标.
    const onType = (t) => {
      const secs = lgbSectionsFor(t), s0 = secs[0];
      patch({ type: t, section: s0 ? s0.section : '', metricKey: (s0 && s0.defs[0]) ? s0.defs[0].key : '' });
    };
    const onSection = (s) => {
      const se = sections.find((x) => x.section === s) || null;
      patch({ section: s, metricKey: (se && se.defs[0]) ? se.defs[0].key : '' });
    };

    return (
      <div className="lgb-card">
        <div className="lgb-ch">
          <div className="lgb-badge">{LGB_TYPE_ICON[card.type] || '◇'}</div>
          <div className="lgb-sel">
            <div className="lgb-cascade">
              <select value={card.type} onChange={(e) => onType(e.target.value)}>
                {types.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
              </select>
              {/* 一级 (defs `section`). 二级 subSection is FUTURE — insert a third
                  <select> here keyed off def.subSection when defs gain the field. */}
              <select value={card.section} onChange={(e) => onSection(e.target.value)}>
                {sections.map((s) => <option key={s.section} value={s.section}>{s.section}</option>)}
              </select>
              <select value={card.metricKey} onChange={(e) => patch({ metricKey: e.target.value })}>
                {(secEntry ? secEntry.defs : []).map((d) => <option key={d.key} value={d.key}>{d.label}</option>)}
              </select>
              {def && def.formulaTip
                ? <span className="lgb-tip" data-tip={def.formulaTip}>ⓘ</span>
                : null}
            </div>
            <span className="lgb-who">{typeLabel} · {card.section}{label ? ' · ' + label : ''}</span>
          </div>
          <div className="lgb-tools">
            <span className="lgb-vtab">
              <button type="button" className={card.view === 'squad' ? 'on' : ''} onClick={() => patch({ view: 'squad' })}>横向</button>
              <button type="button" className={card.view === 'trend' ? 'on' : ''} onClick={() => patch({ view: 'trend' })}>趋势</button>
            </span>
            <span className="lgb-ico" title={`卡设置：阈值 ${card.thr}%`} onClick={() => setEditingThr((v) => !v)}>⚙</span>
            <span className="lgb-ico" title="展开（后续）">⤢</span>
            <span className="lgb-ico" title="移除卡片" onClick={() => onRemove(index)}>✕</span>
          </div>
        </div>

        {editingThr ? (
          <div className="lgb-ctl">
            <span>低于个人均值</span>
            <input className="lgb-thr" type="number" min="0" max="100" value={card.thr}
              onChange={(e) => { const n = Number(e.target.value); patch({ thr: (isFinite(n) && n >= 0) ? n : 0 }); }} />
            <span>% 记红点 · 阈值明示于图例（非评分）</span>
          </div>
        ) : null}

        {card.view === 'squad'
          ? <SquadBody card={card} index={index} roster={roster} stores={stores} unit={unit} onChange={onChange} />
          : <TrendBody card={card} index={index} roster={roster} stores={stores} compareIds={compareIds} currentAthleteId={currentAthleteId} unit={unit} label={label} onChange={onChange} traceReadModel={traceReadModel} />}
      </div>
    );
  }

  // ── ForceLongitudinalBoard — page bar + card grid +布局持久化 ────────────────
  function ForceLongitudinalBoard({ typeId, athleteId, roster, stores, traceReadModel }) {
    React.useEffect(() => { lgbEnsureStyle(); }, []);
    const rosterArr = Array.isArray(roster) ? roster : [];
    const prefsRepo = (typeof window !== 'undefined' && window.VizPanelPrefsRepo) || null;
    const DOMAIN = 'force-longitudinal';

    // Load persisted board once (defs-aware keep-valid guard); empty → default 3 cards.
    const initial = React.useMemo(() => (prefsRepo ? prefsRepo.load(DOMAIN) : null), []); // eslint-disable-line react-hooks/exhaustive-deps
    const [cards, setCards] = React.useState(() => {
      const valid = lgbValidCards(initial && initial.cards);
      return valid.length ? valid : lgbDefaultCards();
    });
    const [compareIds, setCompareIds] = React.useState(() => {
      const ids = (initial && Array.isArray(initial.compareIds)) ? initial.compareIds : [];
      return ids.filter((id) => id !== athleteId && rosterArr.some((a) => a.id === id)).slice(0, FWS_COMPARE_MAX);
    });

    // Save on ANY change — add/remove card, cascade, view/disp/sort/threshold, compare
    // selection all flow through cards/compareIds identity, so this one effect covers them.
    React.useEffect(() => {
      if (prefsRepo) prefsRepo.save(DOMAIN, { cards, compareIds });
    }, [cards, compareIds]); // eslint-disable-line react-hooks/exhaustive-deps

    const updateCard = (i, p) => setCards((prev) => prev.map((c, idx) => idx === i ? Object.assign({}, c, p) : c));
    const removeCard = (i) => setCards((prev) => prev.filter((_, idx) => idx !== i));
    const addCard = () => {
      const t = (lgbTypes()[0] || {}).id || 'cmj';
      const secs = lgbSectionsFor(t), s0 = secs[0];
      setCards((prev) => prev.concat([{ type: t, section: s0 ? s0.section : '', metricKey: (s0 && s0.defs[0]) ? s0.defs[0].key : '', view: 'squad', disp: 'graph', sort: 'hl', thr: 10 }]));
    };

    const current = rosterArr.find((a) => a.id === athleteId) || null;
    const others = rosterArr.filter((a) => a.id !== athleteId);
    const atCap = compareIds.length >= FWS_COMPARE_MAX;
    const toggleCompare = (id) => setCompareIds((prev) => {
      if (prev.includes(id)) return prev.filter((x) => x !== id);
      if (prev.length >= FWS_COMPARE_MAX) return prev; // cap — refuse the 5th
      return prev.concat([id]);
    });

    return (
      <div className="lgb-wrap">
        {/* 页级控制栏（混合仪表盘）：队列/Tag（横向卡用）+ 页级共享对比运动员（趋势卡用）+ 添加卡 */}
        <div className="lgb-bar">
          <span className="lgb-note">队列</span>
          {/* 队列 = 当前 roster；单一队列本切片，Tag 为占位（filter 归后续板块） */}
          <select className="lgb-mini" disabled title="当前队列（本切片单一队列，筛选后续接入）">
            <option>全部运动员 · {rosterArr.length} 人</option>
          </select>
          <select className="lgb-mini" disabled title="Tag 筛选（占位）"><option>Tag: 全部</option></select>
          <span className="lgb-div" />
          <span className="lgb-note">对比运动员（页级共享 · 最多 {FWS_COMPARE_MAX}）</span>
          {current ? <button type="button" className="lgb-chip on self" disabled>{current.name}（本人）</button> : null}
          {others.map((a) => {
            const on = compareIds.includes(a.id);
            const disabled = !on && atCap;
            return (
              <button key={a.id} type="button" className={'lgb-chip' + (on ? ' on' : '')} disabled={disabled}
                title={disabled ? `最多 ${FWS_COMPARE_MAX} 名对比运动员` : undefined}
                onClick={() => toggleCompare(a.id)}>{a.name}</button>
            );
          })}
          <span className="lgb-note" style={{ marginLeft: 2 }}>已选 {compareIds.length}/{FWS_COMPARE_MAX}</span>
          <button type="button" className="lgb-btn" style={{ marginLeft: 'auto' }} onClick={addCard}>＋ 添加卡片</button>
        </div>

        <div className="lgb-grid">
          {cards.map((c, i) => (
            <ForceMetricCard key={i} card={c} index={i} roster={rosterArr} stores={stores} traceReadModel={traceReadModel}
              compareIds={compareIds} currentAthleteId={athleteId}
              onChange={updateCard} onRemove={removeCard} />
          ))}
        </div>

        <div className="lgb-foot" style={{ marginTop: 10 }}>
          混合仪表盘：每卡自选 横向/趋势 视图（可同屏并存）· 列数随屏宽自适应（auto-fill）· 卡＝级联指标选择（动作类型→一级→[二级]→具体指标）＋大数字＋图/表＋阈值红点（默认 10% 可改）＋布局持久化。通用指标卡框架后续迁个人页/团队页（跨板块基建项 1）。
        </div>
      </div>
    );
  }

  // ══ FORCE-WS-3a (2026-07-12) · 分析面 (ForceAnalysisFace) ═══════════════════════
  //  Saved-session rich workbench — reads STORED sessions and reuses the window-exposed
  //  CMJ charts. CMJ/SJ/IMTP share session/trial selection, complete metrics, cards/table,
  //  filtering and trial comparison; ForceAnalysisRegistry limits charts to facts actually
  //  persisted for each type. A future test must declare its own descriptor and never inherits
  //  CMJ behavior implicitly.
  //
  // ── RED LINES ────────────────────────────────────────────────────────────────
  //  只读原始存储值（getMetricValue 原样）· 不新增曲线数学（CMJ 曲线模型是
  //  force-report buildForceCmjCurveModel 的逐字镜像）· 不归一化 / 不排名 / 不判读 /
  //  不建议 / 无评分 · 诚实缺席（存量会话无曲线样本 → 诚实呈现缺席，从不伪造）。

  // Type-specific capabilities live in ForceAnalysisRegistry. Unknown/future types fail closed
  // with an explicit "未接入" state — they never inherit CMJ modules or scientific semantics.
  function fafDescriptor(typeId) {
    const registry = (typeof window !== 'undefined' && window.ForceAnalysisRegistry) || null;
    if (registry && typeof registry.getDescriptor === 'function') return registry.getDescriptor(typeId);
    return { typeId: typeId, supported: false, label: String(typeId || '').toUpperCase(), modules: [], featuredMetricKeys: [] };
  }
  // Phase structure and L/R asymmetry are display-only views over persisted trial metrics.
  // They deliberately do not re-run detection or infer missing historical facts.

  function fafDate(d) { return d ? String(d).slice(0, 10) : '—'; }
  // Value formatting (display only — no derivation). Verbatim mirror of force-report frpFmt.
  function fafFmt(v, unit) {
    if (v == null || !isFinite(v)) return '—';
    const a = Math.abs(v);
    if (unit === 'ms' || (unit === '' && a >= 100)) return String(Math.round(v));
    if (a >= 1000) return Math.round(v).toLocaleString();
    if (a >= 100) return v.toFixed(0);
    if (a >= 10) return v.toFixed(1);
    return v.toFixed(2);
  }
  // Complete metric defs — one catalog for saved summary, trial comparison and report.
  function fafDefsFor(typeId) {
    const source = (typeof window !== 'undefined' && window.ForceSessionSource) || null;
    if (source && typeof source.getMetricDefinitions === 'function') return source.getMetricDefinitions(typeId);
    if (typeId === 'cmj') {
      const cmj = (typeof window !== 'undefined' && window.__FORCE_TEST_INTERNALS__ && window.__FORCE_TEST_INTERNALS__.cmj) || {};
      return Array.isArray(cmj.ALL_SUMMARY_METRICS) ? cmj.ALL_SUMMARY_METRICS : [];
    }
    if (typeId === 'sj') return Array.isArray(window.SJ_SUMMARY_METRICS) ? window.SJ_SUMMARY_METRICS : [];
    if (typeId === 'imtp') return Array.isArray(window.IMTP_SUMMARY_METRICS) ? window.IMTP_SUMMARY_METRICS : [];
    return [];
  }
  // A metric label carrying its formulaTip as a dotted-underline hover (mirrors the
  // finalized mockup .tip + force-report frpTipLabel / .frp-tip). Direct-read metrics
  // (no formulaTip) render as plain text.
  function fafTipLabel(def) {
    if (!def) return null;
    return def.formulaTip
      ? <span className="faf-tip" data-tip={def.formulaTip}>{def.label}</span>
      : def.label;
  }

  // ══ FORCE-WS-3b (2026-07-12) · 代表 trial 改选 + 落库 + trial×指标比对 (FORCE-EXIT F4) ══
  //  Fills the WS-3a seam. Three additions, all display-layer / repository-only:
  //   1. 代表 trial 改选 — chip click re-drives the face's charts + 指标表 + KPI to that
  //      trial's raw stored metrics (trials[].metrics). TEMPORARY preview · 不改存储.
  //   2. 设为会话默认代表 — persists the previewed trial as the session's display default
  //      via VizPanelPrefsRepo 'force-session-rep' domain, keyed by session id. It NEVER
  //      mutates the stored session (cmj/sj/imtp.jsx protected — zero diff).
  //   3. trial × 指标比对 — 'trial 比对' module chip toggles a metrics-row × trials-col
  //      table; per-row 最优 is DIR-AWARE (reads the def's `better` field — the same field
  //      report 5b's Δ-coloring reads; 'higher'→max, 'lower'→min, null→none). 参照 5b.
  //  DATA-MODEL DECLARATION (baseline §4.9): new field = per-session representative-trial-
  //  index override in the 'force-session-rep' prefs domain; default = none (→ the
  //  session's own best/representative trial); old-data = no override present → default
  //  best (works, zero migration); migration = none (additive); rollback = delete the
  //  domain / the session's entry. Keep-valid guard: a stored index ≥ the session's trial
  //  count (session re-imported with fewer trials) → fall back to default + drop the stale
  //  entry. RED LINES (unchanged from WS-3a): 只读原始存储值 · 不新增曲线数学 · 不归一化 /
  //  不排名 / 不判读 / 不建议 / 无评分.

  // Trial values use the same relative-value contract as session summaries and reports.
  function fafTrialValue(trial, metricKey, session, typeId) {
    if (!trial || metricKey == null) return null;
    const m = (trial.metrics && typeof trial.metrics === 'object') ? trial.metrics : null;
    const source = (typeof window !== 'undefined' && window.ForceSessionSource) || null;
    const protocolMass = session && session.protocol ? session.protocol.bodyMass : null;
    const bodyMass = protocolMass != null ? protocolMass : (session ? session.bodyMass : null);
    if (source && typeof source.getMetricValueFromMap === 'function') {
      return source.getMetricValueFromMap(m, typeId, metricKey, bodyMass);
    }
    const value = m ? m[metricKey] : null;
    return (value != null && isFinite(value)) ? value : null;
  }
  // Dir-aware best-per-row (user ruling 1): returns the array position of the best value,
  // reading the metric def's `better` direction field — the SAME field the catalog / report
  // 5b Δ-coloring reads ('higher' | 'lower' | null). 'higher' → max, 'lower' → min,
  // null/neutral → -1 (no best mark). NEVER a hardcoded per-metric direction. Ties → first.
  function fafBestPos(values, better) {
    if (better !== 'higher' && better !== 'lower') return -1;
    let bestPos = -1, bestVal = null;
    values.forEach(function (v, i) {
      if (v == null || !isFinite(v)) return;
      if (bestVal == null || (better === 'higher' ? v > bestVal : v < bestVal)) { bestVal = v; bestPos = i; }
    });
    return bestPos;
  }

  // Chart components consume positions in their local jumps[] array, while UI state keeps the
  // stable trial.index identity. Re-project at render time so missing/corrupt/non-contiguous
  // trials can compress jumps[] without selecting the wrong curve.
  function fafComparePositions(multi, selectedTrialIndices) {
    if (!multi || !Array.isArray(multi.jumps)) return new Set();
    const stable = new Set(Array.from(selectedTrialIndices || []).map(function (value) { return String(value); }));
    return new Set(multi.jumps.reduce(function (positions, jump, position) {
      if (jump && stable.has(String(jump.index))) positions.push(position);
      return positions;
    }, []));
  }

  // CMJ curve model (pit #14): VERBATIM mirror of force-report.jsx buildForceCmjCurveModel
  // (itself a mirror of report.jsx CMJReportPrintBody). Reads the live cmj.jsx internals at
  // call time and builds the EXACT inputs the window-exposed charts consume. NO curve math
  // of our own, NO cmj.jsx edit — pure consumer.
  function fafBuildCmjCurveModel(session, repIndexOverride) {
    const cmjLive = (window.__FORCE_TEST_INTERNALS__ && window.__FORCE_TEST_INTERNALS__.cmj) || {};
    const reconstructLive = cmjLive.reconstructTrialForLive;
    // WS-3b: an explicit override (the previewed trial.index) re-drives the代表-trial curve;
    // absent → the session's own stored representative (WS-3a behavior, unchanged).
    const repIdx = (repIndexOverride != null) ? repIndexOverride
      : (session && session.representative ? session.representative.index : undefined);
    const trials = (session && session.trials) || [];
    const repTrialObj = trials.find(function (t) { return t.index === repIdx; }) || trials[0];
    const repLive = (reconstructLive && repTrialObj && repTrialObj.curve) ? reconstructLive(repTrialObj) : null;
    const repClass = repTrialObj ? { type: repTrialObj.type, isBimodal: repTrialObj.isBimodal, isLF1: repTrialObj.isLF1 } : null;

    const multi = (function () {
      const src = trials.filter(function (t) { return t.curve && t.curve.t && t.curve.t.length; })
        .map(function (t) { return { curve: t.curve, keyPts: t.keyPts, metrics: t.metrics, index: t.index, label: 'Jump ' + t.index }; });
      if (!src.length || !repLive) return null;
      const bw_n = repLive.bw_n, mass_kg = repLive.mass_kg;
      const sharedTotal = [];
      const jumps = [];
      const localNearest = function (c, target) { let bi = 0, bd = Infinity; for (let i = 0; i < c.t.length; i++) { const d = Math.abs(c.t[i] - target); if (d < bd) { bd = d; bi = i; } } return bi; };
      src.forEach(function (e) {
        const c = e.curve, n = c.t.length, off = sharedTotal.length, kp = e.keyPts || {};
        for (let i = 0; i < n; i++) sharedTotal.push(c.f[i] * bw_n);
        jumps.push({
          index: e.label != null ? e.label.replace(/^Jump\s*/, '') : e.index,
          phases: {
            onset: off,
            minForce: off + (kp.b != null ? localNearest(c, kp.b) : Math.floor(n * 0.10)),
            minVel: off + (kp.c != null ? localNearest(c, kp.c) : Math.floor(n * 0.30)),
            zeroCross: off + (kp.e != null ? localNearest(c, kp.e) : Math.floor(n * 0.55)),
            takeoff: off + (n - 1),
            landing: null,
          },
          metrics: e.metrics, vel: c.v.slice(), disp: c.d.slice(), quietRef: off,
        });
      });
      return {
        total: sharedTotal, left: sharedTotal.map(function (v) { return v / 2; }), right: sharedTotal.map(function (v) { return v / 2; }),
        bw_n: bw_n, mass_kg: mass_kg, jumps: jumps, compareSelected: new Set(jumps.map(function (_, i) { return i; })), count: jumps.length,
      };
    })();

    return { repLive: repLive, repClass: repClass, multi: multi };
  }

  // FORCE-WS-4 (2026-07-12) · SJ/IMTP F-t plotter. VERBATIM-mirrors force-report.jsx
  // ForceCurveOverlay (FORCE-5d): pure presentation of ALREADY-STORED per-BW (GRF/BW) samples
  // (curve.t 0–1, curve.f = GRF/BW) — NO curve math, no re-scaling of the stored samples, no cmj.jsx reuse
  // (SJ/IMTP expose no window chart component). Single representative-trial trace.
  function fafCurvePlot(curve) {
    const fVals = (curve.f || []).filter(function (v) { return isFinite(v); });
    if (!fVals.length || !curve.t || !curve.t.length) return null;
    const W = 360, H = 210, ML = 42, MR = 12, MT = 14, MB = 30;
    const PW = W - ML - MR, PH = H - MT - MB;
    let lo = Math.min.apply(null, fVals), hi = Math.max.apply(null, fVals);
    const pad = (hi - lo) * 0.1 || Math.abs(hi) * 0.1 || 0.05; lo -= pad; hi += pad;
    const yS = function (v) { return MT + PH * (1 - (v - lo) / (hi - lo)); };
    const xS = function (t) { return ML + t * PW; };
    const yTicks = [0, 0.25, 0.5, 0.75, 1].map(function (f) { return lo + f * (hi - lo); });
    const xTicks = [0, 0.25, 0.5, 0.75, 1];
    const d = curve.t.map(function (t, i) { return (i === 0 ? 'M' : 'L') + xS(t).toFixed(1) + ',' + yS(curve.f[i]).toFixed(1); }).join('');
    return (
      <svg viewBox={'0 0 ' + W + ' ' + H} style={{ width: '100%', display: 'block' }} aria-hidden="true">
        {yTicks.map(function (v, i) { return (
          <g key={'y' + i}>
            <line x1={ML} y1={yS(v).toFixed(1)} x2={ML + PW} y2={yS(v).toFixed(1)} stroke="var(--border)" strokeWidth="0.5" />
            <text x={ML - 5} y={(yS(v) + 3).toFixed(1)} textAnchor="end" fontSize="8.5" fill="var(--muted)">{v.toFixed(1)}</text>
          </g>
        ); })}
        {xTicks.map(function (t) { return (
          <g key={'x' + t}>
            <line x1={xS(t).toFixed(1)} y1={MT} x2={xS(t).toFixed(1)} y2={MT + PH} stroke="var(--border)" strokeWidth="0.5" />
            <text x={xS(t).toFixed(1)} y={MT + PH + 13} textAnchor="middle" fontSize="8.5" fill="var(--muted)">{(t * 100).toFixed(0)}%</text>
          </g>
        ); })}
        {lo < 1 && hi > 1 && (
          <line x1={ML} y1={yS(1).toFixed(1)} x2={ML + PW} y2={yS(1).toFixed(1)} stroke="var(--muted)" strokeWidth="0.75" strokeDasharray="4 3" />
        )}
        <line x1={ML} y1={MT} x2={ML} y2={MT + PH} stroke="var(--border)" />
        <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="var(--border)" />
        <path d={d} fill="none" stroke="var(--accent)" strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round" />
        <text x="11" y={MT + PH / 2} textAnchor="middle" fontSize="8.5" fill="var(--muted)" transform={'rotate(-90,11,' + (MT + PH / 2) + ')'}>GRF / BW</text>
        <text x={ML + PW / 2} y={H - 5} textAnchor="middle" fontSize="8.5" fill="var(--muted)">时间 · 起点 → 结束（0–100%）</text>
      </svg>
    );
  }

  // FORCE-WS-4: SJ/IMTP analysis charts. New sessions carry the representative trial's stored
  // F-t curve (FORCE-EXIT F2, persisted by sj/imtp.jsx buildSession) → plot it. Old sessions
  // have no curve → honest note (reworded from the WS-2-pending wording, now that F2 is done).
  // The WS-3b representative-trial reselect (repIndexOverride) drives which trial's curve shows.
  function fafSjImtpCharts(session, enabled, repIndexOverride) {
    if (!enabled.ft) return { primary: [], comparison: [] };
    const trials = (session && Array.isArray(session.trials)) ? session.trials : [];
    const repIdx = (repIndexOverride != null) ? repIndexOverride
      : (session && session.representative ? session.representative.index : undefined);
    const repTrial = trials.find(function (t) { return t.index === repIdx; }) || trials[0] || null;
    const hasCurve = !!(repTrial && repTrial.curve && repTrial.curve.t && repTrial.curve.t.length);
    return { primary: [
      <div className="faf-chart faf-chart-primary" data-faf-chart="ft" key="ft">
        <div className="faf-chart-h">力-时间曲线 · 代表 trial（GRF/BW · 起点→结束）</div>
        {hasCurve
          ? fafCurvePlot(repTrial.curve)
          : <div className="faf-absent">旧会话未存曲线（本次前采集）· 重新采集即带曲线</div>}
      </div>
    ], comparison: [] };
  }

  // Enabled analysis charts. CMJ reuses CMJChart / LoopChart / CMJNormChart EXACTLY as
  // force-report's cmj-curves / cmj-norm modules do (same prop shapes). SJ/IMTP plot their
  // stored F-t curve via fafSjImtpCharts (FORCE-WS-4). Honest absence when a stored session
  // has no reconstructable / stored curve.
  // FORCE-TRACE M3-A: CMJ charts read the FULL stored trace bundle (traceCtx — availability from
  // traceRefs[], per-trial runtime entries from the read model). The compact 200-pt reconstruction
  // is ONLY the labeled degraded path for old sessions (availability 'none'); a refs-bearing
  // session never silently falls back to it (storage-error/missing/corrupt show honest placeholders).
  function fafCharts(typeId, session, enabled, repIndexOverride, traceCtx, chartOptions) {
    if (typeId === 'sj' || typeId === 'imtp') return fafSjImtpCharts(session, enabled, repIndexOverride);
    if (typeId !== 'cmj') return { primary: [], comparison: [] };
    const cmjLive = (window.__FORCE_TEST_INTERNALS__ && window.__FORCE_TEST_INTERNALS__.cmj) || {};
    const CMJChart = cmjLive.CMJChart, LoopChart = cmjLive.LoopChart, CMJNormChart = cmjLive.CMJNormChart;
    const tc = traceCtx || { availability: 'none', load: { state: 'idle', bundle: null } };
    const options = chartOptions || {};
    const overlays = options.overlays || { vel: true, disp: false, acc: false, power: false };
    const normalizeX = !!options.normalizeX;
    const onExpandChart = typeof options.onExpandChart === 'function' ? options.onExpandChart : null;
    const chartHeader = (id, title) => (
      <div className="faf-chart-h">
        <span>{title}</span>
        {onExpandChart ? <button type="button" className="faf-chart-expand" data-faf-expand-chart={id}
          aria-label={'放大' + title} title="放大图表" onClick={() => onExpandChart(id)}>↗ <span>放大</span></button> : null}
      </div>
    );
    const selectedTrialIndices = options.compareTrialIndices || new Set();
    const isOld = tc.availability === 'none';
    const loadState = tc.load ? tc.load.state : 'idle';
    const bundle = (loadState === 'ready' && tc.load.bundle) ? tc.load.bundle : null;

    // compact reconstruction — degraded source for OLD sessions only.
    const model = isOld ? fafBuildCmjCurveModel(session, repIndexOverride) : { repLive: null, repClass: null, multi: null };
    const repLive = model.repLive, repClass = model.repClass;
    const degraded = <div className="faf-degraded" key="deg">降级显示：压缩曲线（200 点重建 · 非原始采样 · 无落地）— 该会话早于原图存储。</div>;

    const trials = (session && session.trials) || [];
    const repIdx = (repIndexOverride != null) ? repIndexOverride
      : (session && session.representative ? session.representative.index : (trials[0] && trials[0].index));
    const repEntry = bundle ? bundle.get(repIdx) : null;
    const placeholder = (text, key) => <div className="faf-absent" key={key || 'ph'}>{text}</div>;
    // Per-trial runtime placeholder — persistent vs runtime vs corrupt are DISTINCT, and a
    // storage error is never dressed as old data.
    const stateNode = (entry) => {
      if (loadState === 'loading') return placeholder('原图加载中…');
      if (loadState === 'storage-error') return placeholder('本地原始数据存储不可用 · 原图暂无法读取（不以压缩曲线替代）。');
      if (!entry) return placeholder('该 trial 无原图引用。');
      if (entry.state === 'runtime-missing') return placeholder('原图缺失：本次未在本地存储中读到该 trial 的原始数据。');
      if (entry.state === 'persistent-missing') return placeholder('原图缺失（保存时未能写入' + (session && session.traceCapture && session.traceCapture.reason ? ' · ' + session.traceCapture.reason : '') + '）。');
      if (entry.state === 'corrupt') return placeholder('原图数据损坏，拒绝绘制（' + (entry.reason || '') + '）。');
      return null;
    };

    // Multi-trial input (F-D/F-V/norm) from the READY bundle entries — shared concatenated total +
    // per-trial offset phases (incl landing), the exact LoopChart/CMJNormChart shape.
    const traceMulti = (function () {
      if (!bundle) return null;
      const ready = trials.map((t) => [t.index, bundle.get(t.index)]).filter((p) => p[1] && p[1].state === 'ready');
      if (!ready.length) return null;
      // Concatenate the REAL six-column data — left/right come from the trace's own columns
      // (GPT M3-A audit F2: rebuilding them as total/2 would fabricate bilateral symmetry).
      const sharedTotal = []; const sharedLeft = []; const sharedRight = []; const jumps = [];
      ready.forEach((pair) => {
        const ti = pair[0], m = pair[1].chartModel;
        const n = m.total.length, off = sharedTotal.length, ph = m.phases;
        for (let i = 0; i < n; i++) { sharedTotal.push(m.total[i]); sharedLeft.push(m.left[i]); sharedRight.push(m.right[i]); }
        jumps.push({
          index: ti,
          phases: { onset: off + ph.onset, minForce: off + ph.minForce, minVel: off + ph.minVel, zeroCross: off + ph.zeroCross, takeoff: off + ph.takeoff, landing: ph.landing != null ? off + ph.landing : null },
          metrics: (trials.find((t) => t.index === ti) || {}).metrics,
          vel: Array.from({ length: n }, (_, i) => m.velAt(i)),
          disp: Array.from({ length: n }, (_, i) => m.dispAt(i)),
          quietRef: off,
        });
      });
      const first = ready[0][1].chartModel;
      return {
        total: sharedTotal, left: sharedLeft, right: sharedRight,
        bw_n: first.bw_n, mass_kg: first.mass_kg, jumps,
        compareSelected: new Set(jumps.map((_, i) => i)), count: jumps.length,
        partial: ready.length < trials.length,
      };
    })();
    const multi = isOld ? model.multi : traceMulti;
    const compareSelected = fafComparePositions(multi, selectedTrialIndices);
    const selectedMultiCount = compareSelected.size;
    const partialNote = (!isOld && traceMulti && traceMulti.partial)
      ? <div className="faf-note" key="pn">部分 trial 原图缺失 · 仅显示可读取的 trial。</div> : null;
    const multiAbsent = (key) => isOld
      ? placeholder('无可重建曲线。', key)
      : (loadState === 'loading' ? placeholder('原图加载中…', key)
        : loadState === 'storage-error' ? placeholder('本地原始数据存储不可用 · 原图暂无法读取。', key)
        : placeholder('无可绘制的原始 trial（原图缺失或损坏）。', key));

    const primary = [];
    const comparison = [];
    if (enabled.ft) {
      let body;
      if (isOld) {
        body = (CMJChart && repLive)
          ? [degraded,
              <CMJChart key="c"
                time={repLive.time}
                left={repLive.left} right={repLive.right} total={repLive.total}
                velAt={repLive.velAt} dispAt={repLive.dispAt}
                phases={repLive.phases}
                bw_n={repLive.bw_n} mass_kg={repLive.mass_kg}
                overlays={overlays}
                normalizeX={normalizeX}
                classResult={repClass}
              />]
          : <div className="faf-absent">该会话无可重建的曲线数据（存量会话未存曲线样本 · WS-2 起持久化）。</div>;
      } else {
        const sn = stateNode(repEntry);
        body = sn || (CMJChart
          ? <CMJChart {...repEntry.chartModel} overlays={overlays} normalizeX={normalizeX} />
          : placeholder('图表组件不可用。'));
      }
      primary.push(
        <div className="faf-chart faf-chart-primary" data-faf-chart="ft" key="ft">
          {chartHeader('ft', isOld ? '力-时间曲线 · 代表 trial · 降级' : '力-时间曲线 · 代表 trial · 原始完整（含落地）')}
          {body}
        </div>
      );
    }
    if (enabled.fd) {
      comparison.push(
        <div className="faf-chart faf-chart-comparison" data-faf-chart="fd" key="fd">
          {chartHeader('fd', 'F-D 环 · 顺时针 = 离心→推进' + (isOld ? ' · 降级' : ' · 原始'))}
          {(LoopChart && multi && selectedMultiCount)
            ? [isOld ? degraded : partialNote,
               <LoopChart key="c" mode="fd" jumps={multi.jumps} compareSelected={compareSelected} total={multi.total} bw_n={multi.bw_n} />]
            : (multi && !selectedMultiCount ? placeholder('请选择至少一个可读取的 trial。', 'fd-s') : multiAbsent('fd-a'))}
        </div>
      );
    }
    if (enabled.fv) {
      comparison.push(
        <div className="faf-chart faf-chart-comparison" data-faf-chart="fv" key="fv">
          {chartHeader('fv', 'F-V 环' + (isOld ? ' · 降级' : ' · 原始'))}
          {(LoopChart && multi && selectedMultiCount)
            ? [isOld ? degraded : partialNote,
               <LoopChart key="c" mode="fv" jumps={multi.jumps} compareSelected={compareSelected} total={multi.total} bw_n={multi.bw_n} />]
            : (multi && !selectedMultiCount ? placeholder('请选择至少一个可读取的 trial。', 'fv-s') : multiAbsent('fv-a'))}
        </div>
      );
    }
    if (enabled.norm) {
      comparison.push(
        <div className="faf-chart faf-chart-comparison" data-faf-chart="norm" key="norm">
          {chartHeader('norm', '归一化力时序 · 已选 trial · 仅力曲线')}
          {(CMJNormChart && multi && selectedMultiCount)
            ? [isOld ? degraded : partialNote,
               <CMJNormChart key="c"
                jumps={multi.jumps}
                compareSelected={compareSelected}
                total={multi.total} left={multi.left} right={multi.right}
                bw_n={multi.bw_n} mass_kg={multi.mass_kg}
                overlays={{ vel: false, disp: false, acc: false, power: false }}
                classifications={null}
                showPhaseBg={true}
                phaseBasisLabel={multi.jumps[Array.from(compareSelected)[0]] ? ('Jump ' + multi.jumps[Array.from(compareSelected)[0]].index) : null}
                hideHoverHint={true}
              />]
            : (multi && !selectedMultiCount ? placeholder('请选择至少一个可读取的 trial。', 'norm-s') : multiAbsent('norm-a'))}
        </div>
      );
    }
    return { primary: primary, comparison: comparison };
  }

  // Scoped stylesheet (injected once) — mirrors the mockup 分析面 (.an-*, .card, .chip,
  // .kpi, .tablewrap, .tip) 1:1, on the app's cold tokens.
  function fafEnsureStyle() {
    if (typeof document === 'undefined' || document.getElementById('force-analysis-style')) return;
    const css = `
      .faf-wrap { flex: 1; overflow: auto; padding: 14px 20px 24px; }
      .faf-nav { display: flex; align-items: center; gap: 8px; padding: 2px 0 10px; font-size: 12px; flex-wrap: wrap; }
      .faf-crumb { color: var(--accent); cursor: pointer; border-bottom: 1px solid transparent; }
      .faf-crumb:hover { border-bottom-color: var(--accent); }
      .faf-crumb.ghost { color: var(--muted); }
      .faf-crumb.ghost:hover { color: var(--accent); border-bottom-color: var(--accent); }
      .faf-sep { color: var(--border); }
      .faf-note { font-size: 11px; color: var(--muted); }
      .faf-fresh { font-size: 10px; padding: 1px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-weight: 600; }
      .faf-degraded { font-size: 11px; padding: 4px 10px; border-radius: 6px; background: rgba(217,119,6,.12); color: #d97706; border: 1px solid rgba(217,119,6,.35); margin-bottom: 8px; }
      .faf-avail { font-size: 10px; padding: 1px 8px; border-radius: 999px; font-weight: 600; border: 1px solid var(--border); }
      .faf-avail.full { background: rgba(52,211,153,.12); color: rgba(52,211,153,.95); border-color: rgba(52,211,153,.35); }
      .faf-avail.partial { background: rgba(217,119,6,.12); color: #d97706; border-color: rgba(217,119,6,.35); }
      .faf-avail.missing { background: rgba(239,68,68,.10); color: rgba(239,68,68,.9); border-color: rgba(239,68,68,.3); }
      .faf-avail.legacy { background: var(--panel-2); color: var(--muted); }
      .faf-workbench-grid { display: grid; grid-template-columns: 238px minmax(0,1fr); align-items: start; border: 1px solid var(--border); border-radius: 11px; overflow: hidden; background: var(--panel); }
      .faf-session-browser { position: sticky; top: 0; max-height: calc(100dvh - 190px); overflow: auto; align-self: start; padding: 11px; border-right: 1px solid var(--border); background: var(--bg); }
      .faf-session-browser h3 { margin: 0; color: var(--text); font-size: 11.5px; }
      .faf-session-browser p { margin: 3px 0 9px; color: var(--muted); font-size: 9.5px; }
      .faf-session-search { width: 100%; height: 32px; border: 1px solid var(--border); border-radius: 7px; padding: 0 9px; background: var(--panel); color: var(--text); font: 10.5px var(--font-sans); outline: none; }
      .faf-session-search:focus { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent); }
      .faf-session-list { display: grid; gap: 6px; margin-top: 9px; }
      .faf-session-item { width: 100%; min-width: 0; border: 1px solid var(--border); border-radius: 8px; padding: 9px; background: var(--panel); color: var(--text); text-align: left; cursor: pointer; }
      .faf-session-item:hover { border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
      .faf-session-item.on { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent); }
      .faf-session-item b, .faf-session-item span, .faf-session-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      .faf-session-item b { font-size: 10.5px; }.faf-session-item span { margin-top: 3px; color: var(--text-2, var(--text)); font-size: 9.5px; }.faf-session-item small { margin-top: 4px; color: var(--muted); font-size: 9px; }
      .faf-session-empty { padding: 14px 5px; color: var(--muted); font-size: 10px; text-align: center; }
      .faf-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
      .faf-workbench-grid > .faf-card { min-width: 0; border: 0; border-radius: 0; }
      .faf-h { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px; font-weight: 600; }
      .faf-h-t { color: var(--text); }
      .faf-controls { margin-top: 8px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 8px 10px; background: var(--bg); border-top: 1px solid var(--border); }
      .faf-control-label { font-size: 10px; color: var(--text); font-weight: 650; letter-spacing: .02em; margin-right: 2px; }
      .faf-control { font: inherit; font-size: 11px; padding: 4px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--muted); cursor: pointer; }
      .faf-control.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
      .faf-protocol { margin-left: auto; font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); }
      .faf-capability-note { margin-top: 9px; padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--muted); background: var(--bg); font-size: 11px; }
      .faf-analysis-section { margin-top: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); overflow: hidden; }
      .faf-section-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 11px; border-bottom: 1px solid var(--border); background: var(--bg); font-size: 10.5px; color: var(--muted); }
      .faf-section-head > div { display: flex; align-items: center; gap: 8px; color: var(--text); font-size: 11px; }
      .faf-section-index { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 5px; background: var(--accent-soft); color: var(--accent); font-family: var(--font-mono); font-weight: 700; }
      .faf-chart-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); grid-template-areas: "ft norm" "fd fv"; gap: 10px; padding: 10px; align-items: stretch; }
      .faf-primary-stack { grid-area: ft; min-width: 0; display: flex; flex-direction: column; }
      .faf-comparison-grid { display: contents; }
      .faf-chart { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px 14px; min-width: 0; background: var(--panel); overflow: visible; }
      .faf-primary-stack .faf-chart { flex: 1; }
      .faf-chart[data-faf-chart="norm"] { grid-area: norm; }
      .faf-chart[data-faf-chart="fd"] { grid-area: fd; }
      .faf-chart[data-faf-chart="fv"] { grid-area: fv; }
      .faf-chart-grid .faf-chart svg { display: block; width: 100%; height: auto; max-height: none; overflow: visible !important; margin-bottom: 6px; }
      .faf-chart-h { min-height: 40px; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 11px; color: var(--muted); margin-bottom: 4px; }
      .faf-chart-h > span:first-child { min-width: 0; text-wrap: balance; }
      .faf-chart-expand { flex: 0 0 auto; min-width: 62px; height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 4px; border: 0; border-radius: 6px; padding: 0 10px; background: transparent; color: var(--accent); font: 600 10px var(--font-sans); cursor: pointer; transition-property: background-color, scale; transition-duration: .16s; }
      .faf-chart-expand:hover { background: var(--accent-soft); }.faf-chart-expand:active { scale: .96; }
      .faf-chart-modal { position: fixed; inset: 0; z-index: 240; display: grid; place-items: center; padding: 24px; background: rgba(15,23,42,.58); backdrop-filter: blur(4px); }
      .faf-chart-modal-panel { width: min(1180px,94vw); max-height: 92dvh; display: flex; flex-direction: column; overflow: hidden; border-radius: 14px; background: var(--panel); box-shadow: 0 26px 80px rgba(15,23,42,.32), 0 0 0 1px rgba(255,255,255,.16); }
      .faf-chart-modal-head { min-height: 56px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 10px 8px 18px; border-bottom: 1px solid var(--border); }
      .faf-chart-modal-head strong { color: var(--text); font-size: 13px; text-wrap: balance; }
      .faf-chart-modal-close { width: 40px; height: 40px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); font-size: 20px; cursor: pointer; transition-property: background-color, color, scale; transition-duration: .16s; }
      .faf-chart-modal-close:hover { background: var(--bg); color: var(--text); }.faf-chart-modal-close:active { scale: .96; }
      .faf-chart-modal-body { min-height: 0; overflow: auto; padding: 14px 18px 20px; }
      .faf-chart-modal-body > .faf-chart { border: 0; padding: 0 0 12px; }
      .faf-chart-modal-body .faf-chart-h { display: none; }
      .faf-chart-modal-body svg { display: block; width: 100%; height: auto; max-height: none; overflow: visible !important; margin-bottom: 10px; }
      .faf-compare-selector { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 9px 11px; border-bottom: 1px solid var(--border); }
      .faf-cmptrial { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 7px; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); cursor: pointer; background: var(--panel); }
      .faf-cmptrial input { margin: 0; accent-color: var(--accent); }
      .faf-cmptrial.on { color: var(--text); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); background: var(--accent-soft); }
      .faf-cmptrial.rep { box-shadow: inset 2px 0 0 var(--accent); }
      .faf-cmptrial.unavailable { opacity: .5; cursor: not-allowed; background: var(--bg); }
      .faf-selector-action { font: inherit; font-size: 10.5px; color: var(--accent); border: 0; background: transparent; padding: 4px 5px; cursor: pointer; }
      .faf-selector-action:disabled { color: var(--muted); cursor: default; }
      .faf-absent { font-size: 11px; color: var(--muted); padding: 10px; background: var(--bg); border-radius: 7px; line-height: 1.5; }
      .faf-hair { border: 0; border-top: 1px solid var(--border); margin: 12px 0; }
      .faf-context-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-top: 10px; }
      .faf-context { border: 1px solid var(--border); border-radius: 8px; background: var(--bg); padding: 10px 12px; min-width: 0; }
      .faf-context-k { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
      .faf-context-v { font-family: var(--font-mono); font-size: 20px; font-weight: 650; color: var(--text); margin: 3px 0 5px; }
      .faf-phase-list { display: flex; gap: 8px 14px; flex-wrap: wrap; font-size: 11px; color: var(--text-2, var(--text)); }
      .faf-phase-track { height: 16px; display: flex; overflow: hidden; margin: 8px 0 9px; border: 1px solid var(--border); border-radius: 5px; background: var(--panel); }
      .faf-phase-track > span { min-width: 2px; }
      .faf-phase-track .p0, .faf-phase-list .p0 { background: #8db8e8; }
      .faf-phase-track .p1, .faf-phase-list .p1 { background: #e9ad67; }
      .faf-phase-track .p2, .faf-phase-list .p2 { background: #68ba97; }
      .faf-phase-list i { width: 7px; height: 7px; display: inline-block; margin-right: 5px; border-radius: 2px; }
      .faf-asym-list { display: grid; gap: 10px; padding: 12px; }
      .faf-asym-row { display: grid; grid-template-columns: minmax(145px,.7fr) minmax(260px,1.7fr) 64px; align-items: center; gap: 12px; }
      .faf-asym-label strong, .faf-asym-label span { display: block; }
      .faf-asym-label strong { color: var(--text); font-size: 11px; }
      .faf-asym-label span { margin-top: 2px; color: var(--muted); font-size: 9.5px; }
      .faf-asym-axis { position: relative; height: 16px; border: 1px solid var(--border); border-radius: 5px; background: linear-gradient(90deg, rgba(239,68,68,.045), rgba(34,197,94,.045) 50%, rgba(239,68,68,.045)); overflow: hidden; }
      .faf-asym-mid { position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; background: var(--muted); opacity: .7; }
      .faf-asym-fill { position: absolute; top: 3px; bottom: 3px; border-radius: 3px; }
      .faf-asym-fill.left { right: 50%; }.faf-asym-fill.right { left: 50%; }
      .faf-asym-fill.ok { background: #4ca676; }.faf-asym-fill.watch { background: #d79a3d; }.faf-asym-fill.alert { background: #cf5c62; }
      .faf-asym-value { text-align: right; font: 700 12px var(--font-mono); }
      .faf-asym-value.ok { color: #2f8359; }.faf-asym-value.watch { color: #a56b13; }.faf-asym-value.alert { color: #b64049; }
      .faf-profile-tabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 10px 0; }
      .faf-profile-tabs button { min-height: 40px; border: 1px solid var(--border); border-radius: 8px; padding: 0 12px; background: var(--panel); color: var(--muted); font: 650 10.5px var(--font-sans); cursor: pointer; transition-property: transform, background-color, border-color, color; transition-duration: .16s; }
      .faf-profile-tabs button:hover { border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); color: var(--text); }
      .faf-profile-tabs button:active { transform: scale(.96); }
      .faf-profile-tabs button.on { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: var(--accent-soft); color: var(--accent); }
      .faf-profile-groups { display: grid; grid-template-columns: repeat(auto-fit,minmax(280px,1fr)); gap: 10px; padding: 10px; }
      .faf-profile-group { min-width: 0; border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; background: var(--bg); }
      .faf-profile-group h4 { margin: 0 0 8px; color: var(--text); font-size: 10.5px; }
      .faf-profile-list { display: grid; gap: 7px; }
      .faf-profile-row { display: grid; grid-template-columns: minmax(90px,.8fr) minmax(110px,1.5fr) 76px; align-items: center; gap: 8px; }
      .faf-profile-row span { overflow: hidden; color: var(--muted); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
      .faf-profile-track { height: 8px; overflow: hidden; border-radius: 999px; background: var(--panel); border: 1px solid var(--border); }
      .faf-profile-track i { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: #6675ad; }
      .faf-profile-row strong { color: var(--text); text-align: right; font: 650 10.5px var(--font-mono); font-variant-numeric: tabular-nums; }
      .faf-cols { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(0,1fr); gap: 14px; }
      .faf-cols > div { min-width: 0; }
      .faf-tablewrap { max-height: 430px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; }
      .faf-tbl { width: 100%; border-collapse: collapse; font-size: 12px; }
      .faf-tbl th { text-align: right; color: var(--muted); font-weight: 500; font-size: 10.5px; padding: 5px 8px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--panel); }
      .faf-tbl th:first-child { text-align: left; }
      .faf-tbl td { padding: 5px 8px; border-bottom: 1px solid var(--panel-hi, var(--border)); }
      .faf-m { color: var(--text); }
      .faf-v { font-family: var(--font-mono); text-align: right; white-space: nowrap; }
      .faf-grp { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .07em; padding-top: 10px !important; border-bottom: none !important; }
      .faf-kpirow { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 8px; }
      .faf-kpi { position: relative; min-width: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; overflow: hidden; }
      .faf-kpi-l { font-size: 10.5px; color: var(--muted); }
      .faf-kpi-v { font-family: var(--font-mono); font-size: 19px; font-weight: 650; margin-top: 3px; color: var(--text); }
      .faf-kpi-v small { font-size: 10px; color: var(--muted); font-weight: 400; margin-left: 3px; }
      .faf-kpi-meta { display: flex; justify-content: space-between; gap: 8px; margin-top: 8px; color: var(--muted); font-size: 9.5px; }
      .faf-kpi-meta strong { color: var(--text-2, var(--text)); font: 600 9.5px var(--font-mono); }
      .faf-kpi-range { position: relative; height: 12px; margin-top: 5px; }
      .faf-kpi-range::before { content: ''; position: absolute; left: 0; right: 0; top: 5px; height: 2px; border-radius: 2px; background: var(--border); }
      .faf-kpi-range i { position: absolute; top: 2px; width: 7px; height: 7px; transform: translateX(-50%); border: 1px solid var(--panel); border-radius: 50%; background: var(--muted-2, var(--muted)); }
      .faf-kpi-range i.sel { z-index: 2; width: 9px; height: 9px; top: 1px; background: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
      .faf-ledger { color: var(--muted); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; font-size: 11px; margin-top: 10px; line-height: 1.6; }
      .faf-metric-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 16px 0 8px; }
      .faf-metric-browser { padding: 0 10px 10px; }
      .faf-metric-browser .faf-metric-head { margin-top: 10px; }
      .faf-metric-head > div:first-child { margin-right: auto; }
      .faf-search { min-width: 190px; font: inherit; font-size: 11px; padding: 6px 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); }
      .faf-viewtabs { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
      .faf-viewtabs button, .faf-sections button { font: inherit; font-size: 10.5px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); padding: 4px 9px; cursor: pointer; }
      .faf-viewtabs button.on, .faf-sections button.on { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
      .faf-sections { display: flex; gap: 3px; flex-wrap: wrap; margin-bottom: 9px; }
      .faf-sections button { border: 1px solid var(--border); }
      .faf-metric-groups { display: grid; gap: 9px; }
      .faf-metric-group { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); }
      .faf-metric-group-head { display: flex; justify-content: space-between; align-items: center; padding: 7px 10px; border-bottom: 1px solid var(--border); background: var(--bg); color: var(--text); font-size: 10.5px; font-weight: 650; }
      .faf-metric-group-head span:last-child { color: var(--muted); font-weight: 400; font-family: var(--font-mono); }
      .faf-metric-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(230px,1fr)); }
      .faf-metric-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 12px; min-height: 46px; padding: 7px 10px; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); }
      .faf-metric-label { font-size: 11px; line-height: 1.35; color: var(--text-2, var(--text)); min-width: 0; }
      .faf-metric-value { font-family: var(--font-mono); font-size: 15px; font-weight: 650; color: var(--text); white-space: nowrap; }
      .faf-metric-value small { font-family: var(--font-sans); font-size: 9.5px; color: var(--muted); font-weight: 400; margin-left: 4px; }
      .faf-empty { padding: 26px; text-align: center; color: var(--muted); font-size: 12px; }
      .faf-tip { border-bottom: 1px dotted var(--muted); cursor: help; position: relative; }
      .faf-tip:hover::after { content: attr(data-tip); position: absolute; left: 0; bottom: calc(100% + 6px); z-index: 40; background: #1f242c; color: #f2f4f7; font-size: 11px; font-weight: 400; padding: 7px 10px; border-radius: 7px; white-space: pre-line; width: max-content; max-width: 300px; box-shadow: 0 6px 20px rgba(22,28,40,.25); line-height: 1.5; }
      .faf-tip:hover::before { content: ''; position: absolute; left: 12px; bottom: calc(100% + 1px); border: 5px solid transparent; border-top-color: #1f242c; z-index: 40; }
      .faf-trials { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
      .faf-tchip { font-size: 12px; font-family: var(--font-mono); padding: 3px 11px; border-radius: 999px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); cursor: pointer; }
      .faf-tchip.rep { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
      .faf-tchip.sel { box-shadow: 0 0 0 2px var(--accent) inset; color: var(--accent); }
      .faf-setdef { font: inherit; font-size: 11px; padding: 3px 11px; border-radius: 7px; border: 1px solid var(--accent); background: var(--accent-soft); color: var(--accent); cursor: pointer; }
      .faf-setdef:disabled { opacity: .5; cursor: default; }
      .faf-setok { font-size: 11px; color: var(--accent); font-weight: 600; }
      .faf-tcmp { width: 100%; border-collapse: collapse; font-size: 12px; }
      .faf-tcmp th, .faf-tcmp td { padding: 5px 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); white-space: nowrap; }
      .faf-tcmp th { color: var(--muted); font-weight: 500; font-size: 10.5px; font-family: inherit; position: sticky; top: 0; background: var(--panel); }
      .faf-tcmp td:first-child, .faf-tcmp th:first-child { text-align: left; font-family: inherit; }
      .faf-tcmp .repcol { background: var(--accent-soft); }
      .faf-tcmp .best { color: var(--accent); font-weight: 600; }
      .faf-stat-intro { display: grid; gap: 2px; margin-bottom: 7px; padding: 8px 10px; border-left: 3px solid var(--accent); background: var(--bg); color: var(--text); font-size: 11px; line-height: 1.45; }
      .faf-stat-intro div:last-child { color: var(--muted); font-size: 10.5px; }
      .faf-stat-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 8px; padding: 10px; }
      .faf-stat-card { min-width: 0; padding: 9px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
      .faf-stat-card b { display: block; overflow: hidden; color: var(--text); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
      .faf-stat-values { display: grid; grid-template-columns: repeat(3,1fr); gap: 5px; margin-top: 7px; }
      .faf-stat-values span { color: var(--muted); font-size: 8.5px; }.faf-stat-values strong { display: block; margin-top: 2px; color: var(--text); font: 650 10.5px var(--font-mono); }
      .faf-stat-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 10px 10px; }
      .faf-stat-toggle { border: 1px solid var(--border); border-radius: 7px; padding: 6px 9px; background: var(--panel); color: var(--accent); font: 600 10.5px var(--font-sans); cursor: pointer; }
      .faf-icc-na { color: var(--muted); font-family: inherit !important; cursor: help; }
      .faf-grp2 { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .07em; text-align: left !important; padding-top: 10px !important; }
      @media (max-width: 1040px) { .faf-workbench-grid { grid-template-columns: 190px minmax(0,1fr); } .faf-stat-summary { grid-template-columns: 1fr 1fr; } }
      @media (max-width: 880px) { .faf-workbench-grid { grid-template-columns: 1fr; } .faf-session-browser { position: static; max-height: 220px; border-right: 0; border-bottom: 1px solid var(--border); } .faf-session-list { grid-template-columns: repeat(auto-fill,minmax(170px,1fr)); } .faf-chart-grid { grid-template-columns: 1fr; grid-template-areas: "ft" "norm" "fd" "fv"; } .faf-context-grid { grid-template-columns: 1fr; } .faf-kpirow { grid-template-columns: 1fr 1fr; } .faf-protocol { margin-left: 0; flex-basis: 100%; } .faf-section-head { align-items: flex-start; flex-direction: column; } .faf-asym-row { grid-template-columns: 1fr 58px; } .faf-asym-axis { grid-column: 1 / -1; grid-row: 2; } .faf-asym-value { grid-column: 2; grid-row: 1; } }
    `;
    const el = document.createElement('style');
    el.id = 'force-analysis-style';
    el.textContent = css;
    document.head.appendChild(el);
  }

  // ── ForceAnalysisFace — the 分析 mode surface (window-exposed; mounted by
  //    app-view-renderers when captureMode === 'analyze') ────────────────────────
  function ForceAnalysisFace({ typeId, athleteId, roster, stores, fresh, onConsumeFresh, onBackToCollect, onOpenReport, onDeleteSession, onReanalyzeSession, traceRead, traceReadModel }) {
    React.useEffect(() => { fafEnsureStyle(); }, []);
    const descriptor = fafDescriptor(typeId);
    const moduleDefs = descriptor.modules || [];
    const [enabled, setEnabled] = React.useState(() => {
      const s = {}; moduleDefs.forEach((m) => { s[m.id] = m.on; }); return s;
    });
    const [overlays, setOverlays] = React.useState({ vel: true, disp: false, acc: false, power: false });
    const [normalizeX, setNormalizeX] = React.useState(false);
    const [metricsView, setMetricsView] = React.useState('matrix');
    const [metricSection, setMetricSection] = React.useState('全部');
    const [profileSection, setProfileSection] = React.useState('RFD');
    const [metricQuery, setMetricQuery] = React.useState('');
    const [sessionQuery, setSessionQuery] = React.useState('');
    const [deletePreviewOpen, setDeletePreviewOpen] = React.useState(false);
    const [sessionActionState, setSessionActionState] = React.useState(null);
    const freshHere = (fresh && fresh.type === typeId) ? fresh : null;
    const [selId, setSelId] = React.useState(freshHere ? freshHere.id : null);
    const [anNote, setAnNote] = React.useState('');
    // WS-3b: trial 比对 module toggle · previewed trial (array pos; null = follow the
    // session default) · a transient 已设为默认 confirmation after persisting.
    const [cmpOpen, setCmpOpen] = React.useState(false);
    const [expandedChartId, setExpandedChartId] = React.useState(null);
    const [previewPos, setPreviewPos] = React.useState(null);
    const [justSetDefault, setJustSetDefault] = React.useState(false);
    const [compareChoice, setCompareChoice] = React.useState({ forKey: null, trialIndices: new Set() });
    const REP_DOMAIN = 'force-session-rep';
    const prefsRepo = (typeof window !== 'undefined' && window.VizPanelPrefsRepo) || null;

    // A type switch gets a clean presentation state from that type's descriptor. Stale CMJ
    // controls must never leak into SJ/IMTP or a future test.
    React.useEffect(() => {
      const next = {}; moduleDefs.forEach((m) => { next[m.id] = m.on; });
      setEnabled(next); setOverlays({ vel: true, disp: false, acc: false, power: false });
      setNormalizeX(false); setMetricsView('matrix'); setMetricSection('全部'); setProfileSection('RFD'); setMetricQuery(''); setSessionQuery('');
      setCmpOpen(false); setPreviewPos(null);
      setExpandedChartId(null);
      setCompareChoice({ forKey: null, trialIndices: new Set() });
    }, [typeId]);
    React.useEffect(() => {
      if (!expandedChartId || typeof document === 'undefined') return undefined;
      const previousOverflow = document.body.style.overflow;
      const onKeyDown = (event) => { if (event.key === 'Escape') setExpandedChartId(null); };
      document.body.style.overflow = 'hidden';
      document.addEventListener('keydown', onKeyDown);
      return () => { document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', onKeyDown); };
    }, [expandedChartId]);

    const FSS = (typeof window !== 'undefined' && window.ForceSessionSource) || null;
    const FTS = (typeof window !== 'undefined' && window.ForceTrialStatistics) || null;
    const types = FSS ? FSS.listTestTypes() : [];
    const typeLabel = (types.find((t) => t.id === typeId) || {}).label || descriptor.label || String(typeId || '').toUpperCase();
    const athlete = (roster || []).find((a) => a.id === athleteId) || null;
    const inputs = fwsInputsForAthlete(stores, athleteId);
    const list = FSS ? FSS.listSessions(inputs, typeId) : []; // newest-first
    const sessionNeedle = sessionQuery.trim().toLowerCase();
    const visibleSessions = list.filter((entry) => {
      if (!sessionNeedle) return true;
      const s = entry.session || {};
      return [entry.id, entry.date, s.fileName, s.provenance && s.provenance.fileName]
        .filter(Boolean).join(' ').toLowerCase().includes(sessionNeedle);
    });
    const activeEntry = list.find((r) => r.id === selId) || list[0] || null;
    const session = activeEntry ? activeEntry.session : null;
    const isFreshActive = !!(freshHere && activeEntry && activeEntry.id === freshHere.id);

    // ── FORCE-TRACE M3-A: session trace bundle (CMJ only) ────────────────────────
    // Persistent availability derives ONLY from traceRefs[] (sealed M2 contract); the runtime
    // read is a SEPARATE state. ONE listBySession per session → Map<trial.index, per-trial entry>
    // (ready / runtime-missing / corrupt / persistent-missing). Representative/preview switching
    // only re-points into this bundle — never another storage read. The accessor's promises can't
    // be aborted, so a generation token discards stale results when the session changes fast.
    // availability='none' (old data) → labeled compact fallback; refs-bearing session with no
    // accessor / a failed read → 'storage-error' placeholder (NEVER silently compact).
    const traceAvailability = (typeId === 'cmj' && session && traceReadModel)
      ? traceReadModel.deriveAvailability(session) : 'none';
    const [traceLoad, setTraceLoad] = React.useState({ state: 'idle', bundle: null, forKey: null });
    const traceGen = React.useRef(0);
    // The load IDENTITY is sessionId + the FULL refs signature (id/status/schemaVersion) — not the
    // session id alone (GPT M3-A final F1): a reconcile that downgrades a ref on the SAME session
    // must invalidate the held bundle synchronously, or the old raw chart would coexist with the
    // new 原图缺失 badge for one frame, violating the traceRefs[] single truth.
    const traceRefsSig = (session && Array.isArray(session.traceRefs))
      ? session.traceRefs.map((r) => (r ? r.id + '@' + r.status + '@' + r.schemaVersion : 'x')).join('|') : '';
    const traceKey = session ? session.id + '§' + traceRefsSig : null;
    React.useEffect(() => {
      const sid = session ? session.id : null;
      if (typeId !== 'cmj' || !sid || !traceReadModel || traceReadModel.deriveAvailability(session) === 'none') {
        traceGen.current++; setTraceLoad({ state: 'idle', bundle: null, forKey: traceKey });
        return () => { traceGen.current++; };
      }
      if (!traceRead || typeof traceRead.listBySession !== 'function') {
        traceGen.current++; setTraceLoad({ state: 'storage-error', bundle: null, forKey: traceKey });
        return () => { traceGen.current++; };
      }
      const gen = ++traceGen.current;
      setTraceLoad({ state: 'loading', bundle: null, forKey: traceKey });
      Promise.resolve().then(() => traceRead.listBySession(sid)).then((records) => {
        if (traceGen.current !== gen) return;   // stale generation → discard, never setState
        setTraceLoad({ state: 'ready', bundle: traceReadModel.buildTraceBundle(records, session), forKey: traceKey });
      }).catch(() => {
        if (traceGen.current !== gen) return;
        setTraceLoad({ state: 'storage-error', bundle: null, forKey: traceKey });
      });
      // Cleanup on unmount / key change: bump the generation so an in-flight read that
      // resolves later can never write back.
      return () => { traceGen.current++; };
    }, [typeId, traceKey]);
    // RENDER-PHASE guard: any render whose CURRENT key (session + refs signature) differs from the
    // one the held load was produced for treats the gap as loading — covers both a session switch
    // AND a same-session refs change, before the effect has run.
    const effTraceLoad = (traceLoad.forKey === traceKey)
      ? traceLoad
      : { state: 'loading', bundle: null, forKey: traceKey };
    const traceCtx = { availability: traceAvailability, load: effTraceLoad };

    // Switching session resets the preview (a per-session choice) + the confirmation cue.
    const onPickSession = (id) => { setSelId(id); setPreviewPos(null); setJustSetDefault(false); if (freshHere) onConsumeFresh(); };
    const onPickTrial = (pos) => { setPreviewPos(pos); setJustSetDefault(false); };

    // ── WS-3b representative resolution ──────────────────────────────────────────
    // trials[] + the session's OWN best/representative marker (do NOT recompute — read
    // what cmj/sj/imtp.jsx already stamped: representative.index is a trial.index).
    const trials = (session && Array.isArray(session.trials)) ? session.trials : [];
    const hasTrials = trials.length > 0;
    const repMarkPos = (function () {
      const ri = session && session.representative ? session.representative.index : null;
      const p = trials.findIndex((t) => t.index === ri);
      return p >= 0 ? p : 0;
    })();
    // Persisted per-session override (display default). Read-only here; the keep-valid
    // guard (drop a stale index) runs in an effect below to keep writes out of render.
    const sessionKey = activeEntry ? String(activeEntry.id) : null;
    const overrideRaw = (function () {
      if (!prefsRepo || !sessionKey || !hasTrials) return null;
      const map = prefsRepo.load(REP_DOMAIN) || {};
      const raw = map[sessionKey];
      return (typeof raw === 'number' && isFinite(raw)) ? raw : null;
    })();
    const overrideValid = overrideRaw != null && overrideRaw >= 0 && overrideRaw < trials.length;
    React.useEffect(() => {
      if (!prefsRepo || !sessionKey) return;
      if (overrideRaw != null && !overrideValid) { // stale (session re-imported fewer trials) → drop
        const map = prefsRepo.load(REP_DOMAIN) || {};
        if (map[sessionKey] != null) { const next = Object.assign({}, map); delete next[sessionKey]; prefsRepo.save(REP_DOMAIN, next); }
      }
    }, [sessionKey, overrideRaw, overrideValid]);
    // Effective session default = valid override else the session's own best; the preview
    // (temporary) follows it until the user clicks another chip.
    const defaultPos = overrideValid ? overrideRaw : repMarkPos;
    const effPreviewPos = (previewPos != null && previewPos < trials.length) ? previewPos : defaultPos;
    const previewTrial = hasTrials ? (trials[effPreviewPos] || trials[defaultPos] || trials[0]) : null;
    const defaultTrialIndex = hasTrials && trials[defaultPos] ? trials[defaultPos].index : null;
    const drawableTrialIndices = new Set(typeId !== 'cmj' ? [] : trials.filter((trial) => {
      if (traceAvailability === 'none') return !!(trial.curve && trial.curve.t && trial.curve.t.length);
      if (effTraceLoad.state !== 'ready' || !effTraceLoad.bundle) return false;
      const entry = effTraceLoad.bundle.get(trial.index);
      return !!(entry && entry.state === 'ready');
    }).map((trial) => trial.index));
    const comparisonDefaultTrialIndex = drawableTrialIndices.has(defaultTrialIndex)
      ? defaultTrialIndex
      : ((trials.find((trial) => drawableTrialIndices.has(trial.index)) || {}).index ?? null);
    const drawableSignature = trials.map((trial) => drawableTrialIndices.has(trial.index) ? String(trial.index) : 'x').join('|');
    const compareChoiceKey = [typeId, sessionKey || '', comparisonDefaultTrialIndex == null ? '' : comparisonDefaultTrialIndex, drawableSignature].join('§');
    const rawCompareTrialIndices = compareChoice.forKey === compareChoiceKey
      ? compareChoice.trialIndices
      : new Set(comparisonDefaultTrialIndex == null ? [] : [comparisonDefaultTrialIndex]);
    const compareTrialIndices = new Set(Array.from(rawCompareTrialIndices).filter((trialIndex) => drawableTrialIndices.has(trialIndex)));
    React.useEffect(() => {
      setCompareChoice({
        forKey: compareChoiceKey,
        trialIndices: new Set(comparisonDefaultTrialIndex == null ? [] : [comparisonDefaultTrialIndex]),
      });
    }, [compareChoiceKey]);
    const toggleCompareTrial = (trialIndex) => {
      if (!drawableTrialIndices.has(trialIndex)) return;
      setCompareChoice((prev) => {
      const current = prev.forKey === compareChoiceKey
        ? new Set(prev.trialIndices)
        : new Set(comparisonDefaultTrialIndex == null ? [] : [comparisonDefaultTrialIndex]);
      if (current.has(trialIndex)) current.delete(trialIndex); else current.add(trialIndex);
      return { forKey: compareChoiceKey, trialIndices: current };
      });
    };
    const selectRepresentativeOnly = () => setCompareChoice({
      forKey: compareChoiceKey,
      trialIndices: new Set(comparisonDefaultTrialIndex == null ? [] : [comparisonDefaultTrialIndex]),
    });
    const selectAllTrials = () => setCompareChoice({
      forKey: compareChoiceKey,
      trialIndices: new Set(drawableTrialIndices),
    });
    // Persist the previewed trial as the session's display default (repository only —
    // the stored session object is never mutated). After persist the default IS this
    // trial, so drop the preview override and follow it; show 已设为默认.
    const setSessionDefault = () => {
      if (!prefsRepo || !sessionKey || !hasTrials) return;
      const map = prefsRepo.load(REP_DOMAIN) || {};
      prefsRepo.save(REP_DOMAIN, Object.assign({}, map, { [sessionKey]: effPreviewPos }));
      setPreviewPos(null);
      setJustSetDefault(true);
    };
    const reanalyzeSession = async () => {
      if (!session || typeof onReanalyzeSession !== 'function') return;
      setSessionActionState('loading-source');
      try {
        const outcome = await onReanalyzeSession(session);
        if (!outcome || !outcome.ok) setSessionActionState('source-missing');
      } catch {
        setSessionActionState('source-error');
      }
    };
    const confirmDeleteSession = async () => {
      if (!session || typeof onDeleteSession !== 'function') return;
      setSessionActionState('deleting');
      try {
        const outcome = await onDeleteSession(athleteId, session.id);
        if (outcome && outcome.deleted === false) { setSessionActionState('delete-error'); return; }
        setDeletePreviewOpen(false);
        setSelId(null);
        setSessionActionState(null);
      } catch {
        setSessionActionState('delete-error');
      }
    };

    const sourceLabel = !activeEntry ? '来源：—'
      : (isFreshActive ? '来源：采集保存 · ' + fafDate(activeEntry.date) : '来源：会话 · ' + fafDate(activeEntry.date));
    // Runtime CONSUMER of the persisted sampling status (GPT audit P1.2): read it straight off
    // the session (the analysis frame must not reach into force-core internals — layering gate).
    // limited/non-uniform/invalid → honest notice + excluded from cross-session comparison; an old
    // session with no profile shows no notice (never retro-flagged).
    const _sampStatus = (session && session.algorithmRef && session.algorithmRef.sampling) ? session.algorithmRef.sampling.status : null;
    const reliability = _sampStatus === 'limited' ? { limited: true, comparable: false, note: '采样率不足 · 完整指标仅供参考' }
      : _sampStatus === 'non-uniform' ? { limited: true, comparable: false, note: '采样时间轴存在间隔或抖动 · 已按中位采样间隔分析，指标仅供参考' }
      : _sampStatus === 'invalid' ? { limited: true, comparable: false, note: '时间轴异常 · 指标不可信' }
      : { limited: false, comparable: true, note: null };

    // F7 return-path anchors — carry the session context in the action (no session-id
    // memorization): ← 采集 (back to upload) · 审阅此会话 (R1 seam, see below) · 加入报告
    // (reuse the FORCE-4 ForceReportWorkbench entry for this athlete).
    const nav = (
      <div className="faf-nav">
        <a className="faf-crumb" onClick={onBackToCollect}>← 采集</a>
        <span className="faf-sep">·</span>
        <span className="faf-note">{sourceLabel}</span>
        {reliability.note && <span className="faf-note faf-rate-warn" style={{ color: 'var(--danger, #b45309)' }} title="采样率/时间轴未达完整指标标准 · 已排除跨会话可信比较">⚠ {reliability.note}</span>}
        <span style={{ marginLeft: 'auto' }} />
        {/* 审阅此会话: the per-session Review Database entry is NOT cleanly reachable from
            here (the review-row model + a session→row resolver aren't threaded to the
            force view, and the full 三栏审阅台 is R1 / not built — masterplan v2.1 §18).
            So this stays an honest placeholder anchor this slice (present, non-navigating).
            FUTURE (R1): resolve the review row and open the review workbench at it. */}
        <a className="faf-crumb ghost" title="审阅工作台（R1）建设中" onClick={() => setAnNote('审阅工作台（R1）建设中 · 本会话已定位，无需记 session id')}>审阅此会话 →</a>
        <a className="faf-crumb" onClick={() => { if (typeof onOpenReport === 'function' && athlete) onOpenReport(athleteId); }}>加入报告 →</a>
      </div>
    );

    if (!athlete) return <div className="faf-wrap">{nav}<div className="faf-empty">未选择运动员。</div></div>;
    if (!descriptor.supported) return <div className="faf-wrap">{nav}<div className="faf-empty">{typeLabel} 尚未接入富分析工作台。请先为该测试声明指标、曲线与阶段能力；不会自动套用 CMJ 逻辑。</div></div>;
    if (!activeEntry) {
      return (
        <div className="faf-wrap">{nav}
          <div className="faf-empty">{athlete.name} 暂无 {typeLabel} 会话 —— 先在「采集」导入力板文件。</div>
        </div>
      );
    }

    const defs = fafDefsFor(typeId);
    // WS-3b: the 指标表 / KPI / 图 read the PREVIEWED trial's raw stored metrics (display
    // only — 不改存储). Sessions without a trials[] array fall back to the session-level
    // canonical value (old-data behavior, unchanged).
    const readVal = (key) => hasTrials ? fafTrialValue(previewTrial, key, session, typeId) : FSS.getMetricValue(session, typeId, key);
    const valued = defs.map((d) => ({ def: d, v: readVal(d.key) })).filter((x) => x.v != null);
    const valuedByKey = new Map(valued.map((r) => [r.def.key, r]));
    const featured = (descriptor.featuredMetricKeys || []).map((key) => valuedByKey.get(key)).filter(Boolean);
    const kpis = featured.concat(valued.filter((r) => !featured.includes(r))).slice(0, 4);
    const kpiEvidence = kpis.map((row) => {
      const cells = hasTrials ? trials.map((trial) => fafTrialValue(trial, row.def.key, session, typeId)) : [row.v];
      const finite = cells.filter((value) => value != null && isFinite(value));
      const stats = FTS ? FTS.summarizeWithinSession(cells, { cvEligible: FTS.isCvEligibleMetric(row.def) }) : null;
      const min = finite.length ? Math.min.apply(null, finite) : null;
      const max = finite.length ? Math.max.apply(null, finite) : null;
      const span = min != null && max != null ? max - min : 0;
      const positions = cells.map((value) => value == null || !isFinite(value) ? null : (span > 0 ? (value - min) / span * 100 : 50));
      return { row, stats, positions };
    });
    const sections = ['全部'].concat(Array.from(new Set(valued.map((r) => r.def.section || '指标'))));
    const query = metricQuery.trim().toLowerCase();
    const visibleValued = valued.filter((r) => {
      const inSection = metricSection === '全部' || (r.def.section || '指标') === metricSection;
      const haystack = (r.def.label + ' ' + r.def.key + ' ' + (r.def.unit || '')).toLowerCase();
      return inSection && (!query || haystack.includes(query));
    });
    const groups = [];
    visibleValued.forEach((r) => {
      const sec = r.def.section || '指标';
      let g = groups.find((x) => x.sec === sec);
      if (!g) { g = { sec: sec, rows: [] }; groups.push(g); }
      g.rows.push(r);
    });
    const metricProfiles = FTS && typeof FTS.buildMetricProfiles === 'function' ? FTS.buildMetricProfiles(valued) : [];
    const effectiveProfileSection = FTS && typeof FTS.preferredMetricProfileSection === 'function'
      ? FTS.preferredMetricProfileSection(metricProfiles, profileSection) : null;
    const activeMetricProfile = metricProfiles.find((profile) => profile.section === effectiveProfileSection) || null;
    const chartSet = fafCharts(typeId, session, enabled, previewTrial ? previewTrial.index : undefined, traceCtx, {
      overlays: overlays,
      normalizeX: normalizeX,
      compareTrialIndices: compareTrialIndices,
      onExpandChart: setExpandedChartId,
    });
    const expandedChartNode = expandedChartId
      ? chartSet.primary.concat(chartSet.comparison).find((node) => node && node.props && node.props['data-faf-chart'] === expandedChartId)
      : null;
    const expandedChartTitle = expandedChartId === 'ft' ? '原始 F-t / 运动学'
      : expandedChartId === 'norm' ? '归一化力时序'
      : expandedChartId === 'fd' ? 'F-D 环'
      : expandedChartId === 'fv' ? 'F-V 环' : '曲线图';
    const previewLabel = hasTrials ? ('T' + (effPreviewPos + 1)) : null;
    const durationDef = descriptor.duration || null;
    const durationValue = durationDef ? readVal(durationDef.key) : null;
    const phaseTimes = durationDef ? (durationDef.phases || []).map((p) => ({ label: p.label, value: readVal(p.key) })) : [];
    const hasPhaseTimes = durationValue > 0 && phaseTimes.length > 0 && phaseTimes.every((p) => p.value != null && isFinite(p.value));
    const asymRows = typeId === 'cmj' ? [
      ['asymBraking', '制动力', 'Braking Force'],
      ['asymProp', '推进力', 'Propulsive Force'],
      ['asymBrakImpulse', '制动冲量', 'Braking Impulse'],
      ['asymPropImpulse', '推进冲量', 'Propulsive Impulse'],
    ].map(([key, label, en]) => ({ key, label, en, value: readVal(key) }))
      .filter((row) => row.value != null && isFinite(row.value)) : [];
    const onsetPolicy = session && session.algorithmRef ? session.algorithmRef.onsetPolicy : null;

    // WS-3b trial × 指标比对 (choice: FULL summary-metric set — clean, since every trial
    // carries all metric keys — grouped by section, 参照 5b; NOT the 4-KPI subset). Rows =
    // metrics with ≥1 non-null trial value; cols = trials T1..Tn (代表列高亮) + 最优. The
    // per-row 最优 is dir-aware via fafBestPos(def.better). 原始实测值 · 不归一化 · 不排名.
    const cmpGroups = [];
    if (hasTrials) {
      defs.forEach((d) => {
        const cells = trials.map((t) => fafTrialValue(t, d.key, session, typeId));
        if (!cells.some((v) => v != null)) return;
        const sec = d.section || '指标';
        let g = cmpGroups.find((x) => x.sec === sec);
        if (!g) { g = { sec: sec, rows: [] }; cmpGroups.push(g); }
        g.rows.push({
          def: d,
          cells: cells,
          bestPos: fafBestPos(cells, d.better),
          stats: FTS ? FTS.summarizeWithinSession(cells, { cvEligible: FTS.isCvEligibleMetric(d) }) : null,
        });
      });
    }

    return (
      <div className="faf-wrap" data-faf-workbench="rich" data-force-analysis-type={typeId}>
        {nav}
        <div className="faf-workbench-grid" data-faf-session-workbench>
          <aside className="faf-session-browser" aria-label={`${typeLabel} 会话浏览器`}>
            <h3>{typeLabel} 会话</h3>
            <p>选择历史会话；采集保存后直接回到同一工作台。</p>
            <input className="faf-session-search" value={sessionQuery} onChange={(event) => setSessionQuery(event.target.value)}
              placeholder="搜索日期、文件或会话" aria-label="搜索测力台会话" />
            <div className="faf-session-list">
              {visibleSessions.map((entry, index) => {
                const itemSession = entry.session || {};
                const itemTrials = Array.isArray(itemSession.trials) ? itemSession.trials.length : (itemSession.jumpCount || itemSession.trialCount || 0);
                const itemFile = itemSession.fileName || (itemSession.provenance && itemSession.provenance.fileName) || '未记录源文件';
                const itemAvailability = typeId === 'cmj' && traceReadModel ? traceReadModel.deriveAvailability(itemSession) : null;
                const availabilityLabel = itemAvailability === 'available' ? '原图完整' : itemAvailability === 'partial' ? '原图部分可用' : itemAvailability === 'missing' ? '原图缺失' : (typeId === 'cmj' ? '旧会话 · 压缩曲线' : '已存 F-t');
                const active = activeEntry && entry.id === activeEntry.id;
                return (
                  <button type="button" key={entry.id != null ? entry.id : index} className={'faf-session-item' + (active ? ' on' : '')}
                    data-faf-session-id={entry.id}
                    aria-current={active ? 'true' : undefined} onClick={() => onPickSession(entry.id)}>
                    <b>{fafDate(entry.date)} · {typeLabel}</b>
                    <span title={itemFile}>{itemFile}</span>
                    <small>{itemTrials + ' trials · ' + availabilityLabel + (list[0] && entry.id === list[0].id ? ' · 最新' : '')}</small>
                  </button>
                );
              })}
              {!visibleSessions.length ? <div className="faf-session-empty">没有符合搜索条件的会话。</div> : null}
            </div>
          </aside>
          <main className="faf-card">
          <div className="faf-h">
            <span className="faf-h-t">{typeLabel} · 富分析工作台</span>
            <span className="faf-note">{fafDate(activeEntry.date)}{session && session.fileName ? ' · ' + session.fileName : ''}</span>
            {isFreshActive ? <span className="faf-fresh">刚保存</span> : null}
            {typeId === 'cmj' ? (
              traceAvailability === 'available' ? <span className="faf-avail full">原图完整</span>
              : traceAvailability === 'partial' ? <span className="faf-avail partial">原图部分可用</span>
              : traceAvailability === 'missing' ? <span className="faf-avail missing">原图缺失</span>
              : <span className="faf-avail legacy">旧会话 · 无原图</span>
            ) : null}
            <span className="faf-note">历史会话只读 · 图表、完整指标与 trial 证据同屏</span>
            {typeId === 'cmj' && typeof onReanalyzeSession === 'function' ? (
              <button type="button" className="faf-setdef" onClick={reanalyzeSession}
                disabled={!session.sourceFileRef || sessionActionState === 'loading-source'}
                title={session.sourceFileRef ? '使用已保存的原始 CSV 回到采集页重新分析' : '此历史会话未保存原始 CSV'}>
                {sessionActionState === 'loading-source' ? '正在读取…' : '重新分析'}
              </button>
            ) : null}
            {typeof onDeleteSession === 'function' ? (
              <button type="button" className="faf-setdef" onClick={() => { setSessionActionState(null); setDeletePreviewOpen(true); }}
                style={{ color: 'var(--danger, #b42318)' }}>删除会话</button>
            ) : null}
          </div>
          {sessionActionState === 'source-missing' ? <div className="faf-empty">原始 CSV 已缺失，无法重新分析；会话和现有指标未受影响。</div> : null}
          {sessionActionState === 'source-error' ? <div className="faf-empty">读取原始 CSV 失败，请稍后重试。</div> : null}
          {sessionActionState === 'delete-error' ? <div className="faf-empty">会话删除未完整持久化，系统已保留原始数据。</div> : null}

          {/* WS-3b (2026-07-12): seam filled — 代表 trial 改选 (temporary preview · 不改存储)
              + 设为会话默认代表 (persist via 'force-session-rep' prefs domain). */}
          {hasTrials ? (
            <div className="faf-trials">
              <span className="faf-note">代表 trial</span>
              {trials.map((t, i) => {
                const isDefault = i === defaultPos, isSel = i === effPreviewPos;
                return (
                  <span key={t.index != null ? t.index : i}
                        className={'faf-tchip' + (isDefault ? ' rep' : '') + (isSel ? ' sel' : '')}
                        onClick={() => onPickTrial(i)}>
                    {'T' + (i + 1)}{isDefault ? ' · 代表' : ''}
                  </span>
                );
              })}
              <span className="faf-note">改选影响图/指标显示，不改存储</span>
              {effPreviewPos !== defaultPos
                ? <button className="faf-setdef" onClick={setSessionDefault}>设为会话默认代表</button>
                : null}
              {justSetDefault ? <span className="faf-setok">已设为默认</span> : null}
            </div>
          ) : null}

          <section className="faf-analysis-section" data-faf-outcome-summary>
            <div className="faf-section-head">
              <div><span className="faf-section-index">1</span><strong>核心结果 · {hasTrials ? previewLabel : '会话值'}</strong></div>
              <span>{hasTrials ? '圆点 = 各 Trial；高亮 = 当前 Trial；只呈现会话内证据' : '当前会话已存结果'}</span>
            </div>
            <div className="faf-kpirow" style={{ padding: 10 }}>
              {kpiEvidence.map(({ row, stats, positions }) => (
                <div className="faf-kpi" key={row.def.key} data-metric-key={row.def.key}>
                  <div className="faf-kpi-l">{row.def.label}</div>
                  <div className="faf-kpi-v">{fafFmt(row.v, row.def.unit)}<small>{row.def.unit || ''}</small></div>
                  {hasTrials ? (
                    <>
                      <div className="faf-kpi-meta">
                        <span>会话 Mean <strong>{stats && stats.mean != null ? fafFmt(stats.mean, row.def.unit) : '—'}</strong></span>
                        <span>CV <strong>{stats && stats.cvPct != null ? fafFmt(stats.cvPct, '%') + '%' : '—'}</strong></span>
                      </div>
                      <div className="faf-kpi-range" aria-label={`${row.def.label} 的 Trial 会话内分布`}>
                        {positions.map((position, index) => position == null ? null : <i key={index} className={index === effPreviewPos ? 'sel' : ''} style={{ left: position + '%' }} title={`T${index + 1}: ${fafFmt(fafTrialValue(trials[index], row.def.key, session, typeId), row.def.unit)} ${row.def.unit || ''}`} />)}
                      </div>
                    </>
                  ) : null}
                </div>
              ))}
            </div>
          </section>

          {!descriptor.supportsKinematicOverlays ? (
            <div className="faf-capability-note">当前 {typeLabel} 会话仅保存 F-t 压缩曲线；工作台不会伪造速度、位移、F-D/F-V 或 CMJ 分型。</div>
          ) : null}

          {(chartSet.primary.length || chartSet.comparison.length) ? (
            <section className="faf-analysis-section faf-chart-section">
              <div className="faf-section-head">
                <div><span className="faf-section-index">2</span><strong>曲线证据 · 固定 2 × 2</strong></div>
                <span>第一行 F-t / 归一化 · 第二行 F-D / F-V</span>
              </div>
              {chartSet.comparison.length ? (
                <div className="faf-compare-selector" data-faf-comparison-selector>
                  <span className="faf-control-label">归一化 / F-D / F-V 图层</span>
                  {trials.map((trial, index) => {
                    const drawable = drawableTrialIndices.has(trial.index);
                    const checked = drawable && compareTrialIndices.has(trial.index);
                    return (
                      <label key={trial.index != null ? trial.index : index}
                        className={'faf-cmptrial' + (index === defaultPos ? ' rep' : '') + (checked ? ' on' : '') + (!drawable ? ' unavailable' : '')}
                        data-trial-index={trial.index}
                        title={drawable ? '切换该 trial 的比较图层' : '该 trial 无可读取曲线，不能加入图形比较'}>
                        <input type="checkbox" checked={checked} disabled={!drawable} onChange={() => toggleCompareTrial(trial.index)} />
                        <span>{'T' + (index + 1)}{index === defaultPos ? ' · 代表' : ''}{!drawable ? ' · 不可用' : ''}</span>
                      </label>
                    );
                  })}
                  <button type="button" className="faf-selector-action" disabled={comparisonDefaultTrialIndex == null} onClick={selectRepresentativeOnly}>{comparisonDefaultTrialIndex === defaultTrialIndex ? '仅代表' : '首个可用'}</button>
                  <button type="button" className="faf-selector-action" disabled={!drawableTrialIndices.size} onClick={selectAllTrials}>全选可用</button>
                  <span className="faf-note">{comparisonDefaultTrialIndex === defaultTrialIndex ? '默认仅代表 trial' : '代表 trial 曲线不可用 · 默认选择首个可绘制 trial'} · 同步作用于三张比较图，不改存储</span>
                </div>
              ) : null}
              <div className="faf-chart-grid" data-faf-fixed-chart-grid>
                {chartSet.primary.length ? (
                  <div className="faf-primary-stack" data-faf-chart-section="primary">
                    {chartSet.primary}
                    {descriptor.supportsKinematicOverlays ? (
                      <div className="faf-controls" data-faf-capability="kinematic-overlays" data-faf-control-target="primary-ft">
                        <span className="faf-control-label">仅作用于本格原始 F-t</span>
                        {[
                          ['vel', '速度'], ['disp', '位移'], ['acc', '加速度'], ['power', '功率'],
                        ].map(([key, label]) => (
                          <button key={key} type="button" data-faf-overlay={key}
                            className={'faf-control' + (overlays[key] ? ' on' : '')}
                            onClick={() => setOverlays((prev) => Object.assign({}, prev, { [key]: !prev[key] }))}>{label}</button>
                        ))}
                        <button type="button" data-faf-normalize-x className={'faf-control' + (normalizeX ? ' on' : '')}
                          onClick={() => setNormalizeX((v) => !v)}>{normalizeX ? '动作期 0–100% · 已开启' : '动作期 X 轴 0–100%'}</button>
                        <span className="faf-protocol">采集口径：{onsetPolicy || '历史会话未记录'}</span>
                      </div>
                    ) : null}
                  </div>
                ) : null}
                <div className="faf-comparison-grid" data-faf-chart-section="comparison" data-selected-count={compareTrialIndices.size}>{chartSet.comparison}</div>
              </div>
            </section>
          ) : null}

          {expandedChartNode ? (
            <div className="faf-chart-modal" role="dialog" aria-modal="true" aria-label={expandedChartTitle + '放大视图'}
              data-faf-chart-modal={expandedChartId} onMouseDown={() => setExpandedChartId(null)}>
              <div className="faf-chart-modal-panel" onMouseDown={(event) => event.stopPropagation()}>
                <div className="faf-chart-modal-head">
                  <strong>{expandedChartTitle} · {previewLabel || '当前会话'}</strong>
                  <button type="button" className="faf-chart-modal-close" aria-label="关闭放大图表" title="关闭（Esc）" onClick={() => setExpandedChartId(null)}>×</button>
                </div>
                <div className="faf-chart-modal-body">{React.cloneElement(expandedChartNode, { key: 'expanded-' + expandedChartId })}</div>
              </div>
            </div>
          ) : null}

          <div className="faf-context-grid">
            {enabled.phase ? <div className="faf-context faf-phase-card" data-faf-duration data-faf-phase-visual>
              <div className="faf-context-k">{durationDef ? durationDef.label : '动作时间'}</div>
              <div className="faf-context-v">{durationValue != null ? fafFmt(durationValue, durationDef.unit) + (durationDef.unit ? ' ' + durationDef.unit : '') : '—'}</div>
              {hasPhaseTimes ? (
                <>
                  <div className="faf-phase-track" aria-label="保存时阶段时长比例">
                    {phaseTimes.map((phase, index) => (
                      <span key={phase.label} className={'p' + index} style={{ width: Math.max(2, phase.value / durationValue * 100) + '%' }} title={`${phase.label} ${fafFmt(phase.value, 's')}s`} />
                    ))}
                  </div>
                  <div className="faf-phase-list">
                    {phaseTimes.map((phase, index) => <span key={phase.label}><i className={'p' + index}/>{phase.label} {fafFmt(phase.value, 's')}s · {Math.round(phase.value / durationValue * 100)}%</span>)}
                  </div>
                </>
              ) : <div className="faf-note">按已保存时间指标显示；缺失时不重新检测。</div>}
            </div> : null}
            {descriptor.supportsClassification ? (
              <div className="faf-context" data-faf-classification>
                <div className="faf-context-k">CMJ 分型快照</div>
                <div className="faf-context-v">{previewTrial && previewTrial.type ? 'Type ' + previewTrial.type : '—'}</div>
                <div className="faf-phase-list">
                  <span>{previewTrial ? (previewTrial.isBimodal ? '双峰' : '单峰') : '—'}</span>
                  <span>{previewTrial ? (previewTrial.isLF1 ? 'LF1' : 'LF2') : '—'}</span>
                </div>
                <div className="faf-note">显示保存时标签；未持久化的峰谷证据不重算。</div>
              </div>
            ) : (
              <div className="faf-context">
                <div className="faf-context-k">数据能力</div>
                <div className="faf-context-v">{descriptor.curveTruth === 'compact-ft' ? 'F-t + 完整指标' : '指标'}</div>
                <div className="faf-note">与 CMJ 共用会话、trial、指标与比对工作流。</div>
              </div>
            )}
          </div>

          {enabled.asym && asymRows.length ? (
            <section className="faf-analysis-section faf-asym-section" data-faf-asymmetry-visual>
              <div className="faf-section-head">
                <div><span className="faf-section-index">3</span><strong>左右不对称 · 保存时参数</strong></div>
                <span>正值 = 左侧主导 · 绿 &lt;10% · 黄 10–15% · 红 &gt;15%</span>
              </div>
              <div className="faf-asym-list">
                {asymRows.map((row) => {
                  const abs = Math.abs(row.value);
                  const severity = abs < 10 ? 'ok' : abs <= 15 ? 'watch' : 'alert';
                  const width = Math.min(50, abs / 20 * 50);
                  return (
                    <div className="faf-asym-row" key={row.key} data-metric-key={row.key}>
                      <div className="faf-asym-label"><strong>{row.label}</strong><span>{row.en}</span></div>
                      <div className="faf-asym-axis" aria-label={`${row.label} ${row.value}%`}>
                        <span className="faf-asym-mid"/>
                        <span className={'faf-asym-fill ' + severity + (row.value < 0 ? ' right' : ' left')} style={{ width: width + '%' }}/>
                      </div>
                      <div className={'faf-asym-value ' + severity}>{row.value > 0 ? '+' : ''}{fafFmt(row.value, '%')}%</div>
                    </div>
                  );
                })}
              </div>
            </section>
          ) : enabled.asym ? <div className="faf-absent">当前历史 trial 未保存左右不对称参数；不会补算或伪造。</div> : null}

          {activeMetricProfile ? (
            <section className="faf-analysis-section" data-faf-rfd-profile data-faf-metric-profile data-profile-section={effectiveProfileSection}>
              <div className="faf-section-head">
                <div><span className="faf-section-index">3b</span><strong>指标剖面 · 当前 Trial</strong></div>
                <span>RFD 仅为推荐默认 · 同类别、同单位内缩放 · 不跨单位比较</span>
              </div>
              <div className="faf-profile-tabs" role="group" aria-label="指标剖面类别">
                {metricProfiles.map((profile) => (
                  <button type="button" key={profile.section} data-faf-profile-category={profile.section}
                    className={profile.section === effectiveProfileSection ? 'on' : ''}
                    onClick={() => setProfileSection(profile.section)}>{profile.section}</button>
                ))}
              </div>
              <div className="faf-profile-groups">
                {activeMetricProfile.groups.map((group) => (
                  <div className="faf-profile-group" key={group.unit}>
                    <h4>{group.unit}</h4>
                    <div className="faf-profile-list">
                      {group.rows.map((row) => (
                        <div className="faf-profile-row" key={row.def.key} data-metric-key={row.def.key}>
                          <span title={row.def.label}>{row.def.label}</span>
                          <div className="faf-profile-track"><i style={{ width: Math.max(2, Math.abs(row.v) / group.maxAbs * 100) + '%' }}/></div>
                          <strong>{fafFmt(row.v, row.def.unit)}</strong>
                        </div>
                      ))}
                    </div>
                  </div>
                ))}
              </div>
            </section>
          ) : null}

          {/* Trial statistics are part of the session evidence, not a separate page/module. */}
          {hasTrials ? (
            <section className="faf-analysis-section" data-faf-trial-statistics>
              <div className="faf-section-head">
                <div><span className="faf-section-index">4</span><strong>Trial 一致性 · Mean / SD / CV / ICC</strong></div>
                <span>{trials.length + ' 个有效 Trial · 使用已保存值'}</span>
              </div>
              <div className="faf-stat-summary">
                {kpiEvidence.map(({ row, stats }) => (
                  <div className="faf-stat-card" key={row.def.key}>
                    <b>{row.def.label}</b>
                    <div className="faf-stat-values">
                      <span>Mean<strong>{stats ? fafFmt(stats.mean, row.def.unit) : '—'}</strong></span>
                      <span>SD<strong>{stats ? fafFmt(stats.sampleSD, row.def.unit) : '—'}</strong></span>
                      <span>CV<strong>{stats && stats.cvPct != null ? fafFmt(stats.cvPct, '%') + '%' : '—'}</strong></span>
                    </div>
                  </div>
                ))}
              </div>
              <div className="faf-stat-actions">
                <span className="faf-note">ICC：当前为单运动员单会话，设计不足；不伪造可靠性数值。</span>
                <button type="button" className="faf-stat-toggle" aria-expanded={cmpOpen} onClick={() => setCmpOpen((value) => !value)}>{cmpOpen ? '收起完整矩阵' : '展开全部指标 × Trial'}</button>
              </div>
              {cmpOpen ? <div style={{ padding: '0 10px 10px' }}>
              <div className="faf-stat-intro">
                <div>{'trial × 指标比对 · 本次有效 trial 的描述性变异 · 同会话 ' + trials.length + ' 次 · 使用已保存值 · 原始实测值 · 不归一化 · 不排名 · 结构参照会话对比表 5b'}</div>
                <div>Mean / SD / CV 按每项有效 n 计算；SD 为样本 SD。ICC 不从单运动员单会话伪算，需多运动员重复测量并预先声明模型。</div>
              </div>
              <div className="faf-tablewrap" style={{ maxHeight: 360 }}>
                <table className="faf-tcmp">
                  <thead>
                    <tr>
                      <th>指标</th>
                      {trials.map((t, i) => (
                        <th key={i} className={i === defaultPos ? 'repcol' : ''}>{'T' + (i + 1)}{i === defaultPos ? ' 代表' : ''}</th>
                      ))}
                      <th>最优</th>
                      <th>n</th>
                      <th>Mean</th>
                      <th>SD</th>
                      <th>CV</th>
                      <th>ICC</th>
                    </tr>
                  </thead>
                  <tbody>
                    {cmpGroups.map((g) => [
                      <tr key={g.sec + '::h'}><td className="faf-grp2" colSpan={trials.length + 7}>{g.sec}</td></tr>,
                    ].concat(g.rows.map((r) => (
                      <tr key={r.def.key}>
                        <td>{r.def.label}{r.def.unit ? <span className="faf-note"> {r.def.unit}</span> : null}</td>
                        {r.cells.map((v, i) => (
                          <td key={i} className={(i === defaultPos ? 'repcol ' : '') + (i === r.bestPos ? 'best' : '')}>{v == null ? '—' : fafFmt(v, r.def.unit)}</td>
                        ))}
                        <td className="best">{r.bestPos >= 0 ? ('T' + (r.bestPos + 1)) : '—'}</td>
                        <td>{r.stats ? r.stats.n : '—'}</td>
                        <td>{r.stats ? fafFmt(r.stats.mean, r.def.unit) : '—'}</td>
                        <td>{r.stats ? fafFmt(r.stats.sampleSD, r.def.unit) : '—'}</td>
                        <td title={r.stats && r.stats.cvStatus === 'ineligible-scale' ? '该指标不是已声明的比例量，CV 不适用' : (r.stats && r.stats.cvStatus === 'nonpositive' ? '包含非正值，CV 不适用' : 'CV = 样本 SD / Mean × 100%')}>
                          {r.stats && r.stats.cvPct != null ? fafFmt(r.stats.cvPct, '%') + '%' : (r.stats && r.stats.cvStatus === 'ineligible-scale' ? '不适用' : '—')}
                        </td>
                        <td className="faf-icc-na" title="单运动员只有一个统计目标，无法识别 ICC 所需的组间方差">需队列</td>
                      </tr>
                    ))))}
                  </tbody>
                </table>
              </div>
              </div> : null}
            </section>
          ) : null}

          {anNote ? <div className="faf-ledger">{anNote}</div> : null}

          <section className="faf-analysis-section" data-faf-metric-browser>
            <div className="faf-section-head">
              <div><span className="faf-section-index">5</span><strong>完整指标浏览器</strong></div>
              <span>分类过滤 · 搜索 · 公式来源 · 不重复堆叠卡片</span>
            </div>
            <div className="faf-metric-browser">
          <div className="faf-metric-head">
            <div>
              <div className="faf-context-k">完整指标库</div>
              <div className="faf-note">{visibleValued.length + ' / ' + valued.length + ' 项 · 虚线下划线 = hover 看公式/来源'}</div>
            </div>
            <input className="faf-search" value={metricQuery} onChange={(e) => setMetricQuery(e.target.value)} placeholder="搜索指标 / key / 单位" aria-label="搜索指标" />
            <div className="faf-viewtabs" role="tablist" aria-label="指标呈现形式">
              {[
                ['matrix', '分组矩阵'], ['table', '表格'],
              ].map(([value, label]) => (
                <button key={value} type="button" data-faf-metrics-view={value} role="tab" aria-selected={metricsView === value}
                  className={metricsView === value ? 'on' : ''} onClick={() => setMetricsView(value)}>{label}</button>
              ))}
            </div>
          </div>
          <div className="faf-sections">
            {sections.map((sectionName) => (
              <button key={sectionName} type="button" className={metricSection === sectionName ? 'on' : ''}
                onClick={() => setMetricSection(sectionName)}>{sectionName}</button>
            ))}
          </div>

          {!visibleValued.length ? <div className="faf-absent">没有符合当前筛选的指标。</div>
            : metricsView === 'matrix' ? (
              <div className="faf-metric-groups" data-faf-metric-surface="matrix">
                {groups.map((g) => (
                  <section className="faf-metric-group" key={g.sec}>
                    <div className="faf-metric-group-head"><span>{g.sec}</span><span>{g.rows.length + ' 项'}</span></div>
                    <div className="faf-metric-grid">
                      {g.rows.map((r) => (
                        <div className="faf-metric-row" key={r.def.key} data-metric-key={r.def.key}>
                          <div className="faf-metric-label">{fafTipLabel(r.def)}</div>
                          <div className="faf-metric-value">{fafFmt(r.v, r.def.unit)}<small>{r.def.unit || ''}</small></div>
                        </div>
                      ))}
                    </div>
                  </section>
                ))}
              </div>
            ) : (
              <div className="faf-tablewrap" data-faf-metric-surface="table">
                <table className="faf-tbl">
                  <thead><tr><th>指标</th><th>值</th></tr></thead>
                  <tbody>
                    {groups.map((g) => [
                      <tr key={g.sec + '::h'}><td className="faf-grp" colSpan={2}>{g.sec}</td></tr>,
                    ].concat(g.rows.map((r) => (
                      <tr key={r.def.key} data-metric-key={r.def.key}>
                        <td className="faf-m">{fafTipLabel(r.def)}</td>
                        <td className="faf-v">{fafFmt(r.v, r.def.unit)}{r.def.unit ? ' ' + r.def.unit : ''}</td>
                      </tr>
                    ))))}
                  </tbody>
                </table>
              </div>
            )}
            </div>
          </section>
          <div className="faf-ledger">审阅编辑（分类 / 训练关注点 / 知识规则）在审阅台（R1）统一留痕回归；本工作台只读呈现已存事实，不重新运行检测算法，也不产出判读 / 建议类结论。</div>
          </main>
        </div>
        {deletePreviewOpen ? (
          <div role="presentation" onClick={() => sessionActionState !== 'deleting' && setDeletePreviewOpen(false)}
            style={{ position: 'fixed', inset: 0, zIndex: 240, background: 'rgba(17,24,39,.48)', display: 'grid', placeItems: 'center', padding: 20 }}>
            <div role="dialog" aria-modal="true" aria-label="删除测力台会话" onClick={event => event.stopPropagation()}
              style={{ width: 520, maxWidth: '100%', borderRadius: 12, border: '1px solid var(--border)', background: 'var(--panel)', padding: 20, boxShadow: '0 24px 70px rgba(0,0,0,.24)' }}>
              <h3 style={{ margin: '0 0 8px' }}>删除这条 {typeLabel} 会话？</h3>
              <p style={{ color: 'var(--text-2)', fontSize: 12, lineHeight: 1.65 }}>
                {fafDate(activeEntry.date)} · {session.fileName || '未记录源文件'} · {trials.length} trials。
                会一并清理此会话的原始曲线、已保存原始 CSV、审核记录与报告评语；不会影响其他会话。
              </p>
              <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 18 }}>
                <button type="button" className="faf-setdef" disabled={sessionActionState === 'deleting'} onClick={() => setDeletePreviewOpen(false)}>取消</button>
                <button type="button" className="faf-setdef" disabled={sessionActionState === 'deleting'} onClick={confirmDeleteSession}
                  style={{ color: 'var(--danger, #b42318)' }}>{sessionActionState === 'deleting' ? '正在删除…' : '确认永久删除'}</button>
              </div>
            </div>
          </div>
        ) : null}
      </div>
    );
  }

  function ForceWorkspace(props) {
    const {
      view, setView, selectedId, transitionForceContext,
      cmjStore, sjStore, imtpStore,
      captureMode, setCaptureMode,
      children,
    } = props;

    const curType = TYPE_BY_VIEW[view] || 'cmj';
    // FORCE-WS-3a: for the per-type views (cmj/sj/imtp) the active mode is the
    // collect↔analyze captureMode; the cross-type / longitudinal views keep their
    // view-derived mode (cmj-session, a legacy detail view, stays on 分析).
    const isTypeView = view === 'cmj' || view === 'sj' || view === 'imtp';
    const capMode = (captureMode === 'collect' || captureMode === 'analyze') ? captureMode : 'analyze';
    const curMode = isTypeView ? capMode : (MODE_BY_VIEW[view] || 'analyze');
    const dim = !!CROSS_TYPE_MODES[curMode];
    const curTask = EXPLORE_MODE_IDS.includes(curMode) ? 'explore' : 'session';
    const [lastType, setLastType] = React.useState(curType);
    React.useEffect(() => { if (!dim) setLastType(curType); }, [curType, dim]);
    const taskType = dim ? lastType : curType;
    const navigateForce = function (nextView, testType, nextCaptureMode) {
      if (typeof transitionForceContext !== 'function') return { ok: false, reason: 'context_blocked' };
      return transitionForceContext({
        athleteId: selectedId, testType: testType, view: nextView,
        ...(nextCaptureMode ? { captureMode: nextCaptureMode } : {}),
      });
    };

    // Type pills read the registry (no hardcoded type list). Session-count badge
    // is the current athlete's saved-session count for that type.
    const FSS = (typeof window !== 'undefined' && window.ForceSessionSource) || null;
    const types = FSS ? FSS.listTestTypes() : [];
    const storeByType = { cmj: cmjStore, sj: sjStore, imtp: imtpStore };
    // FORCE-WS-4 (2026-07-12) · pill session-count fix (WS-1a count bug). The badge must
    // reflect the SAME athlete the 分析 face reads = the GLOBAL selected athlete (app.jsx
    // selectedId, threaded to the mounted ForceViewDispatch child). The force-panel-local
    // cmj/sj/imtpAthleteId props stay null until a force-compare / review deep-link sets
    // them, so counting against them showed "0 会话" while 分析 listed the stored session.
    // app-view-renderers.jsx is protected (zero-diff) → read selectedId off the child that
    // already carries it rather than threading a new prop.
    const analysisAthleteId = (children && children.props) ? children.props.selectedId : null;
    const countFor = (id) => {
      const store = storeByType[id];
      return (store && store[analysisAthleteId] && store[analysisAthleteId].length) || 0;
    };

    return (
      <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
        <div className="fws-chrome">
          <div className="fws-chrome-head">
            <div>
              <div className="fws-kicker">FORCE LAB / 测力台</div>
              <div className="fws-task-title">{curTask === 'session' ? (curMode === 'collect' ? '新建采集' : '会话分析') : '纵向与对比'}</div>
              <div className="fws-task-copy">{curTask === 'session'
                ? (curMode === 'collect' ? '导入原始数据，确认 trial 后保存为可复用会话' : '历史会话、完整原图、阶段、不对称与指标证据')
                : '个体纵向、运动员比较与力量剖析共享一个任务面'}</div>
            </div>
            <div className="fws-head-actions">
              {curMode === 'collect' ? (
                <button type="button" className="fws-action" onClick={() => navigateForce(viewFor(taskType, 'analyze'), taskType, 'analyze')}>返回会话分析</button>
              ) : null}
              <button type="button" className="fws-action primary" onClick={() => navigateForce(viewFor(taskType, 'collect'), taskType, 'collect')}>＋ 新建采集</button>
            </div>
          </div>

          <div className="fws-tasknav" aria-label="测力台任务导航">
            {WORKSPACE_TASKS.map((task) => {
              const on = task.id === curTask;
              return (
                <button key={task.id} type="button" className={on ? 'on' : ''} aria-current={on ? 'page' : undefined}
                  onClick={() => {
                    if (task.id === 'session') {
                      navigateForce(viewFor(taskType, 'analyze'), taskType, 'analyze');
                    } else {
                      const targetMode = curTask === 'explore' ? curMode : 'long';
                      navigateForce(viewFor(taskType, targetMode), taskType);
                    }
                  }}>{task.label}</button>
              );
            })}
            <span>采集是创建会话的动作；比较与剖析是分析镜头。</span>
          </div>

          {curTask === 'explore' ? (
            <div className="fws-lenses" aria-label="纵向与对比镜头">
              {MODES.filter((mode) => EXPLORE_MODE_IDS.includes(mode.id)).map((mode) => {
                const on = mode.id === curMode;
                return <button key={mode.id} type="button" className={on ? 'on' : ''}
                  onClick={() => navigateForce(viewFor(taskType, mode.id), taskType)}>{EXPLORE_LABELS[mode.id]}</button>;
              })}
            </div>
          ) : null}

          {/* Test type is a filter inside session/longitudinal tasks, never another page. */}
          <div className={'fws-types' + (dim ? ' is-cross-type' : '')}>
            <span className="fws-types-label">测试类型</span>
            <div className="fws-type-list">
              {types.map((t) => {
                const on = !dim && t.id === taskType;
                return (
                  <button
                    key={t.id}
                    type="button"
                    onClick={() => {
                      if (dim) return;
                      if (curTask === 'session') {
                        navigateForce(viewFor(t.id, curMode), t.id, curMode === 'collect' ? 'collect' : 'analyze');
                      } else navigateForce(viewFor(t.id, curMode), t.id);
                    }}
                    disabled={dim}
                    className={on ? 'on' : ''}
                  >
                    {t.label}
                    <span>{countFor(t.id)}</span>
                  </button>
                );
              })}
            </div>
            <span className="fws-types-note">{dim ? '当前镜头跨测试类型，类型筛选暂不适用' : '类型只筛选当前任务，不创建新页面'}</span>
          </div>
        </div>
        <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
          {children}
        </div>
      </div>
    );
  }

  if (typeof window !== 'undefined') {
    window.ForceWorkspace = ForceWorkspace;
    window.ForceLongitudinalBoard = ForceLongitudinalBoard;
    window.ForceAnalysisFace = ForceAnalysisFace;
  }
})();
