// ═══════════════════════════════════════════════════════════════
// Question Manager (QM) — admin surface
// Three views (Design · Assign · Preview), driven by the sidenav.
//
// Mental model (per the spec):
//   • There is ONE project-wide question tree (the "Project Question Set").
//     Forms are the rendered reviewer UI; admins author versioned question sets, then assign
//     subsets of them to specific stages.
//   • Every question is versioned. A question can be:
//       - Draft (never published)
//       - Published (clean — what reviewers see now)
//       - Published with unpublished changes (edits queued for next publish)
//   • Stages have their own assignment state ("Stage Question Set") which is
//     also versioned. Publishing happens PER STAGE, in the Assign view.
//   • The PQS / SQS terminology never appears in user copy.
// ═══════════════════════════════════════════════════════════════


// ── Categories (one tree per category, all part of the SAME project set) ───
const QM_CATEGORIES = [
  { id: 'study',     label: 'Study',              icon: 'article',     unit: null },
  { id: 'disease',   label: 'Disease Model',      icon: 'coronavirus', unit: 'model' },
  { id: 'treatment', label: 'Treatment',          icon: 'medication',  unit: 'treatment' },
  { id: 'outcome',   label: 'Outcome Assessment', icon: 'insights',    unit: 'outcome' },
  { id: 'cohort',    label: 'Cohort',             icon: 'groups',      unit: 'cohort' },
  { id: 'experiment',label: 'Experiment',         icon: 'science',     unit: 'experiment' },
];

// ── Stages (mirrors the project's stage spine — for now hard-coded) ────────
const QM_STAGES = [
  { id: 'screen-ta', label: 'Title & Abstract Screening', dataExtraction: false },
  { id: 'screen-ft', label: 'Full Text Screening',        dataExtraction: false },
  { id: 'extract',   label: 'Data Extraction',            dataExtraction: true  },
];

// ── Question type & Control type (spec §4–§5) ─────────────────────────────
//   questionType is the DATA shape of the answer — what gets stored & analysed.
//   controlType is the WIDGET shown to the reviewer.
//   They're independent within constraints (see QM_CONTROL_FOR).
//   • boolean → must use checkbox.
//   • string  → textbox | dropdown | radio | checklist | autocomplete.
//   • integer / decimal → textbox (with numeric validation) | dropdown
//                         | radio | checklist | autocomplete (numeric options).
const QM_QUESTION_TYPES = [
  { id: 'string',  label: 'Text',     icon: 'short_text',
    desc: 'Free-text or one-of-many words/phrases.' },
  { id: 'integer', label: 'Integer',  icon: 'pin',
    desc: 'Whole numbers — counts, ages in years, etc.' },
  { id: 'decimal', label: 'Decimal',  icon: 'tag',
    desc: 'Numbers with decimals — doses, concentrations, scores.' },
  { id: 'boolean', label: 'Yes / No', icon: 'toggle_on',
    desc: 'A single yes/no checkbox answer.' },
];
const QM_CONTROL_TYPES = [
  { id: 'textbox',      label: 'Text box',     icon: 'edit_note',
    desc: 'Free-form input. Numeric question types are validated as numbers.' },
  { id: 'dropdown',     label: 'Dropdown',     icon: 'arrow_drop_down_circle',
    desc: 'Pick one value from a list.' },
  { id: 'radio',        label: 'Radio',        icon: 'radio_button_checked',
    desc: 'Pick one value, all options visible.' },
  { id: 'checkbox',     label: 'Checkbox',     icon: 'check_box',
    desc: 'A single yes/no toggle. Used for boolean questions.' },
  { id: 'checklist',    label: 'Checklist',    icon: 'checklist',
    desc: 'Pick zero or more values from a list.' },
  { id: 'autocomplete', label: 'Autocomplete', icon: 'spellcheck',
    desc: 'Type-to-search, with the option to add a new value.' },
];
const QM_QTYPE_MAP   = Object.fromEntries(QM_QUESTION_TYPES.map(t => [t.id, t]));
const QM_CTRL_MAP    = Object.fromEntries(QM_CONTROL_TYPES.map(t => [t.id, t]));

// Which controls are valid for each question type
const QM_CONTROL_FOR = {
  string:  ['textbox', 'dropdown', 'radio', 'checklist', 'autocomplete'],
  integer: ['textbox', 'dropdown', 'radio', 'checklist', 'autocomplete'],
  decimal: ['textbox', 'dropdown', 'radio', 'checklist', 'autocomplete'],
  boolean: ['checkbox'],
};
// Controls that need an option list
const QM_OPTIONED = new Set(['dropdown', 'radio', 'checklist', 'autocomplete']);

// Default control when switching question types
const QM_DEFAULT_CONTROL = {
  string: 'textbox', integer: 'textbox', decimal: 'textbox', boolean: 'checkbox',
};

// Legacy "type" → questionType + controlType (so the existing fixture keeps rendering)
const QM_LEGACY_TYPE_MAP = {
  text:         { questionType: 'string',  controlType: 'textbox'      },
  textarea:     { questionType: 'string',  controlType: 'textbox', longText: true },
  number:       { questionType: 'decimal', controlType: 'textbox'      },
  radio:        { questionType: 'string',  controlType: 'radio'        },
  checklist:    { questionType: 'string',  controlType: 'checklist'    },
  dropdown:     { questionType: 'string',  controlType: 'dropdown'     },
  autocomplete: { questionType: 'string',  controlType: 'autocomplete' },
  date:         { questionType: 'string',  controlType: 'textbox'      },
};
function qmResolveTypes(node) {
  if (node.questionType && node.controlType) return { qt: node.questionType, ct: node.controlType };
  const m = QM_LEGACY_TYPE_MAP[node.type] || QM_LEGACY_TYPE_MAP.text;
  return { qt: m.questionType, ct: m.controlType };
}
// Back-compat helpers (a few callsites still use the old single-type icon)
const QM_TYPES = QM_CONTROL_TYPES;
const QM_TYPE_MAP = new Proxy({}, {
  get(_, k) {
    // Legacy lookup by old type id
    if (QM_LEGACY_TYPE_MAP[k]) {
      const ct = QM_LEGACY_TYPE_MAP[k].controlType;
      return QM_CTRL_MAP[ct];
    }
    return QM_CTRL_MAP[k];
  }
});


// ═══════════════════════════════════════════════════════════════
// Seed data — a project that's been live for a while.
//
// We use this fixture to demonstrate the three lifecycle states, the
// "Edited from another stage" cross-publish case, and a question with
// existing annotations that's mid-edit.
// ═══════════════════════════════════════════════════════════════

// Helper for fixture authoring — short hand for version stamps
const v = (n, when, by, note) => ({ n, when, by, note });

const QM_PROJECT_TREE = {
  // ─── STUDY ─────────────────────────────────────────────────────
  study: [
    { id: 'q-title',  category: 'study', system: true,
      text: 'Study title', type: 'text', required: true,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['screen-ta', 'screen-ft', 'extract'],
      help: 'Pre-filled from imported metadata. Reviewer confirms.' },

    { id: 'q-doi', category: 'study',
      text: 'DOI or other identifier', type: 'text', required: false,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['screen-ta', 'screen-ft', 'extract'] },

    { id: 'q-design', category: 'study',
      text: 'Study design', type: 'dropdown', required: true,
      options: ['Parallel', 'Crossover', 'Factorial', 'Sequential', 'Single-arm'],
      lifecycle: 'published-changed',
      versions: [
        v(2, '2026-02-04', 'C. Sena', 'Added "Single-arm" option'),
        v(1, '2025-09-12', 'C. Sena', 'Initial publication'),
      ],
      assignedStages: ['extract'],
      annotations: { studies: 47, total: 47 },
      // Live distribution of answers — drives the "objectively still valid" check.
      // (Sums to total across studies counted once.)
      existingAnswers: [
        { value: 'Parallel',   count: 28 },
        { value: 'Crossover',  count: 9  },
        { value: 'Factorial',  count: 4  },
        { value: 'Sequential', count: 3  },
        { value: 'Single-arm', count: 3  },
      ],
      draftChanges: [
        { field: 'options', from: 'Parallel, Crossover, Factorial, Sequential, Single-arm',
          to: 'Parallel, Crossover, Factorial, Sequential, Single-arm, Cluster-randomised' },
      ],
      // Pre-existing design-time decision: classified as "does not affect" because
      // the change only adds an option — every existing answer remains valid.
      draftPublishDecision: {
        classification: 'does-not-affect',
        note: 'Adding option only — no existing answers affected',
        flagForReview: false,
        decidedAt: '2026-04-22 15:08',
      },
    },

    { id: 'q-protocol', category: 'study',
      text: 'Do the authors provide a study protocol available to you?',
      type: 'radio', required: true,
      options: ['Yes', 'No', 'Unclear'],
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'],
      children: [
        { id: 'q-protocol-link', category: 'study',
          text: 'Link to protocol', type: 'text', required: false,
          showIf: { parent: 'q-protocol', equals: 'Yes' },
          lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
          assignedStages: ['extract'] },
      ] },

    { id: 'q-funding', category: 'study',
      text: 'Funding source(s)', type: 'text', required: false, multi: true,
      lifecycle: 'draft', versions: [],
      assignedStages: [],
      help: 'One per line. Include grant numbers where given.' },
  ],

  // ─── DISEASE MODEL ─────────────────────────────────────────────
  disease: [
    { id: 'q-dm-label', category: 'disease', system: true,
      text: 'Disease model label', type: 'text', required: true,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-dm-name', category: 'disease',
      text: 'Disease / model name', type: 'autocomplete', required: true,
      options: ['MCAO (transient)', 'MCAO (permanent)', 'Photothrombotic', 'Endothelin-1'],
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-dm-species', category: 'disease',
      text: 'Species', type: 'dropdown', required: true,
      options: ['Mouse', 'Rat', 'Other'],
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'],
      annotations: { studies: 47, total: 124 } },

    { id: 'q-dm-strain', category: 'disease',
      text: 'Strain', type: 'text', required: true,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },
  ],

  // ─── TREATMENT ─────────────────────────────────────────────────
  treatment: [
    { id: 'q-tx-label', category: 'treatment', system: true,
      text: 'Treatment label', type: 'text', required: true,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-tx-control', category: 'treatment', system: true,
      text: 'Control vs non-control arm', type: 'radio', required: true,
      options: ['Control', 'Non-control'],
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-tx-route', category: 'treatment',
      text: 'Route of administration', type: 'dropdown', required: true,
      options: ['Intraperitoneal (IP)', 'Intravenous (IV)', 'Oral (PO)', 'Subcutaneous (SC)', 'Other'],
      lifecycle: 'published-changed',
      versions: [
        v(3, '2026-03-10', 'M. Macleod', 'Added "Subcutaneous (SC)" via Screening publish'),
        v(2, '2025-11-04', 'C. Sena', 'Renamed "IV" → "Intravenous (IV)"'),
        v(1, '2025-09-12', 'C. Sena', 'Initial publication'),
      ],
      assignedStages: ['extract'],
      annotations: { studies: 38, total: 81 },
      // The published version has these options; the draft (below) renamed
      // one and removed another — so "Topical (skin)" can no longer be a valid
      // answer, which forces a Map or Re-answer decision.
      previousOptions: ['Intraperitoneal (IP)', 'Intravenous (IV)', 'Oral (PO)', 'Subcutaneous (SC)', 'Topical (skin)', 'Other'],
      existingAnswers: [
        { value: 'Intraperitoneal (IP)', count: 41 },
        { value: 'Intravenous (IV)',     count: 22 },
        { value: 'Oral (PO)',            count: 11 },
        { value: 'Subcutaneous (SC)',    count: 4  },
        { value: 'Topical (skin)',       count: 2,  invalid: true },
        { value: 'Other',                count: 1  },
      ],
      draftSource: 'cross-stage',
      draftChanges: [
        { field: 'options', from: 'Intraperitoneal (IP), Intravenous (IV), Oral (PO), Subcutaneous (SC), Topical (skin), Other',
          to: 'Intraperitoneal (IP), Intravenous (IV), Oral (PO), Subcutaneous (SC), Other' },
      ],
      // No design-time decision recorded yet — admin will set one in the panel.
      draftPublishDecision: null,
    },

    { id: 'q-tx-drug', category: 'treatment',
      text: 'What is the treatment drug or intervention?',
      type: 'autocomplete', required: true, scope: 'non-control',
      options: ['Saline', 'Fluoxetine', 'Minocycline', 'Remote ischaemic conditioning (RIC)', 'Other'],
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'],
      annotations: { studies: 38, total: 64 },
      children: [
        { id: 'q-tx-dose', category: 'treatment',
          text: 'Dose (mg/kg)', type: 'number', required: true,
          showIf: { parent: 'q-tx-drug', notEquals: 'Saline' },
          lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
          assignedStages: ['extract'] },
        { id: 'q-tx-vehicle', category: 'treatment',
          text: 'Specify the vehicle used', type: 'text', required: false,
          showIf: { parent: 'q-tx-drug', equals: 'Other' },
          lifecycle: 'draft', versions: [],
          assignedStages: [],
          validationError: 'Show When references option that no longer exists' },
      ] },

    { id: 'q-tx-timing', category: 'treatment',
      text: 'Timing of administration (post-induction)',
      type: 'text', required: false,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },
  ],

  // ─── OUTCOME ─────────────────────────────────────────────────
  outcome: [
    { id: 'q-oa-label', category: 'outcome', system: true,
      text: 'Outcome label', type: 'text', required: true,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    // Outcome-specific system questions (spec §6 — Outcome Assessment behaviours).
    { id: 'q-oa-avg-type', category: 'outcome', system: true, systemKind: 'oa-average',
      text: 'Average type', questionType: 'string', controlType: 'radio', required: true,
      options: ['Mean', 'Median'],
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'],
      help: 'Drives which Error Type values are valid: Mean → SD/SEM; Median → IQR/Range.' },

    { id: 'q-oa-err-type', category: 'outcome', system: true, systemKind: 'oa-error',
      text: 'Error type', questionType: 'string', controlType: 'radio', required: true,
      options: ['SD', 'SEM', 'IQR', 'Range'],
      // The conditional that drives this is implicit in the system contract,
      // but expressed here as a per-option parent rule for fidelity.
      optionRules: {
        'SD':    { whenParent: 'q-oa-avg-type', equals: 'Mean' },
        'SEM':   { whenParent: 'q-oa-avg-type', equals: 'Mean' },
        'IQR':   { whenParent: 'q-oa-avg-type', equals: 'Median' },
        'Range': { whenParent: 'q-oa-avg-type', equals: 'Median' },
      },
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-oa-units', category: 'outcome', system: true, systemKind: 'oa-units',
      text: 'Units', questionType: 'string', controlType: 'textbox', required: true,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'],
      help: 'Free-text; meta-analysis converts compatible units automatically.' },

    { id: 'q-oa-direction', category: 'outcome', system: true, systemKind: 'oa-direction',
      text: 'Greater is worse', questionType: 'boolean', controlType: 'checkbox', required: true,
      defaultChecked: false,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'],
      help: 'Tells the meta-analyser which direction is "improvement" (e.g. mNSS: greater is worse).' },

    { id: 'q-oa-name', category: 'outcome',
      text: 'Outcome name',
      questionType: 'string', controlType: 'autocomplete', required: true,
      options: ['Infarct volume (TTC)', 'mNSS', 'Morris water maze', 'Grip strength'],
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-oa-blind', category: 'outcome',
      text: 'Was the assessor blinded to treatment?',
      questionType: 'string', controlType: 'radio', required: true, options: ['Yes', 'No', 'Unclear'],
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-oa-time', category: 'outcome',
      text: 'Time of assessment (days post-induction)',
      questionType: 'integer', controlType: 'textbox', required: true,
      validation: { min: 0, max: 365 },
      lifecycle: 'draft', versions: [],
      assignedStages: [],
      help: 'Whole days. Use 0 for "at induction".' },
  ],

  // ─── COHORT ─────────────────────────────────────────────────
  cohort: [
    { id: 'q-co-label', category: 'cohort', system: true,
      text: 'Cohort label', type: 'text', required: true,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-co-n', category: 'cohort',
      text: 'Number of animals (N)',
      questionType: 'integer', controlType: 'textbox', required: true,
      validation: { min: 1, max: 500 },
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-co-sex', category: 'cohort',
      text: 'Sex',
      questionType: 'string', controlType: 'dropdown', required: true,
      options: ['Male', 'Female', 'Mixed'],
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-co-age', category: 'cohort',
      text: 'Age at induction (weeks)',
      questionType: 'decimal', controlType: 'textbox', required: false,
      validation: { min: 0, max: 200, decimals: 1 },
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-co-comorbid', category: 'cohort',
      text: 'Animals had a comorbidity at baseline',
      questionType: 'boolean', controlType: 'checkbox', required: false,
      defaultChecked: false,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'],
      help: 'Tick if animals were e.g. aged, hypertensive, or diabetic at the time of induction.',
      children: [
        { id: 'q-co-comorbid-list', category: 'cohort',
          text: 'Which comorbidities?',
          questionType: 'string', controlType: 'checklist', required: true, multi: true,
          options: ['Aged', 'Hypertension', 'Diabetes', 'Hyperlipidaemia', 'Other'],
          showIf: { parent: 'q-co-comorbid', booleanIs: 'checked' },
          lifecycle: 'draft', versions: [],
          assignedStages: [] },
      ] },
  ],

  // ─── EXPERIMENT ─────────────────────────────────────────────
  experiment: [
    { id: 'q-ex-label', category: 'experiment', system: true,
      text: 'Experiment label', type: 'text', required: true,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },

    { id: 'q-ex-rand', category: 'experiment',
      text: 'Randomisation method', type: 'text', required: true,
      lifecycle: 'published', versions: [v(1, '2025-09-12', 'C. Sena', 'Initial publication')],
      assignedStages: ['extract'] },
  ],
};

// Stage publish history (most-recent first) — purely fixture
const QM_STAGE_HISTORY = {
  'screen-ta': [
    { update: 2, when: '2025-11-12 09:14', by: 'C. Sena', note: 'Added inclusion criterion question', changedQs: 1 },
    { update: 1, when: '2025-09-12 16:02', by: 'C. Sena', note: 'Initial publication', changedQs: 1 },
  ],
  'screen-ft': [
    { update: 2, when: '2025-11-12 09:14', by: 'C. Sena', note: 'Updated reason taxonomy', changedQs: 6 },
    { update: 1, when: '2025-09-12 16:02', by: 'C. Sena', note: 'Initial publication', changedQs: 6 },
  ],
  'extract': [
    { update: 4, when: '2026-03-10 11:42', by: 'M. Macleod', note: 'Renamed route options for clarity', changedQs: 1 },
    { update: 3, when: '2026-02-04 10:18', by: 'C. Sena', note: 'Added Single-arm to study design', changedQs: 1 },
    { update: 2, when: '2025-11-04 14:30', by: 'C. Sena', note: 'Renamed IV in route options', changedQs: 1 },
    { update: 1, when: '2025-09-12 16:02', by: 'C. Sena', note: 'Initial publication', changedQs: 38 },
  ],
};


// ═══════════════════════════════════════════════════════════════
// Tree helpers
// ═══════════════════════════════════════════════════════════════
function qmFlatten(tree) {
  const out = [];
  const walk = (ns) => ns.forEach(n => { out.push(n); if (n.children) walk(n.children); });
  Object.values(tree).forEach(walk);
  return out;
}
function qmFlattenCat(nodes) {
  const out = [];
  const walk = (ns) => ns.forEach(n => { out.push(n); if (n.children) walk(n.children); });
  walk(nodes);
  return out;
}
function qmFindNode(tree, id) {
  for (const cat of Object.keys(tree)) {
    const found = qmFindInList(tree[cat], id);
    if (found) return found;
  }
  return null;
}
function qmFindInList(nodes, id) {
  for (const n of nodes) {
    if (n.id === id) return n;
    if (n.children) {
      const c = qmFindInList(n.children, id);
      if (c) return c;
    }
  }
  return null;
}
function qmUpdateNode(nodes, id, patch) {
  return nodes.map(n => {
    if (n.id === id) {
      const next = typeof patch === 'function' ? patch(n) : { ...n, ...patch };
      return next;
    }
    if (n.children) return { ...n, children: qmUpdateNode(n.children, id, patch) };
    return n;
  });
}


// ═══════════════════════════════════════════════════════════════
// QuestionManager — root component
// ═══════════════════════════════════════════════════════════════
function QuestionManager({ initialTab = 'design' }) {
  const [tab, setTab] = React.useState(initialTab);
  const [tree, setTree] = React.useState(() => QM_PROJECT_TREE);
  const [catId, setCatId] = React.useState('treatment'); // shared across views
  const [selId, setSelId] = React.useState('q-tx-route');
  const [expanded, setExpanded] = React.useState(() => new Set(['q-tx-drug', 'q-protocol']));
  const [stageId, setStageId] = React.useState('extract'); // shared between Assign and Preview
  const [saveState, setSaveState] = React.useState('saved'); // saving | saved | offline
  const [publishWizardFor, setPublishWizardFor] = React.useState(null); // stageId or null
  const [draftHistoryOpen, setDraftHistoryOpen] = React.useState(false);

  // Sidenav drives the tab
  React.useEffect(() => {
    const onTab = (e) => { if (e.detail && e.detail.tab) setTab(e.detail.tab); };
    window.addEventListener('qm-set-tab', onTab);
    return () => window.removeEventListener('qm-set-tab', onTab);
  }, []);

  // When category changes, keep selection in-category
  React.useEffect(() => {
    const node = selId ? qmFindNode(tree, selId) : null;
    if (!node || node.category !== catId) {
      const first = (tree[catId] || []).find(n => !n.system) || (tree[catId] || [])[0];
      setSelId(first ? first.id : null);
    }
  }, [catId]);

  const selNode = selId ? qmFindNode(tree, selId) : null;

  const patchNode = (id, patch) => {
    setTree(prev => {
      const cat = qmFindNode(prev, id)?.category;
      if (!cat) return prev;
      return { ...prev, [cat]: qmUpdateNode(prev[cat], id, (n) => {
        const next = typeof patch === 'function' ? patch(n) : { ...n, ...patch };
        // Edits to a published question demote it to "published-changed"
        if (next.lifecycle === 'published') next.lifecycle = 'published-changed';
        return next;
      }) };
    });
    setSaveState('saving');
    setTimeout(() => setSaveState('saved'), 600);
  };

  return (
    <section className="qm" data-screen-label="admin Question Manager">
      <header className="qm-head">
        <div className="qm-head-row">
          <div className="qm-title-block">
            <div className="qm-crumbs">
              <span>Admin</span>
              <span className="ms">chevron_right</span>
              <span>Questions</span>
              <span className="ms">chevron_right</span>
              <span style={{color:'var(--ink-2)', fontWeight:500, textTransform:'capitalize'}}>{tab}</span>
            </div>
            <h1 className="qm-title">
              {tab === 'design'  && 'Design questions'}
              {tab === 'assign'  && 'Assign questions to stages'}
              {tab === 'preview' && 'Preview the extraction form'}
            </h1>
            <div className="qm-subtitle">
              {tab === 'design'  && 'Author the project\u2019s questions. Every change is autosaved as a draft — reviewers won\u2019t see it until you publish to a stage.'}
              {tab === 'assign'  && 'Pick which questions apply to a given stage. Publishing a stage promotes its draft assignments — and any draft question edits — into a new immutable version.'}
              {tab === 'preview' && 'Simulate the reviewer\u2019s extraction session for a given stage. No data is saved.'}
            </div>
          </div>
          <div className="qm-actions">
            <QMSaveStatus state={saveState} onOpenHistory={() => setDraftHistoryOpen(true)}/>
          </div>
        </div>
      </header>

      {tab === 'design' && (
        <QMDesign
          tree={tree}
          catId={catId} setCatId={setCatId}
          selId={selId}  setSelId={setSelId}
          expanded={expanded} setExpanded={setExpanded}
          selNode={selNode}
          patchNode={patchNode}
          setTree={setTree}
        />
      )}
      {tab === 'assign' && (
        <QMAssign
          tree={tree}
          catId={catId} setCatId={setCatId}
          stageId={stageId} setStageId={setStageId}
          patchNode={patchNode}
          setTree={setTree}
          onPublish={() => setPublishWizardFor(stageId)}
        />
      )}
      {tab === 'preview' && (
        <QMPreview
          tree={tree}
          catId={catId} setCatId={setCatId}
          stageId={stageId} setStageId={setStageId}
          onPublish={() => setPublishWizardFor(stageId)}
        />
      )}

      {publishWizardFor && (
        <QMPublishWizard
          tree={tree}
          stageId={publishWizardFor}
          onClose={() => setPublishWizardFor(null)}
          onPublish={() => {
            // mark as published in fixture
            setTree(prev => {
              const next = {};
              for (const cat of Object.keys(prev)) {
                next[cat] = qmFlattenCat(prev[cat]).reduce((acc, n) => acc, prev[cat]);
                next[cat] = (function bump(nodes) {
                  return nodes.map(n => {
                    let m = n;
                    if (m.lifecycle === 'published-changed' && (m.assignedStages || []).includes(publishWizardFor)) {
                      const last = (m.versions || [])[0];
                      m = {
                        ...m,
                        lifecycle: 'published',
                        draftChanges: undefined,
                        draftSource: undefined,
                        versions: [
                          v((last?.n || 0) + 1, '2026-04-25', 'You', 'Published from Assign'),
                          ...(m.versions || []),
                        ],
                      };
                    }
                    if (m.children) m = { ...m, children: bump(m.children) };
                    return m;
                  });
                })(prev[cat]);
              }
              return next;
            });
            setPublishWizardFor(null);
          }}
        />
      )}

      {draftHistoryOpen && <QMDraftHistory onClose={() => setDraftHistoryOpen(false)}/>}
    </section>
  );
}


// ═══════════════════════════════════════════════════════════════
// Save status (header chip)
// ═══════════════════════════════════════════════════════════════
function QMSaveStatus({ state, onOpenHistory }) {
  const map = {
    saving:  { ic: 'sync',         tx: 'Saving\u2026', cls: '' },
    saved:   { ic: 'cloud_done',   tx: 'All changes saved', cls: '' },
    offline: { ic: 'cloud_off',    tx: 'Offline — saved locally', cls: 'is-warn' },
  };
  const s = map[state] || map.saved;
  return (
    <div className="qm-save">
      <span className={"qm-save-chip " + s.cls}>
        <span className={"ms" + (state==='saving'?' qm-spin':'')}>{s.ic}</span>
        {s.tx}
      </span>
      <button className="qm-save-history" onClick={onOpenHistory}
              title="Browse autosaved snapshots — restore an earlier draft">
        <span className="ms">history</span>
        Draft history
      </button>
    </div>
  );
}


// ═══════════════════════════════════════════════════════════════
// DESIGN VIEW
// ═══════════════════════════════════════════════════════════════
function QMDesign({ tree, catId, setCatId, selId, setSelId, expanded, setExpanded, selNode, patchNode, setTree }) {
  const currentTree = tree[catId] || [];
  const currentCat = QM_CATEGORIES.find(c => c.id === catId);

  // Roll-up counts per lifecycle for the rail
  const counts = React.useMemo(() => {
    const map = {};
    QM_CATEGORIES.forEach(c => {
      const flat = qmFlattenCat(tree[c.id] || []);
      map[c.id] = {
        total: flat.length,
        draft:   flat.filter(n => n.lifecycle === 'draft').length,
        changed: flat.filter(n => n.lifecycle === 'published-changed').length,
      };
    });
    return map;
  }, [tree]);

  const toggleExpanded = (id) => setExpanded(prev => {
    const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
  });

  return (
    <>
      <QMCategoryTabs catId={catId} setCatId={setCatId} counts={counts}/>

      <div className="qm-design-frame">
        <div className="qm-frame-line">
          <span className="ms">tips_and_updates</span>
          Changes here are drafts — annotators won&rsquo;t see them until you publish to a stage.
        </div>

        <div className="qm-body qm-body-2col">
          {/* MIDDLE — the project tree (filtered to this category) */}
          <div className="qm-tree-col">
            <div className="qm-tree-bar">
              <button className="qm-tree-bar-btn primary"
                      onClick={() => {
                        const id = 'new-' + Date.now().toString(36);
                        setTree(prev => ({
                          ...prev,
                          [catId]: [...(prev[catId] || []), {
                            id, category: catId, text: 'New question',
                            questionType: 'string', controlType: 'textbox',
                            required: false, lifecycle: 'draft', versions: [], assignedStages: [],
                          }]
                        }));
                        setSelId(id);
                      }}>
                <span className="ms">add</span>
                Add question
              </button>
              <div className="qm-tree-bar-sp"/>
              <span className="qm-tree-legend">
                <LifecycleIcon lifecycle="draft"/> Draft
                <LifecycleIcon lifecycle="published-changed"/> Edited
                <LifecycleIcon lifecycle="published"/> Published
                <LifecycleIcon system/> System
              </span>
            </div>

            {currentTree.length === 0 ? (
              <div className="qm-empty">
                <span className="ms">edit_note</span>
                <h3>No questions yet</h3>
                <p>Add questions for the {currentCat.label} category to start authoring.</p>
              </div>
            ) : (
              <QMTreeList
                nodes={currentTree}
                selId={selId} setSelId={setSelId}
                expanded={expanded} toggleExpanded={toggleExpanded}
              />
            )}
          </div>

          {/* RIGHT — properties */}
          <aside className="qm-props">
            {selNode ? (
              <QMProperties node={selNode} patch={(p) => patchNode(selNode.id, p)}/>
            ) : (
              <div className="qm-props-empty">
                <span className="ms">ads_click</span>
                <h4>Nothing selected</h4>
                <p>Pick a question from the tree to edit it.</p>
              </div>
            )}
          </aside>
        </div>
      </div>
    </>
  );
}


// ═══════════════════════════════════════════════════════════════
// Category tabs (shared by all three views — DD-03)
// ═══════════════════════════════════════════════════════════════
function QMCategoryTabs({ catId, setCatId, counts }) {
  return (
    <nav className="qm-cat-tabs">
      {QM_CATEGORIES.map(c => {
        const ct = (counts && counts[c.id]) || {};
        const dirty = (ct.draft || 0) + (ct.changed || 0);
        return (
          <button key={c.id}
                  className={"qm-cat-tab" + (catId === c.id ? ' on' : '')}
                  onClick={() => setCatId(c.id)}>
            <span className="ms">{c.icon}</span>
            <span>{c.label}</span>
            {ct.total != null && <span className="qm-cat-count">{ct.total}</span>}
            {dirty > 0 && <span className="qm-cat-dot" title={`${dirty} unpublished change${dirty===1?'':'s'}`}/>}
          </button>
        );
      })}
    </nav>
  );
}


// ═══════════════════════════════════════════════════════════════
// Lifecycle icon — single source of truth
// ═══════════════════════════════════════════════════════════════
function LifecycleIcon({ lifecycle, system, size = 16 }) {
  if (system) return <span className="qm-life sys" style={{fontSize:size}} title="System question — auto-included"><span className="ms">lock</span></span>;
  if (lifecycle === 'draft') return <span className="qm-life draft" style={{fontSize:size}} title="Draft — never published"><span className="ms">edit_note</span></span>;
  if (lifecycle === 'published-changed') return <span className="qm-life changed" style={{fontSize:size}} title="Published, with unpublished edits"><span className="ms">published_with_changes</span></span>;
  return <span className="qm-life pub" style={{fontSize:size}} title="Published — live for reviewers"><span className="ms">task_alt</span></span>;
}


// ═══════════════════════════════════════════════════════════════
// Tree list (Design view — recursive, with lifecycle chips)
// ═══════════════════════════════════════════════════════════════
function QMTreeList({ nodes, selId, setSelId, expanded, toggleExpanded, depth = 0 }) {
  return (
    <div>
      {nodes.map(n => (
        <QMTreeNode key={n.id} node={n} selId={selId} setSelId={setSelId}
                    expanded={expanded} toggleExpanded={toggleExpanded} depth={depth}/>
      ))}
    </div>
  );
}

function QMTreeNode({ node, selId, setSelId, expanded, toggleExpanded, depth }) {
  const kids = node.children || [];
  const hasKids = kids.length > 0;
  const open = expanded.has(node.id);
  const { qt, ct } = qmResolveTypes(node);
  const ctrlInfo = QM_CTRL_MAP[ct] || QM_CONTROL_TYPES[0];
  const qtInfo   = QM_QTYPE_MAP[qt] || QM_QUESTION_TYPES[0];
  const sel = selId === node.id;

  return (
    <>
      <div className={"qm-q" + (sel ? ' sel' : '') + (node.system ? ' sys' : '')}>
        <div className="qm-q-row" onClick={() => setSelId(node.id)}>
          <span className={"qm-q-handle" + (node.system ? ' off' : '')} title="Drag to reorder">
            <span className="ms">drag_indicator</span>
          </span>
          <span className={"qm-q-caret" + (hasKids ? '' : ' hidden') + (open ? ' open' : '')}
                onClick={(e) => { e.stopPropagation(); toggleExpanded(node.id); }}>
            <span className="ms">chevron_right</span>
          </span>
          <span className="qm-q-type" title={`${ctrlInfo.label} · ${qtInfo.label}`}>
            <span className="ms">{ctrlInfo.icon}</span>
          </span>
          <span className="qm-q-label">{node.text}</span>
          <div className="qm-q-meta">
            <span className="qm-q-qt" title={`Stored as ${qtInfo.label.toLowerCase()}`}>{qtInfo.label}</span>
            {node.required && <span className="qm-q-req" title="Reviewers must answer">REQ</span>}
            {node.scope === 'control' && <span className="qm-q-scope" title="Only on control arms">control</span>}
            {node.scope === 'non-control' && <span className="qm-q-scope" title="Only on non-control arms">non-control</span>}
            {node.showIf && <span className="qm-q-cond" title={qmDescribeShowIf(node.showIf)}><span className="ms">call_split</span></span>}
            {node.validationError && <span className="qm-q-warn" title={node.validationError}><span className="ms">warning</span></span>}
            {node.annotations && (
              <span className="qm-q-ann" title={`${node.annotations.studies} studies, ${node.annotations.total} annotations`}>
                <span className="ms">people</span>{node.annotations.studies}
              </span>
            )}
            <LifecycleIcon lifecycle={node.lifecycle} system={node.system}/>
          </div>
          <div className="qm-q-actions" onClick={(e) => e.stopPropagation()}>
            {!node.system && <>
              <button title="Add child question"><span className="ms">subdirectory_arrow_right</span></button>
              <button title="Duplicate"><span className="ms">content_copy</span></button>
              <button title="Delete"><span className="ms">delete_outline</span></button>
            </>}
          </div>
        </div>
      </div>

      {hasKids && open && (
        <div className="qm-q-children">
          {kids.map(k => (
            <QMTreeNode key={k.id} node={k}
                        selId={selId} setSelId={setSelId}
                        expanded={expanded} toggleExpanded={toggleExpanded}
                        depth={depth+1}/>
          ))}
        </div>
      )}
    </>
  );
}

function qmDescribeShowIf(si) {
  if (!si) return '';
  if (si.equals)    return `parent answer = "${si.equals}"`;
  if (si.notEquals) return `parent answer ≠ "${si.notEquals}"`;
  if (si.in)        return `parent answer is one of: ${si.in.join(', ')}`;
  if (si.booleanIs === 'checked')   return 'parent is checked';
  if (si.booleanIs === 'unchecked') return 'parent is unchecked';
  if (si.booleanIs === 'either')    return 'parent has any value';
  return 'a condition is met';
}


// ═══════════════════════════════════════════════════════════════
// Properties panel — Design view right rail
// ═══════════════════════════════════════════════════════════════
function QMProperties({ node, patch }) {
  const { qt, ct } = qmResolveTypes(node);
  const ctrlInfo = QM_CTRL_MAP[ct] || QM_CONTROL_TYPES[0];
  const qtInfo   = QM_QTYPE_MAP[qt] || QM_QUESTION_TYPES[0];
  const hasOptions = QM_OPTIONED.has(ct);
  const cat = QM_CATEGORIES.find(c => c.id === node.category);

  return (
    <>
      <header className="qm-props-head">
        <div className="qm-props-eyebrow">
          <span className="ms">{ctrlInfo.icon}</span>
          {qtInfo.label} · {ctrlInfo.label} · {cat?.label}
          {node.system && <span className="qm-props-sys"><span className="ms">lock</span> System</span>}
        </div>
        <div className="qm-props-title">{node.text || 'Untitled question'}</div>
      </header>

      <div className="qm-props-body">
        <QMStatusSection node={node} patch={patch}/>
        <QMImpactMapping node={node} patch={patch}/>
        <QMConditionalSection node={node}/>

        {node.system ? (
          <QMSystemBody node={node} cat={cat}/>
        ) : (
          <QMEditableBody node={node} qt={qt} ct={ct} hasOptions={hasOptions} patch={patch}/>
        )}

        <QMVersionTimeline node={node}/>
      </div>
    </>
  );
}


// ── Editable properties body (for non-system questions) ─────────────
function QMEditableBody({ node, qt, ct, hasOptions, patch }) {
  // Switching question type also re-bases the control type if the current
  // one isn't valid for the new question type.
  const setQuestionType = (newQt) => {
    const allowed = QM_CONTROL_FOR[newQt];
    const nextCt = allowed.includes(ct) ? ct : QM_DEFAULT_CONTROL[newQt];
    const p = { questionType: newQt, controlType: nextCt };
    if (newQt === 'boolean') {
      // Boolean questions don't carry an option list.
      p.options = undefined;
      p.multi = false;
    }
    patch(p);
  };
  const setControlType = (newCt) => {
    if (!QM_CONTROL_FOR[qt].includes(newCt)) return;
    const p = { controlType: newCt };
    // Drop options when moving away from an option-driven control.
    if (!QM_OPTIONED.has(newCt)) p.options = undefined;
    // Seed options when moving INTO one and there are none yet.
    if (QM_OPTIONED.has(newCt) && (!node.options || node.options.length === 0)) {
      p.options = ['Option 1', 'Option 2'];
    }
    patch(p);
  };

  const validation = node.validation || {};

  return (
    <>
      {/* Question text + help */}
      <div className="qm-field">
        <label className="qm-field-label">Question text</label>
        <textarea className="qm-textarea" rows={2} value={node.text || ''}
                  onChange={(e) => patch({ text: e.target.value })}/>
      </div>
      <div className="qm-field">
        <label className="qm-field-label">Help text</label>
        <textarea className="qm-textarea" rows={2}
                  placeholder="Optional — extra guidance shown below the question"
                  value={node.help || ''}
                  onChange={(e) => patch({ help: e.target.value })}/>
      </div>

      {/* Question type — what gets stored */}
      <div className="qm-field">
        <label className="qm-field-label">
          Question type
          <span className="qm-field-hint">What gets stored. Drives meta-analysis compatibility.</span>
        </label>
        <div className="qm-type-grid qm-type-grid--qt">
          {QM_QUESTION_TYPES.map(t => (
            <button key={t.id}
                    className={"qm-type-opt" + (qt === t.id ? ' on' : '')}
                    onClick={() => setQuestionType(t.id)}
                    title={t.desc}>
              <span className="ms">{t.icon}</span>{t.label}
            </button>
          ))}
        </div>
      </div>

      {/* Control type — what reviewers see */}
      <div className="qm-field">
        <label className="qm-field-label">
          Control type
          <span className="qm-field-hint">How reviewers enter the answer.</span>
        </label>
        <div className="qm-type-grid qm-type-grid--ct">
          {QM_CONTROL_TYPES.map(t => {
            const allowed = QM_CONTROL_FOR[qt].includes(t.id);
            return (
              <button key={t.id}
                      className={"qm-type-opt" + (ct === t.id ? ' on' : '') + (allowed ? '' : ' off')}
                      onClick={() => allowed && setControlType(t.id)}
                      disabled={!allowed}
                      title={allowed ? t.desc : `Not valid for ${QM_QTYPE_MAP[qt].label.toLowerCase()} questions`}>
                <span className="ms">{t.icon}</span>{t.label}
              </button>
            );
          })}
        </div>
        {qt === 'boolean' && (
          <div className="qm-field-note">
            <span className="ms">info</span>
            Yes / No questions always use a single checkbox.
          </div>
        )}
      </div>

      {/* Numeric validation (integer / decimal + textbox/autocomplete free input) */}
      {(qt === 'integer' || qt === 'decimal') && (ct === 'textbox' || ct === 'autocomplete') && (
        <div className="qm-field">
          <label className="qm-field-label">
            Numeric validation
            <span className="qm-field-hint">Annotators see an inline error if their entry falls outside the range.</span>
          </label>
          <div className="qm-validation-row">
            <div className="qm-validation-field">
              <label>Min</label>
              <input type="number" value={validation.min ?? ''}
                     onChange={(e) => patch({ validation: { ...validation, min: e.target.value === '' ? undefined : Number(e.target.value) } })}/>
            </div>
            <div className="qm-validation-field">
              <label>Max</label>
              <input type="number" value={validation.max ?? ''}
                     onChange={(e) => patch({ validation: { ...validation, max: e.target.value === '' ? undefined : Number(e.target.value) } })}/>
            </div>
            {qt === 'decimal' && (
              <div className="qm-validation-field">
                <label>Decimals</label>
                <input type="number" min="0" max="6" value={validation.decimals ?? ''}
                       onChange={(e) => patch({ validation: { ...validation, decimals: e.target.value === '' ? undefined : Number(e.target.value) } })}/>
              </div>
            )}
          </div>
        </div>
      )}

      {/* Options editor */}
      {hasOptions && (
        <QMOptionsEditor node={node} qt={qt} patch={patch}/>
      )}

      {/* Behaviour */}
      <div className="qm-field">
        <label className="qm-field-label">Behaviour</label>

        <div className="qm-toggle-row">
          <div className="qm-toggle-info">
            <div className="qm-toggle-label">Required</div>
            <div className="qm-toggle-desc">Reviewers must answer this before submitting the form.</div>
          </div>
          <div className={"qm-switch" + (node.required ? ' on' : '')}
               onClick={() => patch({ required: !node.required })}/>
        </div>

        {/* "Allow multiple answers" — applies to single-pick controls only.
            Checklist is multi by definition; checkbox is single-by-definition. */}
        {['textbox', 'dropdown', 'radio', 'autocomplete'].includes(ct) && qt !== 'boolean' && (
          <div className="qm-toggle-row">
            <div className="qm-toggle-info">
              <div className="qm-toggle-label">Allow multiple answers</div>
              <div className="qm-toggle-desc">Reviewer can give more than one response — e.g. several funding sources, or two strain names.</div>
            </div>
            <div className={"qm-switch" + (node.multi ? ' on' : '')}
                 onClick={() => patch({ multi: !node.multi })}/>
          </div>
        )}

        {/* "Answer array" — for option-driven, lets reviewer record several
            picks with metadata, distinct from "multi" which is just N text values. */}
        {QM_OPTIONED.has(ct) && ct !== 'checklist' && (
          <div className="qm-toggle-row">
            <div className="qm-toggle-info">
              <div className="qm-toggle-label">Answer as a list</div>
              <div className="qm-toggle-desc">Reviewers can add multiple selections, each with its own follow-up answers.</div>
            </div>
            <div className={"qm-switch" + (node.answerArray ? ' on' : '')}
                 onClick={() => patch({ answerArray: !node.answerArray })}/>
          </div>
        )}

        {/* Default checkbox state — only for checkbox controls */}
        {ct === 'checkbox' && (
          <div className="qm-toggle-row">
            <div className="qm-toggle-info">
              <div className="qm-toggle-label">Default to checked</div>
              <div className="qm-toggle-desc">Pre-tick the box for new annotations. Reviewers can always change it.</div>
            </div>
            <div className={"qm-switch" + (node.defaultChecked ? ' on' : '')}
                 onClick={() => patch({ defaultChecked: !node.defaultChecked })}/>
          </div>
        )}

        {/* Group as single — child questions of an option-driven parent */}
        {(node.children && node.children.length > 0) && (
          <div className="qm-toggle-row">
            <div className="qm-toggle-info">
              <div className="qm-toggle-label">Group child questions inline</div>
              <div className="qm-toggle-desc">Render this question and its children together as a single block in the form.</div>
            </div>
            <div className={"qm-switch" + (node.groupAsSingle ? ' on' : '')}
                 onClick={() => patch({ groupAsSingle: !node.groupAsSingle })}/>
          </div>
        )}
      </div>
    </>
  );
}


// ── Options editor (with per-option Advanced > parent filter) ────────
function QMOptionsEditor({ node, qt, patch }) {
  const [advancedFor, setAdvancedFor] = React.useState(null);
  const opts = node.options || [];
  // Numeric option validity — guarded duplicate detection.
  const seen = new Map();
  opts.forEach((o, i) => { if (!seen.has(o)) seen.set(o, []); seen.get(o).push(i); });

  const updateOpt = (i, val) => {
    const next = [...opts]; next[i] = val.slice(0, 80);
    patch({ options: next });
  };
  const numericInvalid = (s) => {
    if (s == null || s === '') return false;
    if (qt === 'integer') return !/^-?\d+$/.test(s);
    if (qt === 'decimal') return !/^-?\d+(\.\d+)?$/.test(s);
    return false;
  };
  const optionRules = node.optionRules || {};

  return (
    <div className="qm-field">
      <label className="qm-field-label">
        Options
        <span className="qm-field-hint">
          {qt === 'string'
            ? 'Each option is the value stored on the answer. Max 80 characters.'
            : `Each option must be a valid ${qt} value. Max 80 characters.`}
        </span>
      </label>
      <div className="qm-opts">
        {opts.map((opt, i) => {
          const dup = (seen.get(opt) || []).length > 1;
          const numErr = numericInvalid(opt);
          const tooLong = (opt || '').length >= 80;
          const rule = optionRules[opt];
          const advOpen = advancedFor === i;
          return (
            <React.Fragment key={i}>
              <div className={"qm-opt" + ((dup || numErr) ? ' is-err' : '')}>
                <span className="qm-opt-drag" title="Drag to reorder"><span className="ms">drag_indicator</span></span>
                <input value={opt}
                       maxLength={80}
                       onChange={(e) => updateOpt(i, e.target.value)}/>
                {rule && (
                  <span className="qm-opt-rule" title={`Only shown when ${rule.whenParent} ${rule.equals ? '= "' + rule.equals + '"' : ''}`}>
                    <span className="ms">filter_alt</span>
                  </span>
                )}
                <button className={"qm-opt-adv" + (advOpen ? ' on' : '')}
                        title="Advanced — restrict when this option is shown"
                        onClick={() => setAdvancedFor(advOpen ? null : i)}>
                  <span className="ms">tune</span>
                </button>
                <button className="qm-opt-x" title="Remove option"
                        onClick={() => patch({ options: opts.filter((_, j) => j !== i) })}>
                  <span className="ms">close</span>
                </button>
              </div>
              {(dup || numErr || tooLong) && (
                <div className="qm-opt-err">
                  <span className="ms">error</span>
                  {dup && <span>Duplicate value — each option must be unique.</span>}
                  {numErr && <span>Not a valid {qt} value.</span>}
                  {tooLong && !dup && !numErr && <span>Maximum 80 characters.</span>}
                </div>
              )}
              {advOpen && (
                <div className="qm-opt-advanced">
                  <div className="qm-opt-advanced-head">
                    <span className="ms">filter_alt</span>
                    Show this option only when…
                  </div>
                  <div className="qm-opt-advanced-row">
                    <span className="qm-field-hint">
                      Restrict this option based on a previous answer. Useful for cascades — e.g. only show <i>Strain X</i> if Species = Mouse.
                    </span>
                  </div>
                  <div className="qm-opt-advanced-pickers">
                    <select value={rule?.whenParent || ''}
                            onChange={() => { /* fixture-only */ }}>
                      <option value="">No restriction</option>
                      <option value="q-dm-species">Species</option>
                      <option value="q-tx-control">Control vs non-control</option>
                    </select>
                    <span>=</span>
                    <select value={rule?.equals || ''} onChange={() => {}}>
                      <option value="">Any value</option>
                      <option value="Mouse">Mouse</option>
                      <option value="Rat">Rat</option>
                    </select>
                  </div>
                </div>
              )}
            </React.Fragment>
          );
        })}
      </div>
      <button className="qm-opts-add"
              onClick={() => patch({ options: [...opts, 'New option'] })}>
        <span className="ms">add</span>Add option
      </button>
    </div>
  );
}


// ── System-question read-only view ───────────────────────────────────
function QMSystemBody({ node, cat }) {
  const { qt, ct } = qmResolveTypes(node);
  const qtInfo   = QM_QTYPE_MAP[qt] || QM_QUESTION_TYPES[0];
  const ctrlInfo = QM_CTRL_MAP[ct] || QM_CONTROL_TYPES[0];

  // Subtle, system-kind-specific copy
  const note = (() => {
    switch (node.systemKind) {
      case 'oa-average':   return 'Drives which Error Type values are valid: Mean → SD/SEM; Median → IQR/Range. Required by meta-analysis.';
      case 'oa-error':     return 'Options are filtered by Average Type. Editing this would break meta-analysis input expectations.';
      case 'oa-units':     return 'Free-text. The analyser converts compatible units automatically when pooling outcomes across studies.';
      case 'oa-direction': return 'Tells the analyser which direction is "improvement". Used when computing standardised mean differences.';
      default: return null;
    }
  })();

  return (
    <>
      <div className="qm-sys-note">
        <span className="ms">lock</span>
        <div>
          <b>System question.</b> The {cat?.label} {cat?.unit ? cat.unit : 'category'} structure depends on this question, so its configuration is read-only. It&rsquo;s automatically assigned to any stage that has data extraction enabled.
          {note && <div className="qm-sys-note-extra">{note}</div>}
        </div>
      </div>

      <div className="qm-sys-spec">
        <div className="qm-sys-spec-row">
          <div className="qm-sys-spec-key">Question type</div>
          <div className="qm-sys-spec-val">
            <span className="ms" style={{fontSize: 16}}>{qtInfo.icon}</span> {qtInfo.label}
          </div>
        </div>
        <div className="qm-sys-spec-row">
          <div className="qm-sys-spec-key">Control type</div>
          <div className="qm-sys-spec-val">
            <span className="ms" style={{fontSize: 16}}>{ctrlInfo.icon}</span> {ctrlInfo.label}
          </div>
        </div>
        <div className="qm-sys-spec-row">
          <div className="qm-sys-spec-key">Required</div>
          <div className="qm-sys-spec-val">{node.required ? 'Yes' : 'No'}</div>
        </div>
        {node.options && (
          <div className="qm-sys-spec-row">
            <div className="qm-sys-spec-key">Options</div>
            <div className="qm-sys-spec-val">
              <ul className="qm-sys-opt-list">
                {node.options.map((o, i) => {
                  const rule = node.optionRules?.[o];
                  return (
                    <li key={i}>
                      <span>{o}</span>
                      {rule && (
                        <span className="qm-sys-opt-rule">
                          <span className="ms">filter_alt</span>
                          shown when parent = "{rule.equals}"
                        </span>
                      )}
                    </li>
                  );
                })}
              </ul>
            </div>
          </div>
        )}
        {node.help && (
          <div className="qm-sys-spec-row">
            <div className="qm-sys-spec-key">Help text</div>
            <div className="qm-sys-spec-val qm-sys-spec-help">{node.help}</div>
          </div>
        )}
        {ct === 'checkbox' && (
          <div className="qm-sys-spec-row">
            <div className="qm-sys-spec-key">Default</div>
            <div className="qm-sys-spec-val">{node.defaultChecked ? 'Checked' : 'Unchecked'}</div>
          </div>
        )}
      </div>
    </>
  );
}


// ── Status strip (Draft / Published / Changed) ────────────────
// Slim one-line strip at the top of the panel: icon + title + meta.
// For the "changed" state the diff/impact/discard detail lives in a
// collapsible disclosure so it doesn't dominate the panel.
function QMStatusSection({ node, patch }) {
  const [open, setOpen] = React.useState(false);

  if (node.lifecycle === 'draft') {
    return (
      <div className="qm-status-strip is-draft">
        <LifecycleIcon lifecycle="draft" size={14}/>
        <span className="qm-status-strip-title">Draft</span>
        <span className="qm-status-strip-meta">Never published — goes live on next publish</span>
      </div>
    );
  }

  if (node.lifecycle === 'published-changed') {
    const last = (node.versions || [])[0];
    const changeCount = (node.draftChanges || []).length;
    return (
      <div className="qm-status-block">
        <button
          className="qm-status-strip is-changed is-button"
          onClick={() => setOpen(o => !o)}
          aria-expanded={open}
        >
          <LifecycleIcon lifecycle="published-changed" size={14}/>
          <span className="qm-status-strip-title">Unpublished edits</span>
          <span className="qm-status-strip-meta">
            {changeCount > 0 && <>{changeCount} {changeCount === 1 ? 'change' : 'changes'} · </>}
            live v{last?.n || 1}
            {node.draftSource === 'cross-stage' && <> · from another stage</>}
          </span>
          <span className={"ms qm-status-strip-chev" + (open ? ' is-open' : '')}>expand_more</span>
        </button>
        {open && (
          <div className="qm-status-detail">
            {node.draftChanges && node.draftChanges.length > 0 && (
              <div className="qm-diff">
                {node.draftChanges.map((d, i) => <QMDiffRow key={i} d={d}/>)}
              </div>
            )}
            {node.annotations && (
              <div className="qm-status-detail-note">
                <span className="ms">people</span>
                <div>
                  <b>{node.annotations.studies} studies · {node.annotations.total} annotations</b> reference this question.
                  Decide how existing answers should be handled in the <b>Impact &amp; Mapping</b> section below — the publish wizard will pre-populate from your decision.
                </div>
              </div>
            )}
            <button
              className="qm-status-discard"
              onClick={() => patch({ lifecycle: 'published', draftChanges: undefined, draftSource: undefined })}
            >
              <span className="ms">undo</span>
              Discard draft changes
            </button>
          </div>
        )}
      </div>
    );
  }

  // Published clean — quietest possible: single line, no chrome
  const last = (node.versions || [])[0];
  return (
    <div className="qm-status-strip is-pub">
      <LifecycleIcon lifecycle="published" size={14}/>
      <span className="qm-status-strip-title">Published</span>
      <span className="qm-status-strip-meta">
        v{last?.n || 1} · {last?.when}
        {node.annotations && <> · {node.annotations.studies} studies</>}
      </span>
    </div>
  );
}


// ── Conditional display section ────────────────────────────────
function QMConditionalSection({ node }) {
  if (!node.showIf) {
    return null;
  }
  const si = node.showIf;
  return (
    <div className="qm-cond-card">
      <div className="qm-cond-head">
        <span className="ms">call_split</span>
        Conditional display
      </div>
      <div className="qm-cond-body">
        <div className="qm-cond-line">
          Shown to annotators only when {qmDescribeShowIf(si)}.
        </div>
        <div className="qm-cond-detail">
          <span className="qm-cond-detail-key">Parent question</span>
          <span className="qm-cond-detail-val"><code>{si.parent}</code></span>
        </div>
        {si.booleanIs && (
          <div className="qm-cond-detail">
            <span className="qm-cond-detail-key">Trigger</span>
            <span className="qm-cond-detail-val">
              {si.booleanIs === 'checked'   && 'Parent checkbox is ticked'}
              {si.booleanIs === 'unchecked' && 'Parent checkbox is empty'}
              {si.booleanIs === 'either'    && 'Parent has any value'}
            </span>
          </div>
        )}
        {(si.equals || si.notEquals || si.in) && (
          <div className="qm-cond-detail">
            <span className="qm-cond-detail-key">Match</span>
            <span className="qm-cond-detail-val">
              {si.equals    && <>= <b>"{si.equals}"</b></>}
              {si.notEquals && <>≠ <b>"{si.notEquals}"</b></>}
              {si.in        && <>one of: {si.in.map((x, i) => <b key={i}>"{x}"{i < si.in.length - 1 ? ', ' : ''}</b>)}</>}
            </span>
          </div>
        )}
      </div>
    </div>
  );
}


// ── Impact & Mapping section ──────────────────────────────────
// Spec: 03-ui-specification/impact-and-mapping-decision-capture.md
//
// Auto-expands when the selected question is a published AnnotationQuestion
// with at least one existing annotation. Captures a mutable
// `DraftPublishDecision` that the publish wizard pre-populates from.
//
// What lives HERE (design-time):
//   • Classification: does-not-affect / may-affect
//   • Handling: keep / map / re-answer  (only when may-affect)
//   • Per-option mappings (only when handling = map)
//   • Flag-for-review checkbox
//   • Free-text change note
//
// What's deferred to the wizard:
//   • Stage-specific overrides
//   • Session scope (in-progress vs. completed)
//   • Decided-by / decided-at audit fields
//
// Constraint: "Keep answers as they are" is hidden when any existing answer
// is no longer objectively valid (e.g. an option that was removed).
function QMImpactMapping({ node, patch }) {
  // Where in the editing surface we expose this:
  //   • Direct edit of a published AQ with annotations  → AQ.draft.draftPublishDecision
  //   • Replacement-with-new-version draft              → DraftQuestion.draftPublishDecision
  // Same component, different storage location — fixture treats both the same.
  const isReplacement = !!node.replacesAnnotationQuestionId;
  const hasAnnotations = !!node.annotations && node.annotations.total > 0;
  const isPublishedWithEdits = node.lifecycle === 'published-changed';
  const showPanel = (isPublishedWithEdits && hasAnnotations) || (isReplacement && hasAnnotations);
  if (!showPanel) return null;

  const decision = node.draftPublishDecision || {};
  const classification = decision.classification || null; // null | 'does-not-affect' | 'may-affect'
  const handling = decision.handling || null;             // null | 'keep' | 'map' | 're-answer'
  const flagForReview = !!decision.flagForReview;
  const note = decision.note || '';
  const mappings = decision.mappings || [];

  // Compute objective validity of "keep" against the live answer distribution.
  const existing = node.existingAnswers || [];
  const invalidExisting = existing.filter(a => a.invalid);
  const keepIsValid = invalidExisting.length === 0;

  const setDecision = (next) => {
    patch({ draftPublishDecision: { ...decision, ...next } });
  };

  const setClassification = (c) => {
    if (c === 'does-not-affect') {
      // Drop handling/mappings — they're may-affect-only.
      setDecision({ classification: c, handling: undefined, mappings: undefined });
    } else {
      // Default handling to "keep" when valid, else "map"
      const h = decision.handling || (keepIsValid ? 'keep' : 'map');
      setDecision({ classification: c, handling: h });
    }
  };

  const setHandling = (h) => {
    if (h === 'map') {
      // Seed mappings from existing answers if empty.
      const seed = (existing.length ? existing : (node.options || []).map(o => ({ value: o, count: 0 })))
        .map(a => {
          const exists = (node.options || []).includes(a.value);
          return { fromValue: a.value, toValue: exists ? a.value : '', requiresReview: !exists, count: a.count || 0 };
        });
      setDecision({ handling: h, mappings: mappings.length ? mappings : seed });
    } else {
      setDecision({ handling: h });
    }
  };

  const updateMapping = (i, patchObj) => {
    const next = mappings.map((m, idx) => idx === i ? { ...m, ...patchObj } : m);
    setDecision({ mappings: next });
  };

  // Decided / undecided summary for the strip
  const decided = !!classification && (classification === 'does-not-affect' || !!handling);

  return (
    <section className="qm-im" data-decided={decided ? 'yes' : 'no'}>
      <header className="qm-im-head">
        <div className="qm-im-head-l">
          <span className="qm-im-icon"><span className="ms">data_thresholding</span></span>
          <div>
            <div className="qm-im-title">Impact &amp; Mapping</div>
            <div className="qm-im-sub">
              <b>{node.annotations.total} annotations across {node.annotations.studies} {node.annotations.studies === 1 ? 'study' : 'studies'}</b> reference this question.
              {isReplacement && <> · Replaces <code>{node.replacesAnnotationQuestionId}</code></>}
            </div>
          </div>
        </div>
        <span className={"qm-im-state " + (decided ? 'is-set' : 'is-unset')}>
          <span className="ms">{decided ? 'task_alt' : 'priority_high'}</span>
          {decided ? 'Decision recorded' : 'Decision needed'}
        </span>
      </header>

      {!keepIsValid && (
        <div className="qm-im-warn">
          <span className="ms">warning</span>
          <div>
            <b>{invalidExisting.reduce((s, a) => s + (a.count || 0), 0)} existing {invalidExisting.length === 1 ? 'answer' : 'answers'} no longer match an option in the new version.</b>
            {' '}You'll need to <i>map</i> them or ask annotators to <i>re-answer</i> — keeping them as they are isn't possible.
          </div>
        </div>
      )}

      {/* CLASSIFICATION ─────────────────────────────────── */}
      <div className="qm-im-block">
        <div className="qm-im-block-head">
          <span className="qm-im-step-n">1</span>
          <div>
            <div className="qm-im-block-title">Does this change affect existing answers?</div>
            <div className="qm-im-block-sub">Your call here is what the publish wizard pre-populates from.</div>
          </div>
        </div>
        <div className="qm-im-radio-row qm-im-radio-row--2">
          <label className={"qm-im-radio" + (classification === 'does-not-affect' ? ' on' : '')}>
            <input type="radio" name={`im-cls-${node.id}`}
                   checked={classification === 'does-not-affect'}
                   onChange={() => setClassification('does-not-affect')}/>
            <div>
              <div className="qm-im-radio-title">
                <span className="ms">check_circle</span>
                Does not affect existing answers
              </div>
              <div className="qm-im-radio-sub">
                Wording tweaks, help-text changes, added options, fixes that don't change interpretation.
              </div>
            </div>
          </label>
          <label className={"qm-im-radio" + (classification === 'may-affect' ? ' on' : '')}>
            <input type="radio" name={`im-cls-${node.id}`}
                   checked={classification === 'may-affect'}
                   onChange={() => setClassification('may-affect')}/>
            <div>
              <div className="qm-im-radio-title">
                <span className="ms">change_history</span>
                May affect existing answers
              </div>
              <div className="qm-im-radio-sub">
                Removed/renamed options, narrowed scope, type changes, anything that could invalidate prior data.
              </div>
            </div>
          </label>
        </div>
      </div>

      {/* HANDLING (only if may-affect) ─────────────────── */}
      {classification === 'may-affect' && (
        <div className="qm-im-block">
          <div className="qm-im-block-head">
            <span className="qm-im-step-n">2</span>
            <div>
              <div className="qm-im-block-title">How should existing answers be handled?</div>
              <div className="qm-im-block-sub">Choose one. The publish wizard adds in-progress vs. completed-session scoping on top.</div>
            </div>
          </div>
          <div className="qm-im-radio-col">
            {keepIsValid && (
              <label className={"qm-im-radio" + (handling === 'keep' ? ' on' : '')}>
                <input type="radio" name={`im-hnd-${node.id}`}
                       checked={handling === 'keep'}
                       onChange={() => setHandling('keep')}/>
                <div>
                  <div className="qm-im-radio-title">Keep answers as they are</div>
                  <div className="qm-im-radio-sub">Best for: minor copy edits, added options that don't change existing interpretations.</div>
                </div>
              </label>
            )}
            <label className={"qm-im-radio" + (handling === 'map' ? ' on' : '')}>
              <input type="radio" name={`im-hnd-${node.id}`}
                     checked={handling === 'map'}
                     onChange={() => setHandling('map')}/>
              <div>
                <div className="qm-im-radio-title">Map answers to updated options</div>
                <div className="qm-im-radio-sub">Best for: option renames, merges, or splitting one option into several.</div>
              </div>
            </label>
            <label className={"qm-im-radio" + (handling === 're-answer' ? ' on' : '')}>
              <input type="radio" name={`im-hnd-${node.id}`}
                     checked={handling === 're-answer'}
                     onChange={() => setHandling('re-answer')}/>
              <div>
                <div className="qm-im-radio-title">Ask annotators to re-answer</div>
                <div className="qm-im-radio-sub">Best for: significant meaning changes that need a fresh judgement.</div>
              </div>
            </label>
          </div>
        </div>
      )}

      {/* MAPPING TABLE (only if handling = map) ─────────── */}
      {classification === 'may-affect' && handling === 'map' && (
        <div className="qm-im-block">
          <div className="qm-im-block-head">
            <span className="qm-im-step-n">3</span>
            <div>
              <div className="qm-im-block-title">Map each existing answer to a new option</div>
              <div className="qm-im-block-sub">
                <b>{mappings.reduce((s, m) => s + (m.count || 0), 0)} annotations</b> will be re-pointed when you publish.
                Tick "needs review" to flag any annotation whose mapping isn't a clean rename.
              </div>
            </div>
          </div>
          <div className="qm-im-map">
            <div className="qm-im-map-head">
              <span>From (existing answer)</span>
              <span></span>
              <span>To (new option)</span>
              <span>Review?</span>
            </div>
            {mappings.map((m, i) => {
              const newOpts = node.options || [];
              const valid = newOpts.includes(m.toValue);
              return (
                <div key={i} className={"qm-im-map-row" + (m.requiresReview ? ' is-review' : '') + (!valid ? ' is-empty' : '')}>
                  <div className="qm-im-from">
                    <span className="qm-im-from-val">{m.fromValue}</span>
                    {m.count > 0 && <span className="qm-im-from-count">{m.count}</span>}
                  </div>
                  <span className="ms qm-im-arrow">arrow_forward</span>
                  <div className="qm-im-to">
                    <select value={m.toValue || ''} onChange={(e) => updateMapping(i, { toValue: e.target.value, requiresReview: m.requiresReview || (e.target.value !== m.fromValue) })}>
                      <option value="">— pick an option —</option>
                      {newOpts.map(o => <option key={o} value={o}>{o}</option>)}
                      <option value="__re-answer__">Ask annotator to re-answer</option>
                    </select>
                  </div>
                  <label className="qm-im-review">
                    <input type="checkbox" checked={!!m.requiresReview}
                           onChange={(e) => updateMapping(i, { requiresReview: e.target.checked })}/>
                  </label>
                </div>
              );
            })}
          </div>
          <div className="qm-im-map-foot">
            <span className="ms">tips_and_updates</span>
            Counts come from live annotations. Anything mapped to <b>"Ask annotator to re-answer"</b> appears in the wizard's session scoping step.
          </div>
        </div>
      )}

      {/* META — flag + note ───────────────────────────── */}
      <div className="qm-im-meta">
        <label className="qm-im-flag">
          <input type="checkbox" checked={flagForReview} onChange={(e) => setDecision({ flagForReview: e.target.checked })}/>
          <div>
            <div className="qm-im-flag-title">Flag for review</div>
            <div className="qm-im-flag-sub">Annotators who have already answered may need to reconsider — this surfaces a "review" badge on those annotations.</div>
          </div>
        </label>
        <div className="qm-im-note">
          <label className="qm-field-label">Change note <span className="qm-field-hint">(optional — appears in the publish wizard and the immutable PublishDecision audit trail)</span></label>
          <textarea className="qm-textarea" rows={2}
                    placeholder="e.g. Removed Topical (skin) — never used in stroke models; renamed for consistency with WHO ATC."
                    value={note}
                    onChange={(e) => setDecision({ note: e.target.value })}/>
        </div>
      </div>

      <div className="qm-im-foot">
        <span className="ms">cloud_done</span>
        <span>Autosaved as a draft decision · the publish wizard will load this verbatim and ask only for stage-specific scoping.</span>
        {decided && (
          <button className="qm-im-clear" onClick={() => patch({ draftPublishDecision: null })}>
            Clear decision
          </button>
        )}
      </div>
    </section>
  );
}


// ── Diff row — option-list aware ───────────────────────────────
// For option-list changes, render the before/after as two stacked
// labeled option lists. Set-diff math (added/removed chips) gets
// confused by renames, which are common — easier to read full lists.
function QMDiffRow({ d }) {
  const isList = d.field === 'options' && typeof d.from === 'string' && typeof d.to === 'string';
  if (isList) {
    const split = (s) => s.split(',').map(x => x.trim()).filter(Boolean);
    const from = split(d.from), to = split(d.to);
    return (
      <div className="qm-diff-list">
        <div className="qm-diff-list-row">
          <span className="qm-diff-list-label">Was</span>
          <div className="qm-diff-list-chips">
            {from.map((x, i) => <span key={i} className="qm-diff-chip removed">{x}</span>)}
          </div>
        </div>
        <div className="qm-diff-list-row">
          <span className="qm-diff-list-label">Now</span>
          <div className="qm-diff-list-chips">
            {to.map((x, i) => <span key={i} className="qm-diff-chip added">{x}</span>)}
          </div>
        </div>
      </div>
    );
  }
  return (
    <div className="qm-diff-row">
      <div className="qm-diff-field">{d.field}</div>
      <div className="qm-diff-from">{d.from}</div>
      <div className="qm-diff-arrow"><span className="ms">arrow_forward</span></div>
      <div className="qm-diff-to">{d.to}</div>
    </div>
  );
}


// ── Version timeline (bottom of properties) ────────────────────
function QMVersionTimeline({ node }) {
  if (!node.versions || node.versions.length === 0) return null;
  return (
    <div className="qm-versions">
      <div className="qm-versions-head">
        <span className="ms">history</span>
        Version history
      </div>
      <ol className="qm-versions-list">
        {node.versions.map((ver, i) => (
          <li key={ver.n} className={"qm-ver" + (i === 0 ? ' current' : '')}>
            <span className="qm-ver-num">v{ver.n}</span>
            <div className="qm-ver-body">
              <div className="qm-ver-note">{ver.note}</div>
              <div className="qm-ver-meta">{ver.when} · {ver.by}</div>
            </div>
            {i !== 0 && <button className="qm-ver-restore" title="Restore this version's content as the current draft">Apply as draft</button>}
          </li>
        ))}
      </ol>
    </div>
  );
}


// ═══════════════════════════════════════════════════════════════
// ASSIGN VIEW
// ═══════════════════════════════════════════════════════════════
function QMAssign({ tree, catId, setCatId, stageId, setStageId, patchNode, setTree, onPublish }) {
  const stage = QM_STAGES.find(s => s.id === stageId);
  const [filter, setFilter] = React.useState('all'); // all | on | off

  // local-only "draft assignment" overlay so that toggling a checkbox shows a +/− indicator
  const [draftAssign, setDraftAssign] = React.useState(() => {
    const m = {};
    qmFlatten(tree).forEach(n => { m[n.id] = (n.assignedStages || []).includes(stageId); });
    return m;
  });
  React.useEffect(() => {
    const m = {};
    qmFlatten(tree).forEach(n => { m[n.id] = (n.assignedStages || []).includes(stageId); });
    setDraftAssign(m);
  }, [stageId, tree]);

  const flat = qmFlatten(tree);
  const publishedHere = (n) => (n.assignedStages || []).includes(stageId);
  const draftHere     = (n) => !!draftAssign[n.id];
  const isAdded   = (n) => !publishedHere(n) && draftHere(n);
  const isRemoved = (n) =>  publishedHere(n) && !draftHere(n);

  // Counts
  const inCategory = qmFlattenCat(tree[catId] || []);
  const assignedCount = inCategory.filter(draftHere).length;
  const totalCount = inCategory.length;

  // Pending changes across the WHOLE stage (all categories)
  const pendingAssignChanges = flat.filter(n => isAdded(n) || isRemoved(n)).length;
  const pendingContentChanges = flat.filter(n => n.lifecycle === 'published-changed' && draftHere(n)).length;
  const pendingTotal = pendingAssignChanges + pendingContentChanges;

  // Counts per category for the tabs
  const counts = React.useMemo(() => {
    const map = {};
    QM_CATEGORIES.forEach(c => {
      const f = qmFlattenCat(tree[c.id] || []);
      map[c.id] = { total: f.length };
    });
    return map;
  }, [tree]);

  const toggle = (n) => {
    if (n.system && stage.dataExtraction) return;
    const next = !draftAssign[n.id];
    setDraftAssign(prev => {
      const m = { ...prev, [n.id]: next };
      // Auto-include ancestors when checking
      if (next) {
        let cur = qmFindAncestor(tree, n.id);
        while (cur) { m[cur.id] = true; cur = qmFindAncestor(tree, cur.id); }
      } else {
        // Auto-exclude descendants when unchecking
        const sub = (cur) => { (cur.children || []).forEach(c => { m[c.id] = false; sub(c); }); };
        sub(n);
      }
      return m;
    });
  };

  const filteredNodes = (tree[catId] || []).filter(filterFn(filter, publishedHere));

  return (
    <>
      <QMCategoryTabs catId={catId} setCatId={setCatId} counts={counts}/>

      <div className="qm-assign-frame">
        <div className="qm-stage-head">
          <div className="qm-stage-pick">
            <label>Stage</label>
            <select value={stageId} onChange={(e) => setStageId(e.target.value)}>
              {QM_STAGES.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
            </select>
          </div>
          <div className="qm-stage-filter">
            <label>Show</label>
            <div className="qm-seg">
              {[['all','All'],['on','On stage'],['off','Not on stage']].map(([k,l]) => (
                <button key={k} className={filter===k?'on':''} onClick={() => setFilter(k)}>{l}</button>
              ))}
            </div>
          </div>
          <div className="qm-stage-publish-when">
            Last published: <b>2026-03-10 11:42</b> · Update 4
          </div>
        </div>

        {stage.dataExtraction && (
          <div className="qm-assign-info">
            <span className="ms">info</span>
            <div>
              <b>Data extraction enabled.</b> System questions (label and control questions for each category) are auto-assigned and can&rsquo;t be removed — they define the unit structure that quantitative analysis depends on. Custom questions can be freely assigned.
            </div>
          </div>
        )}

        {/* Cross-stage publish notice — quiet inline */}
        <div className="qm-cross-notice">
          <span className="ms">sync</span>
          <span>
            <b>1 question</b> was updated when <b>Full Text Screening</b> was published on 2026-03-10. Review it below — assignment-side amber dot indicates content changes that ship on next publish.
          </span>
        </div>

        <QMAssignTree
          nodes={filteredNodes}
          stageId={stageId} stage={stage}
          publishedHere={publishedHere} draftHere={draftHere}
          isAdded={isAdded} isRemoved={isRemoved}
          toggle={toggle}
        />

        <div className="qm-assign-bar">
          <div className="qm-assign-bar-stats">
            <span><b>{assignedCount}</b> of {totalCount} assigned in {QM_CATEGORIES.find(c=>c.id===catId)?.label}</span>
            <span className="qm-bar-sep">·</span>
            {pendingTotal > 0 ? (
              <span className="qm-bar-pending">
                <span className="ms">edit_note</span>
                <b>{pendingTotal}</b> pending change{pendingTotal===1?'':'s'} for this stage
              </span>
            ) : (
              <span className="qm-bar-clean"><span className="ms">check_circle</span>No pending changes</span>
            )}
          </div>
          <div className="qm-assign-bar-actions">
            {pendingTotal > 0 && (
              <>
                <button className="qm-btn qm-btn-outline">Discard</button>
                <button className="qm-btn qm-btn-primary" onClick={onPublish}>
                  <span className="ms">publish</span>
                  Review &amp; Publish {pendingTotal} change{pendingTotal===1?'':'s'}
                </button>
              </>
            )}
            {pendingTotal === 0 && (
              <button className="qm-btn qm-btn-outline" disabled>
                <span className="ms">publish</span>
                Nothing to publish
              </button>
            )}
          </div>
        </div>

        <QMStageHistory stageId={stageId}/>
      </div>
    </>
  );
}

function filterFn(filter, publishedHere) {
  if (filter === 'on')  return (n) => deepAny(n, publishedHere);
  if (filter === 'off') return (n) => deepAny(n, (x) => !publishedHere(x));
  return () => true;
}
function deepAny(n, pred) {
  if (pred(n)) return true;
  return (n.children || []).some(c => deepAny(c, pred));
}
function qmFindAncestor(tree, id) {
  for (const cat of Object.keys(tree)) {
    const found = walkAnc(tree[cat], id, null);
    if (found) return found;
  }
  return null;
}
function walkAnc(nodes, id, parent) {
  for (const n of nodes) {
    if (n.id === id) return parent;
    if (n.children) {
      const a = walkAnc(n.children, id, n);
      if (a) return a;
    }
  }
  return null;
}


// ── Assign tree (single, with checkboxes + two visual dimensions) ─
function QMAssignTree({ nodes, stage, publishedHere, draftHere, isAdded, isRemoved, toggle }) {
  return (
    <div className="qm-assign-tree">
      {nodes.map(n => (
        <QMAssignRow key={n.id} node={n} depth={0}
                     stage={stage}
                     publishedHere={publishedHere} draftHere={draftHere}
                     isAdded={isAdded} isRemoved={isRemoved}
                     toggle={toggle}/>
      ))}
    </div>
  );
}

function QMAssignRow({ node, depth, stage, publishedHere, draftHere, isAdded, isRemoved, toggle }) {
  const typeInfo = QM_TYPE_MAP[node.type] || QM_TYPES[0];
  const sysLocked = node.system && stage.dataExtraction;
  const checked = sysLocked ? true : draftHere(node);
  const added = isAdded(node);
  const removed = isRemoved(node);
  // Are some descendants checked but not all? indeterminate
  const subFlat = qmFlattenCat([node]).slice(1);
  const subChecked = subFlat.filter(s => draftHere(s)).length;
  const indet = !checked && subChecked > 0;

  const hasContentChange = node.lifecycle === 'published-changed';

  let rowCls = 'qm-arow';
  if (added)   rowCls += ' is-added';
  if (removed) rowCls += ' is-removed';
  if (!checked && !removed) rowCls += ' is-off';

  return (
    <>
      <div className={rowCls} style={{paddingLeft: 12 + depth * 22}}>
        <div className={"qm-arow-prefix"}>
          {added   && <span className="qm-prefix added">+</span>}
          {removed && <span className="qm-prefix removed">−</span>}
        </div>
        <span className={"qm-acheck" + (sysLocked ? ' locked' : '') + (indet ? ' indet' : '') + (checked ? ' on' : '')}
              onClick={() => toggle(node)}>
          {sysLocked ? <span className="ms">remove</span>
            : checked ? <span className="ms">check</span>
            : indet   ? <span className="ms">remove</span>
            : null}
        </span>
        <span className="qm-arow-type" title={typeInfo.label}>
          <span className="ms">{typeInfo.icon}</span>
        </span>
        <span className="qm-arow-label">{node.text}</span>
        <div className="qm-arow-meta">
          {hasContentChange && <span className="qm-changed-dot" title="Has unpublished content changes — will ship on next publish"><span className="ms">edit_note</span></span>}
          {node.validationError && <span className="qm-warn" title={node.validationError}><span className="ms">warning</span></span>}
          {node.system && <span className="qm-lock" title="System question — auto-assigned"><span className="ms">lock</span></span>}
          {node.annotations && (
            <span className="qm-arow-ann" title={`${node.annotations.studies} studies, ${node.annotations.total} annotations`}>
              {node.annotations.studies} studies
            </span>
          )}
        </div>
      </div>
      {(node.children || []).map(c => (
        <QMAssignRow key={c.id} node={c} depth={depth+1}
                     stage={stage}
                     publishedHere={publishedHere} draftHere={draftHere}
                     isAdded={isAdded} isRemoved={isRemoved}
                     toggle={toggle}/>
      ))}
    </>
  );
}


// ── Stage publish history strip ────────────────────────────────
function QMStageHistory({ stageId }) {
  const items = QM_STAGE_HISTORY[stageId] || [];
  return (
    <div className="qm-stage-history">
      <div className="qm-stage-history-head">
        <span className="ms">history</span>
        Publish history for this stage
      </div>
      <ol className="qm-stage-history-list">
        {items.map(h => (
          <li key={h.update}>
            <span className="qm-update-num">Update {h.update}</span>
            <div className="qm-update-body">
              <div className="qm-update-note">{h.note}</div>
              <div className="qm-update-meta">{h.when} · {h.by} · {h.changedQs} question{h.changedQs===1?'':'s'} changed</div>
            </div>
          </li>
        ))}
      </ol>
    </div>
  );
}


// ═══════════════════════════════════════════════════════════════
// PUBLISH WIZARD (modal)
// ═══════════════════════════════════════════════════════════════
function QMPublishWizard({ tree, stageId, onClose, onPublish }) {
  const stage = QM_STAGES.find(s => s.id === stageId);
  const [step, setStep] = React.useState(1);
  const [reason, setReason] = React.useState('');

  const flat = qmFlatten(tree);
  const editsToPublish = flat.filter(n => n.lifecycle === 'published-changed' && (n.assignedStages || []).includes(stageId));
  const ownEdits   = editsToPublish.filter(n => n.draftSource !== 'cross-stage');
  const fromOther  = editsToPublish.filter(n => n.draftSource === 'cross-stage');
  const annotated  = editsToPublish.filter(n => n.annotations);

  return (
    <div className="qm-modal-bg" onClick={onClose}>
      <div className="qm-modal" onClick={(e) => e.stopPropagation()}>
        <header className="qm-modal-head">
          <div>
            <div className="qm-props-eyebrow"><span className="ms">publish</span> Publish to stage</div>
            <h2 className="qm-modal-title">{stage.label}</h2>
          </div>
          <button className="qm-modal-x" onClick={onClose}><span className="ms">close</span></button>
        </header>

        <ol className="qm-wiz-steps">
          {['Review changes', 'Confirm impact', 'Publish'].map((lbl, i) => (
            <li key={lbl} className={"qm-wiz-step" + (step===i+1 ? ' on' : '') + (step>i+1 ? ' done' : '')}>
              <span className="qm-wiz-step-n">{step>i+1 ? <span className="ms">check</span> : i+1}</span>
              <span>{lbl}</span>
            </li>
          ))}
        </ol>

        <div className="qm-modal-body">
          {step === 1 && (
            <>
              <p className="qm-modal-lede">Here&rsquo;s what will change for <b>{stage.label}</b> when you publish.</p>

              {ownEdits.length > 0 && (
                <div className="qm-wiz-group">
                  <div className="qm-wiz-group-head">
                    <span className="ms" style={{color:'var(--accent)'}}>edit</span>
                    Content changes (your edits) · {ownEdits.length}
                  </div>
                  {ownEdits.map(n => (
                    <div key={n.id} className="qm-wiz-q">
                      <div className="qm-wiz-q-name">{n.text}</div>
                      {(n.draftChanges || []).map((d, j) => (
                        <div key={j} className="qm-diff inline">
                          <QMDiffRow d={d}/>
                        </div>
                      ))}
                    </div>
                  ))}
                </div>
              )}

              {fromOther.length > 0 && (
                <div className="qm-wiz-group">
                  <div className="qm-wiz-group-head">
                    <span className="ms" style={{color:'var(--brand-ink)'}}>sync</span>
                    Updated from other stages · {fromOther.length}
                  </div>
                  {fromOther.map(n => (
                    <div key={n.id} className="qm-wiz-q">
                      <div className="qm-wiz-q-name">{n.text}</div>
                      <div className="qm-wiz-q-sub">Newer version exists from another stage&rsquo;s publish — adopt it on this stage</div>
                    </div>
                  ))}
                </div>
              )}

              {editsToPublish.length === 0 && (
                <div className="qm-empty" style={{padding:'24px 12px'}}>
                  <span className="ms">check_circle</span>
                  <h3>No content changes</h3>
                  <p>Only assignment changes (additions or removals) will be published.</p>
                </div>
              )}
            </>
          )}

          {step === 2 && (
            <>
              <p className="qm-modal-lede">For each changed question that has annotations, confirm how existing answers should be handled. Pre-populated from the decisions you made while editing.</p>
              {annotated.length === 0 && (
                <div className="qm-empty" style={{padding:'24px 12px'}}>
                  <span className="ms">check_circle</span>
                  <h3>Nothing to confirm</h3>
                  <p>None of the changed questions have existing annotations.</p>
                </div>
              )}
              {annotated.map(n => {
                const d = n.draftPublishDecision || {};
                const cls = d.classification;
                const handling = d.handling;
                const hasDesignTime = !!cls;
                return (
                  <div key={n.id} className={"qm-wiz-decide" + (hasDesignTime ? ' is-prefilled' : ' is-fresh')}>
                    <div className="qm-wiz-decide-head">
                      <div>
                        <div className="qm-wiz-q-name">{n.text}</div>
                        <div className="qm-wiz-q-sub">{n.annotations.studies} studies · {n.annotations.total} annotations reference this question</div>
                      </div>
                      {hasDesignTime ? (
                        <span className="qm-wiz-prefill" title="Pre-populated from your Design-time decision. Click Override to change.">
                          <span className="ms">auto_awesome</span>
                          Pre-populated from Design-time
                        </span>
                      ) : (
                        <span className="qm-wiz-prefill is-fresh">
                          <span className="ms">priority_high</span>
                          No design-time decision
                        </span>
                      )}
                    </div>

                    {hasDesignTime ? (
                      <div className="qm-wiz-decision-card">
                        <div className="qm-wiz-decision-row">
                          <span className="qm-wiz-decision-key">Classification</span>
                          <span className="qm-wiz-decision-val">
                            <span className="ms">{cls === 'does-not-affect' ? 'check_circle' : 'change_history'}</span>
                            {cls === 'does-not-affect' ? 'Does not affect existing answers' : 'May affect existing answers'}
                          </span>
                        </div>
                        {cls === 'may-affect' && (
                          <div className="qm-wiz-decision-row">
                            <span className="qm-wiz-decision-key">Handling</span>
                            <span className="qm-wiz-decision-val">
                              {handling === 'keep' && 'Keep answers as they are'}
                              {handling === 'map' && (<><b>Map answers</b> · {(d.mappings || []).length} mappings configured</>)}
                              {handling === 're-answer' && 'Ask annotators to re-answer'}
                              {!handling && '\u2014'}
                            </span>
                          </div>
                        )}
                        {d.flagForReview && (
                          <div className="qm-wiz-decision-row">
                            <span className="qm-wiz-decision-key">Flag</span>
                            <span className="qm-wiz-decision-val"><span className="ms">flag</span>Annotations flagged for reviewer reconsideration</span>
                          </div>
                        )}
                        {d.note && (
                          <div className="qm-wiz-decision-row">
                            <span className="qm-wiz-decision-key">Note</span>
                            <span className="qm-wiz-decision-val qm-wiz-decision-note">"{d.note}"</span>
                          </div>
                        )}
                        <div className="qm-wiz-decision-actions">
                          <button className="qm-btn qm-btn-ghost qm-wiz-confirm">
                            <span className="ms">check</span>
                            Confirm
                          </button>
                          <button className="qm-btn qm-btn-outline">
                            <span className="ms">edit</span>
                            Override for this stage
                          </button>
                        </div>
                      </div>
                    ) : (
                      <>
                        <div className="qm-wiz-fresh-hint">
                          <span className="ms">tips_and_updates</span>
                          You didn't record a decision while editing. Make one now, or jump back to the Design view's <b>Impact &amp; Mapping</b> panel where the context is richer.
                        </div>
                        <div className="qm-radio-row">
                          <label className="qm-radio">
                            <input type="radio" name={`wiz-${n.id}`}/>
                            <div>
                              <div className="qm-radio-title">Keep answers as they are</div>
                              <div className="qm-radio-sub">Best for wording improvements, adding help text, or changes that don&rsquo;t affect interpretation.</div>
                            </div>
                          </label>
                          <label className="qm-radio">
                            <input type="radio" name={`wiz-${n.id}`}/>
                            <div>
                              <div className="qm-radio-title">Map answers to updated options</div>
                              <div className="qm-radio-sub">Best for option renames or merges. <a>Configure mapping</a></div>
                            </div>
                          </label>
                          <label className="qm-radio">
                            <input type="radio" name={`wiz-${n.id}`}/>
                            <div>
                              <div className="qm-radio-title">Ask annotators to re-answer</div>
                              <div className="qm-radio-sub">Best for significant meaning changes that need fresh judgements.</div>
                            </div>
                          </label>
                        </div>
                      </>
                    )}
                  </div>
                );
              })}
            </>
          )}

          {step === 3 && (
            <>
              <p className="qm-modal-lede">All set. Add an optional change reason — it will be attached to the new stage version (visible in publish history).</p>
              <div className="qm-field">
                <label className="qm-field-label">Change reason (optional)</label>
                <textarea className="qm-textarea" rows={3}
                          placeholder="e.g. Added cluster-randomised study design per protocol amendment v3"
                          value={reason} onChange={(e) => setReason(e.target.value)}/>
              </div>
              <div className="qm-wiz-result">
                <div className="qm-wiz-result-line">
                  <span className="ms" style={{color:'var(--ok)'}}>check_circle</span>
                  <b>{editsToPublish.length}</b> question{editsToPublish.length===1?'':'s'} will get a new version
                </div>
                <div className="qm-wiz-result-line">
                  <span className="ms" style={{color:'var(--ok)'}}>check_circle</span>
                  Stage <b>{stage.label}</b> will move to update <b>5</b>
                </div>
                <div className="qm-wiz-result-line">
                  <span className="ms" style={{color:'var(--ok)'}}>check_circle</span>
                  Reviewers currently mid-session keep their previous version until they submit
                </div>
              </div>
            </>
          )}
        </div>

        <footer className="qm-modal-foot">
          {step > 1 && <button className="qm-btn qm-btn-outline" onClick={() => setStep(step-1)}>Back</button>}
          <div style={{flex:1}}/>
          <button className="qm-btn qm-btn-ghost" onClick={onClose}>Cancel</button>
          {step < 3 ? (
            <button className="qm-btn qm-btn-primary" onClick={() => setStep(step+1)}>Next</button>
          ) : (
            <button className="qm-btn qm-btn-primary" onClick={onPublish}>
              <span className="ms">publish</span>
              Publish stage
            </button>
          )}
        </footer>
      </div>
    </div>
  );
}


// ═══════════════════════════════════════════════════════════════
// DRAFT HISTORY (modal — autosave snapshots)
// ═══════════════════════════════════════════════════════════════
function QMDraftHistory({ onClose }) {
  const items = [
    { when: 'Today 14:32', changed: 3, sample: ['Treatment route options', 'Study design options', 'Funding source (added)'] },
    { when: 'Today 11:15', changed: 1, sample: ['Treatment route options'] },
    { when: 'Yesterday 16:45', changed: 5, sample: ['Funding source', 'Study design', 'Disease model name', '+ 2 more'] },
    { when: 'Mar 20 (weekly)', changed: 12, sample: [] },
  ];
  return (
    <div className="qm-modal-bg" onClick={onClose}>
      <div className="qm-modal" onClick={(e) => e.stopPropagation()} style={{maxWidth: 620}}>
        <header className="qm-modal-head">
          <div>
            <div className="qm-props-eyebrow"><span className="ms">history</span> Draft history</div>
            <h2 className="qm-modal-title">Restore an earlier draft</h2>
          </div>
          <button className="qm-modal-x" onClick={onClose}><span className="ms">close</span></button>
        </header>
        <div className="qm-modal-body">
          <p className="qm-modal-lede">Autosaved snapshots of your draft state. Restoring brings back the exact wording, options, and ordering at that point — your current draft is snapshotted first so the restore itself is undoable.</p>
          <ol className="qm-snap-list">
            {items.map((it, i) => (
              <li key={i} className="qm-snap">
                <div className="qm-snap-when">{it.when}</div>
                <div className="qm-snap-body">
                  <div className="qm-snap-summary">{it.changed} question{it.changed===1?'':'s'} changed</div>
                  {it.sample.length > 0 && (
                    <div className="qm-snap-sample">{it.sample.join(' · ')}</div>
                  )}
                </div>
                <button className="qm-btn qm-btn-outline">Restore</button>
              </li>
            ))}
          </ol>
        </div>
      </div>
    </div>
  );
}


// ═══════════════════════════════════════════════════════════════
// PREVIEW VIEW
// ═══════════════════════════════════════════════════════════════
function QMPreview({ tree, catId, setCatId, stageId, setStageId, onPublish }) {
  // Preview only makes sense for stages that actually use the annotation form
  // (data-extraction stages). Screening stages have an entirely separate UI.
  const extractionStages = QM_STAGES.filter(s => s.dataExtraction);
  const effectiveStageId = extractionStages.some(s => s.id === stageId)
    ? stageId
    : (extractionStages[0]?.id || stageId);
  React.useEffect(() => {
    if (effectiveStageId !== stageId) setStageId(effectiveStageId);
  }, [effectiveStageId, stageId, setStageId]);

  const [mode, setMode] = React.useState('with-changes'); // published | with-changes
  const stage = QM_STAGES.find(s => s.id === effectiveStageId);

  // Force the embedded AnnotationForm into "annotation only" mode and pre-seed
  // the category from QM's current catId so the admin lands on the same slice
  // they were just editing. Set synchronously in render — useEffect runs too
  // late for the form's first render.
  const prevFlagsRef = React.useRef();
  if (prevFlagsRef.current === undefined) {
    prevFlagsRef.current = {
      mode: window.__syrfStageMode,
      cat:  window.__syrfInitialCat,
    };
  }
  window.__syrfStageMode  = 'annotation';
  window.__syrfInitialCat = catId;
  React.useEffect(() => {
    // Push category changes into the live form when QM's catId changes.
    window.__syrfInitialCat = catId;
    window.dispatchEvent(new CustomEvent('syrf-set-category', { detail: { cat: catId } }));
  }, [catId]);
  React.useEffect(() => {
    return () => {
      window.__syrfStageMode  = prevFlagsRef.current.mode;
      window.__syrfInitialCat = prevFlagsRef.current.cat;
    };
  }, []);

  const flat = qmFlatten(tree).filter(n => (n.assignedStages || []).includes(effectiveStageId));
  const edited = flat.filter(n => n.lifecycle === 'published-changed').length;

  return (
    <div className="qm-preview-frame">
      {/* Toolbar — what stage / which version / publish action.
          NO category rail here: the embedded AnnotationForm has its own
          category tabs (the very ones reviewers see), and showing a second
          identical rail above it is just confusing duplication. */}
      <div className="qm-preview-bar">
        <div className="qm-preview-bar-left">
          <span className="qm-preview-eyebrow">
            <span className="ms">visibility</span>
            Reviewer&rsquo;s view
          </span>
          <div className="qm-stage-pick">
            <label>Stage</label>
            <select value={effectiveStageId} onChange={(e) => setStageId(e.target.value)}>
              {extractionStages.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
            </select>
            <span className="qm-stage-pick-note" title="Screening stages aren't shown — they don't use the annotation form.">
              <span className="ms">info</span>
              Extraction stages only
            </span>
          </div>
        </div>
        <div className="qm-preview-bar-right">
          <div className="qm-prev-mode">
            <label>Showing</label>
            <div className="qm-seg">
              <button className={mode==='published'?'on':''} onClick={() => setMode('published')}>Published (live)</button>
              <button className={mode==='with-changes'?'on':''} onClick={() => setMode('with-changes')}>With pending changes</button>
            </div>
          </div>
          {mode === 'with-changes' && edited > 0 && (
            <button className="qm-btn qm-btn-primary qm-prev-publish" onClick={onPublish}>
              <span className="ms">publish</span>
              Publish stage
            </button>
          )}
        </div>
      </div>

      <div className={"qm-preview-banner " + (mode==='published' ? 'is-pub' : 'is-changed')}>
        <span className="ms">{mode==='published' ? 'visibility' : 'edit_note'}</span>
        <div>
          {mode === 'published' ? (
            <><b>This is what reviewers see right now</b> when extracting data for {stage.label}. Nothing entered below is saved.</>
          ) : edited > 0 ? (
            <><b>This is what reviewers will see after you publish.</b> {edited} question{edited===1?'':'s'} {edited===1?'has':'have'} unpublished edits — review them in the form below, then publish when ready. Nothing entered here is saved.</>
          ) : (
            <><b>No pending changes for {stage.label}.</b> The reviewer&rsquo;s view matches the published version. Nothing entered below is saved.</>
          )}
        </div>
      </div>

      <div className="qm-preview-stage">
        {typeof AnnotationForm === 'function'
          ? <AnnotationForm/>
          : <div style={{padding:40, color:'var(--ink-3)'}}>Extraction form unavailable.</div>}
      </div>
    </div>
  );
}


// ── Export ──
Object.assign(window, { QuestionManager });
