// RobotRecipes.jsx — Admin Portal page for the Next Robot cooking programs
// (v17.00; grid rework v17.01; step types v20.00; SEQUENCE REDESIGN v22.00).
//
// A robot program reads like a script, not a spreadsheet:
//
//   1. Auto    Oil 15 g
//   2. Wait    5 s
//   3. Manual  Dry Chili 1 x
//   4. Wait    10 s
//   5. Auto    Basic Sauce 20 g · Sugar 12 g · Water 60 g
//   6. Wait    2 s
//   7. Auto    Power 0 kW
//      Complete
//
// so the page draws exactly that down the left: a numbered rail of
// entries, each one the robot dispensing (auto) or the crew adding
// (manual) one or more ingredients, or a wait (v23.02: its own entry,
// added and moved like any other — it used to hang under every step). The
// portions (1x … 6x) run across as columns — v22.01 put them
// back shoulder to shoulder after a one-portion-at-a-time v22.00 — because
// the portions of a dish run the same sequence and only the numbers move,
// and seeing 1x next to 4x is how a wrong number gets noticed. The grid
// scrolls sideways; the step column stays put. Scaling is NOT linear (2x
// is often 1.8x the sauce and 1.2x the wait), so while Compare is on every
// number off the baseline carries a small multiple against it.
//
// Setup (heating mode / °C / position / speed) is a fixed set of four
// machine settings that CAN differ per portion, so it is four rows with one
// value per portion column — not a timer + amount pair like a step.
//
// Reordering: drag a step by its handle, or use the always-visible ↑ ↓;
// + inserts a new step right after the current one.
//
// Scale with Claude (v23.00): one click sends the baseline program to the
// robot-recipe-scale edge function, which has Claude propose every other
// portion with cooking judgement (oil coats the wok and scales well under
// linear; seasoning tracks the servings; a manual add gets a little more
// time in a bigger batch; machine settings mostly stay). The proposal
// comes back with a rule + reason per ingredient and per wait entry, is
// snapped to one-decimal multiples of the baseline (rrSnapToBase), is
// shown in RrAiModal for review (Fable 5.1 / Opus 5 / Sonnet 5, chosen
// there), and is written only on Apply — through the same
// history as every other edit, so ⌘Z takes the whole pass back. "Fill from
// 1x" stays as the mechanical multiplier for when the judgement isn't wanted.
//
// Data model — three tables:
//   robot_recipes      — one row per dish. `portions` (jsonb array) is the
//                        set of portion keys that dish actually runs.
//   robot_recipe_setup — one row per (recipe, portion): heating_mode,
//                        temp_c, position_deg, speed. Real columns.
//   robot_recipe_steps — one row per ENTRY, shared across all portions.
//                        `step_kind` = 'auto' | 'manual' | 'wait'.
//                        `items` (jsonb) = [{ id, name, unit, amounts:
//                        { "1x": "15", "2x": "28" } }] — what the step
//                        dispenses / adds (an ingredient, or a machine
//                        action like Power 0 kW). Amounts are kept as the
//                        typed strings, per portion; a missing portion key
//                        = that portion skips the ingredient.
//                        `wait` (jsonb) = { "1x": 5, "2x": 6 } — seconds a
//                        WAIT entry holds, per portion (empty on auto /
//                        manual rows since v23.02 split the waits out).
//                        `note` — optional free text ("stir until fragrant").
//                        The pre-v22 label / unit / cells columns were
//                        backfilled into items / wait by the v22.00
//                        migration and are dead; dropping them is on the
//                        §10 list.
//
// All top-level idents are Rr/rr/RR_-prefixed per the global-scope
// convention (HANDBOOK §9 "Top-level const is GLOBAL").

// ------------------------------------------------------------------
// Mappers
// ------------------------------------------------------------------
// NOTE: never send restaurant_id — useSupaList injects it on inserts.
const rrRecipeFromRow = (r) => ({
  id: r.id,
  name: r.name || '',
  category: r.category || '',
  notes: r.notes || '',
  // Owner-written context for Scale with Claude, this dish only (v23.08).
  // `notes` stays the human note shown under the title.
  aiContext: r.ai_context || '',
  portions: Array.isArray(r.portions) ? r.portions : [],
  archived: !!r.archived,
  updatedAt: r.updated_at || null,
});
const rrRecipeToRow = (o) => ({
  id: o.id,
  name: o.name || '',
  category: o.category || '',
  notes: o.notes || '',
  ai_context: o.aiContext || '',
  portions: o.portions || [],
  archived: !!o.archived,
  updated_at: new Date().toISOString(),
});

const rrItemFromJson = (it) => ({
  id: (it && it.id) || crypto.randomUUID(),
  name: (it && it.name) || '',
  unit: (it && it.unit != null) ? String(it.unit) : '',
  amounts: (it && it.amounts && typeof it.amounts === 'object' && !Array.isArray(it.amounts)) ? it.amounts : {},
});
const rrStepFromRow = (r) => ({
  id: r.id,
  recipeId: r.recipe_id,
  kind: r.step_kind === 'manual' ? 'manual' : (r.step_kind === 'wait' ? 'wait' : 'auto'),
  items: Array.isArray(r.items) ? r.items.map(rrItemFromJson) : [],
  wait: (r.wait && typeof r.wait === 'object' && !Array.isArray(r.wait)) ? r.wait : {},
  note: r.note || '',
  // Linear only (v23.11): the owner fixed this entry to scale exactly with
  // the portion multiple. Other portions are derived from the baseline and
  // locked in the grid; Fill and Scale with Claude honour it.
  linear: r.linear === true,
  sortOrder: Number(r.sort_order) || 0,
});
const rrStepToRow = (o) => ({
  id: o.id,
  recipe_id: o.recipeId,
  step_kind: o.kind === 'manual' ? 'manual' : (o.kind === 'wait' ? 'wait' : 'auto'),
  items: (o.items || []).map(it => ({ id: it.id, name: it.name || '', unit: it.unit || '', amounts: it.amounts || {} })),
  wait: o.wait || {},
  note: o.note || '',
  linear: !!o.linear,
  sort_order: Number(o.sortOrder) || 0,
  updated_at: new Date().toISOString(),
});

// Numbers are held as STRINGS client-side so a half-typed field ("1", "")
// round-trips without becoming 0 or NaN; the toRow maps blank → null.
const rrSetupFromRow = (r) => ({
  id: r.id,
  recipeId: r.recipe_id,
  portion: r.portion,
  heatingMode: r.heating_mode || '',
  tempC: r.temp_c == null ? '' : String(r.temp_c),
  positionDeg: r.position_deg == null ? '' : String(r.position_deg),
  speed: r.speed == null ? '' : String(r.speed),
});
const rrSetupNum = (s) => (s === '' || s == null || isNaN(Number(s))) ? null : Number(s);
const rrSetupToRow = (o) => ({
  id: o.id,
  recipe_id: o.recipeId,
  portion: o.portion,
  heating_mode: o.heatingMode || null,
  temp_c: rrSetupNum(o.tempC),
  position_deg: rrSetupNum(o.positionDeg),
  speed: rrSetupNum(o.speed),
  updated_at: new Date().toISOString(),
});

// ------------------------------------------------------------------
// Constants
// ------------------------------------------------------------------
// `mult` is the NOMINAL multiplier — what a linear scale-up would use. It
// seeds the Fill-from-baseline defaults; it is never assumed to be true.
const RR_PORTIONS = [
  { key: '1x', label: '1x', mult: 1, sub: '' },
  { key: '2x', label: '2x', mult: 2, sub: '' },
  { key: '3x', label: '3x', mult: 3, sub: '' },
  { key: '4x', label: '4x', mult: 4, sub: '' },
  { key: '5x', label: '5x', mult: 5, sub: '' },
  { key: '6x', label: '6x', mult: 6, sub: 'tray' },
];
const RR_ALL_PORTION_KEYS = RR_PORTIONS.map(p => p.key);

// The built-in units. Anything else typed into a step's unit box joins the
// picker for every other step (the column is free text, so no migration).
const RR_UNITS = ['g', 'x', 'pc', 'kW', '°C'];
const RR_UNIT_MAX = 4;
// Only these scale with portion size. A bigger batch needs more sauce; it
// does NOT need a hotter wok, more power, or a faster paddle, so Fill from
// baseline copies °C / kW / custom units and unitless items across verbatim.
const RR_SCALABLE_UNITS = ['g', 'x', 'pc'];
const rrUnitScales = (unit) => RR_SCALABLE_UNITS.indexOf(unit) >= 0;

const RR_HEAT_MODES = ['Standard', 'Aggressive', 'Chill'];

// The setup block is a fixed four-field form — the machine settings every
// program on the robot opens with.
const RR_SETUP_FIELDS = [
  { key: 'heatingMode', label: 'Heating mode', type: 'enum', options: RR_HEAT_MODES },
  { key: 'tempC', label: 'Initial temperature', type: 'num', suffix: '°C' },
  { key: 'positionDeg', label: 'Initial position', type: 'num', suffix: '°' },
  { key: 'speed', label: 'Initial speed', type: 'num', suffix: '' },
];

// The two step types. Auto = the robot dispenses it; Manual = the crew
// adds it by hand. Either way the program holds for the wait afterwards.
const RR_KINDS = [
  { value: 'auto', label: 'Auto', hint: 'The robot dispenses these itself — oil, sauces, water, or a machine change like Power 0 kW.' },
  { value: 'manual', label: 'Manual', hint: 'The crew adds these by hand — dry chili, the protein, the veggies.' },
];
const rrIsManual = (step) => !!step && step.kind === 'manual';
// A wait is its own entry on the rail (v23.02): no ingredients, seconds per
// portion in `wait`, numbered and moved like any step.
const rrIsWait = (step) => !!step && step.kind === 'wait';
const RR_CMP = [
  { value: 'on', label: 'On' },
  { value: 'off', label: 'Off' },
];

// Password managers (1Password first) bolt their fill button onto any bare
// <input>, and a grid of sixty number boxes grew sixty little blue icons.
// These are the documented opt-outs — 1Password, LastPass, Bitwarden,
// Dashlane — spread onto every input on the page (v23.01). The name box in
// the step popup takes the attribute-only set: autocomplete="off" can hide
// its <datalist> suggestions in some browsers.
const RR_NO_PM_ATTRS = { 'data-1p-ignore': '', 'data-lpignore': 'true', 'data-bwignore': '', 'data-form-type': 'other' };
const RR_NO_PM = Object.assign({ autoComplete: 'off' }, RR_NO_PM_ATTRS);

// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
// seconds → "1:12". Null/blank stays blank so an empty field reads empty.
const rrFmtTime = (sec) => {
  if (sec == null || isNaN(sec)) return '';
  const s = Math.max(0, Math.round(sec));
  return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
};

// "1:12" / "72" / "00:42" → seconds. Bare numbers are read as seconds.
const rrParseTime = (str) => {
  const s = String(str == null ? '' : str).trim();
  if (!s) return null;
  if (s.indexOf(':') >= 0) {
    const parts = s.split(':');
    const m = parseFloat(parts[0]) || 0;
    const sec = parseFloat(parts[1]) || 0;
    return Math.max(0, Math.round(m * 60 + sec));
  }
  const n = parseFloat(s.replace(/[^0-9.]/g, ''));
  return isNaN(n) ? null : Math.max(0, Math.round(n));
};

// Leading number out of a free-text value, so "200c" and "1x" still take
// part in the comparison math.
const rrNum = (v) => {
  if (v == null || v === '') return null;
  const m = String(v).match(/-?\d+(\.\d+)?/);
  return m ? parseFloat(m[0]) : null;
};

const rrFmtNum = (n) => {
  if (n == null || isNaN(n)) return '';
  return String(Math.round(n * 100) / 100);
};

const rrToF = (c) => {
  const n = rrNum(c);
  return n == null ? '' : Math.round(n * 9 / 5 + 32) + '°F';
};

// The comparison chip: how many times the baseline this value is, written
// the same way the portions are (1.8x, 2.0x, 0.9x) — directly comparable
// to the portion you're looking at. ALWAYS one decimal (v23.06 / v23.07):
// a cook scales in tenths, 1.53x said nothing 1.5x doesn't, and 2.0x lines
// up under 1.8x where 2x wouldn't. Null when there is no baseline to
// compare against: a blank chip beats a misleading zero.
const rrRatioOf = (cur, base) => Math.round((cur / base) * 10) / 10;
const rrRatioFmt = (r) => r.toFixed(1) + 'x';
const rrRatioText = (cur, base) => {
  if (base == null || !isFinite(base) || base === 0) return null;
  if (cur == null || !isFinite(cur)) return null;
  return rrRatioFmt(rrRatioOf(cur, base));
};

// Rounding for a value produced from a multiple (the editable compare chip,
// v23.05): grams / kW / °C / anything else to a whole unit with a half
// rounding UP (15 g × 1.5 = 22.5 → 23 g), counts in x / pc to the nearest
// 0.5, seconds whole.
const rrRoundFor = (unit, n) => {
  if (!isFinite(n)) return n;
  if (unit === 'x' || unit === 'pc') return Math.round(n * 2) / 2;
  return Math.round(n);
};

// Scale-with-Claude rule (v23.06): a proposed number is a multiple of the
// baseline with ONE decimal — 1.6x, never 1.53x — and the amount is derived
// from that multiple, then rounded for its unit. Applied to whatever the
// model returns, so the rule holds even when it drifts. No baseline → just
// the unit rounding.
const rrSnapToBase = (proposed, base, unit) => {
  if (!isFinite(proposed)) return proposed;
  if (base == null || !isFinite(base) || base === 0) return rrRoundFor(unit, proposed);
  return rrRoundFor(unit, base * (Math.round((proposed / base) * 10) / 10));
};

// Linear-only entries (v23.11). The owner fixed the step to scale exactly
// with the portion multiple — 2x is double the 1x amount, no judgement —
// so every other portion is DERIVED from the baseline: g / x / pc multiply
// (rounded for the unit), anything else copies across. The grid locks
// those cells; Fill from 1x and Scale with Claude route through the same
// helper so all three agree.
const rrPortionMult = (key) => { const p = RR_PORTIONS.find(x => x.key === key); return p ? p.mult : null; };
const rrLinearFactor = (pKey, basePortion) => (rrPortionMult(pKey) || 1) / (rrPortionMult(basePortion) || 1);
const rrLinearAmounts = (item, portions, basePortion) => {
  const baseText = rrAmountOf(item, basePortion);
  const base = rrNum(baseText);
  const amounts = Object.assign({}, item.amounts || {});
  portions.forEach(p => {
    if (p.key === basePortion) return;
    if (baseText === '') { delete amounts[p.key]; return; }
    amounts[p.key] = (rrUnitScales(item.unit) && base != null)
      ? rrFmtNum(rrRoundFor(item.unit, base * rrLinearFactor(p.key, basePortion)))
      : baseText;
  });
  return Object.assign({}, item, { amounts });
};
const rrApplyLinear = (step, portions, basePortion) =>
  (!step || rrIsWait(step) || !step.linear) ? step
    : Object.assign({}, step, { items: (step.items || []).map(it => rrLinearAmounts(it, portions, basePortion)) });

// Small formatters for the context file card (v23.10).
const rrFmtBytes = (n) => n < 1024 ? n + ' B' : (n < 1024 * 1024 ? (Math.round(n / 102.4) / 10) + ' KB' : (Math.round(n / 104857.6) / 10) + ' MB');
const rrFmtDay = (iso) => { const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
const RR_CTX_MAX_BYTES = 200 * 1024;

// Per-portion reads. Amounts are strings as typed; waits are seconds.
const rrAmountOf = (item, pKey) => {
  const v = (item && item.amounts) ? item.amounts[pKey] : null;
  return v == null ? '' : String(v);
};
const rrWaitOf = (step, pKey) => {
  const w = (step && step.wait) ? step.wait[pKey] : null;
  return (w == null || w === '' || isNaN(Number(w))) ? null : Number(w);
};
const rrWaitTotal = (steps, pKey) => steps.filter(rrIsWait).reduce((a, s) => a + (rrWaitOf(s, pKey) || 0), 0);
// "Oil 15 g · Sugar 12 g" — the list column and the modal preview.
const rrStepText = (items, pKey) => (items || []).map(it => {
  const a = rrAmountOf(it, pKey);
  return (it.name || 'Unnamed') + (a ? ' ' + a + (it.unit ? ' ' + it.unit : '') : '');
}).join(' · ');

// Restamp sort_order in 10-step increments after moving `id` one slot
// within `list`. Restamping (vs a two-row value swap) survives duplicate
// sort values; useSupaList only writes the rows that actually changed.
const rrMoveSort = (list, id, dir, apply) => {
  const idx = list.findIndex(x => x.id === id);
  const nIdx = idx + dir;
  if (idx < 0 || nIdx < 0 || nIdx >= list.length) return;
  const next = list.slice();
  const [moved] = next.splice(idx, 1);
  next.splice(nIdx, 0, moved);
  apply(new Map(next.map((x, i) => [x.id, (i + 1) * 10])));
};

// Focus the input at grid coordinates (row, col). Enter / ↓ walk DOWN a
// portion column and ↑ walks up — the cells are real <input>s tagged
// data-rr="r{row}c{col}", so this stays a one-liner instead of a bespoke
// cell-editor state machine. `dir` keeps walking past rows that have no
// input in that column (a section header, a step with no ingredients).
const rrFocusCell = (r, c, dir) => {
  for (let i = 0; i < 400 && r >= 0; i++) {
    const el = document.querySelector('[data-rr="r' + r + 'c' + c + '"]');
    if (el) { el.focus(); if (el.select) el.select(); return; }
    if (!dir) return;
    r += dir;
  }
};

// Restamp sort_order in 10s for an explicit id order (drag-drop, insert).
const rrOrderMap = (ids) => new Map(ids.map((id, i) => [id, (i + 1) * 10]));

// ------------------------------------------------------------------
// Styles — scoped to rr- classnames, kept in-file so the shared
// portal.css doesn't need a cache-busting version bump for one page.
// (No backticks in here: this block is a JS template literal.)
// ------------------------------------------------------------------
const RrStyles = () => (
  <style>{`
/* The portal's --font-num is SF Pro Display — a display face, not a mono
   one. In a grid whose whole job is comparing a number to the one beside
   it, digits have to occupy the same width, so the page declares its own
   mono stack locally. */
.rr-page { --rr-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; }
/* Sideways scroll only. No max-height: the grid is as tall as it needs to
   be and the PAGE scrolls — a recipe is read top to bottom. */
.rr-scroll { overflow-x: auto; }
.rr-grid { border-collapse: separate; border-spacing: 0; width: max-content; min-width: 100%; }
.rr-grid th, .rr-grid td { padding: 0; border-bottom: 1px solid var(--border-2); text-align: left; vertical-align: top; }
.rr-grid thead th { position: sticky; top: 0; z-index: 2; background: var(--bg-surface); border-bottom: 1px solid var(--border-1); }
/* The step column stays put while the portions scroll under it. */
.rr-stick { position: sticky; left: 0; z-index: 3; background: var(--bg-surface); border-right: 1px solid var(--border-1); width: 380px; min-width: 380px; }
.rr-grid thead .rr-stick { z-index: 4; }
.rr-row:hover td { background: #FAFAFC; }
.rr-row:hover .rr-stick { background: #FAFAFC; }
.rr-thlabel { padding: 9px 12px; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-3); font-weight: 600; }
.rr-grid th.rr-phead { padding: 9px 10px; border-left: 1px solid var(--border-2); text-align: center; width: 132px; min-width: 132px; }
.rr-pname { font-size: 13px; font-weight: 600; color: var(--fg-1); letter-spacing: -0.01em; white-space: nowrap; }
.rr-tag { margin-left: 5px; font-size: 10px; font-weight: 500; color: var(--fg-3); text-transform: uppercase; letter-spacing: 0.05em; }
.rr-secrow td { background: var(--bg-sunken); padding: 5px 12px; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-2); font-weight: 600; }
.rr-cell { position: relative; border-left: 1px solid var(--border-2); }
.rr-cellwrap { padding: 6px 6px; }
/* One 30px line per ingredient, in the step column AND in every portion
   column, so a name and its amounts sit on the same row across the grid. */
.rr-line { display: flex; align-items: center; height: 30px; }
.rr-line-empty { justify-content: flex-end; padding-right: 30px; color: var(--border-1); font-size: 12px; }

/* ---- number boxes ---- */
.rr-in { height: 26px; width: 56px; padding: 0 7px; font-size: 13.5px; font-family: var(--rr-mono); font-variant-numeric: tabular-nums; color: var(--fg-1); background: transparent; border: 1px solid transparent; border-radius: 6px; text-align: right; }
.rr-in:hover { background: var(--bg-sunken); }
.rr-in:focus { background: #fff; border-color: var(--fg-1); outline: none; box-shadow: 0 0 0 2px rgba(24,24,24,0.08); }
.rr-in::placeholder { color: var(--border-1); }
/* appearance:none drops the native chevron, which was eating the last
   letters of "Aggressive" in a narrow column. Hover still reveals it as a
   control, and the whole cell opens the menu. */
.rr-sel { appearance: none; -webkit-appearance: none; width: 100%; height: 26px; padding: 0 7px; font-size: 12.5px; font-weight: 500; color: var(--fg-1); background: transparent; border: 1px solid transparent; border-radius: 6px; cursor: pointer; text-align: right; }
.rr-sel:hover { background: var(--bg-sunken); }
.rr-sel:focus { background: #fff; border-color: var(--fg-1); outline: none; }
/* The unit sits OUTSIDE the input so it can be muted — the value is what
   you read, the unit is a reminder. A fixed slot keeps every number in a
   column ending at the same x whether its row has a unit or not. */
.rr-unit { width: 22px; flex-shrink: 0; padding-left: 3px; font-size: 10.5px; font-family: var(--rr-mono); font-variant-numeric: tabular-nums; color: var(--fg-3); overflow: hidden; }
/* The multiple hugs the RIGHT edge of the cell (margin-left:auto in the
   flex line), so amount chips and wait chips share one right edge down a
   column whatever sits to their left. Flat text, no pill (v23.12) — the
   number is what you read, the multiple is a footnote; hover shows it's a
   control. */
.rr-cmp { height: 18px; margin-left: auto; font-size: 10px; font-family: var(--rr-mono); font-variant-numeric: tabular-nums; color: var(--fg-3); background: transparent; border: 1px solid transparent; border-radius: 4px; padding: 0 4px; white-space: nowrap; cursor: text; text-align: right; }
.rr-cmp:hover { border-color: var(--border-1); background: #fff; color: var(--fg-1); }
.rr-cmp.is-flat { color: var(--fg-3); background: transparent; }
/* No value yet in this portion: a faint × you can click to fill it by
   multiple ("2x of the 1x number"). */
.rr-cmp.is-empty { color: var(--border-1); background: transparent; }
.rr-cmp.is-empty:hover { color: var(--fg-2); }
.rr-cmp-in { width: 44px; height: 18px; margin-left: auto; padding: 0 3px; text-align: center; font-size: 10px; font-family: var(--rr-mono); font-variant-numeric: tabular-nums; color: var(--fg-1); background: #fff; border: 1px solid var(--fg-1); border-radius: 4px; outline: none; box-shadow: 0 0 0 2px rgba(24,24,24,0.08); }
.rr-sub { min-height: 13px; padding-right: 25px; text-align: right; font-size: 10px; font-family: var(--rr-mono); font-variant-numeric: tabular-nums; color: var(--fg-3); }

/* ---- setup rows ---- */
.rr-setupname { font-size: 13px; font-weight: 500; color: var(--fg-1); padding: 13px 12px 13px 48px; }
.rr-setuprow .rr-cellwrap { padding: 6px 6px 3px; }

/* ---- the rail, drawn down the step column ---- */
.rr-railcell { position: relative; }
.rr-railcell::before { content: ''; position: absolute; left: 25px; top: -1px; bottom: -1px; width: 2px; background: var(--border-2); }
.rr-railcell.is-first::before { top: 21px; }
.rr-railcell.is-end::before { bottom: auto; height: 21px; }
.rr-railcell.is-first.is-end::before { display: none; }
.rr-stepcell { position: relative; display: flex; align-items: flex-start; gap: 8px; padding: 6px 8px 6px 12px; }
.rr-num { position: relative; z-index: 1; flex-shrink: 0; width: 28px; height: 28px; margin-top: 1px; border-radius: 999px; background: var(--bg-surface); border: 2px solid var(--border-1); display: flex; align-items: center; justify-content: center; font-size: 11.5px; font-weight: 600; font-family: var(--rr-mono); color: var(--fg-2); }
.rr-num.is-manual { background: var(--fg-1); border-color: var(--fg-1); color: #fff; }
.rr-num.is-end { border-color: var(--fg-1); color: var(--fg-1); }
/* Step type in front of the ingredients so the column scans as a sequence
   of auto / manual. Manual is the one the crew acts on, so it's filled.
   Both open the step popup. */
/* One fixed width for all three badges — sized to MANUAL, the widest — so
   the ingredient names start on the same x down the whole rail. */
.rr-kind { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 64px; margin-top: 5px; height: 20px; padding: 0; font-size: 9.5px; line-height: 1; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 600; color: var(--fg-2); background: var(--bg-sunken); border: 1px solid transparent; border-radius: 5px; cursor: pointer; }
.rr-kind:hover { border-color: var(--border-1); background: #fff; }
.rr-kind.is-manual { color: #fff; background: var(--fg-1); }
.rr-kind.is-manual:hover { background: #000; border-color: #000; }
.rr-names { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rr-name { display: block; width: 100%; height: 30px; line-height: 28px; text-align: left; font-size: 13px; font-weight: 500; color: var(--fg-1); background: transparent; border: 1px solid transparent; border-radius: 6px; padding: 0 6px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rr-name:hover { background: var(--bg-sunken); border-color: var(--border-1); }
.rr-name.is-empty { color: var(--fg-3); font-style: italic; font-weight: 400; }
.rr-note { font-size: 11.5px; color: var(--fg-3); font-style: italic; padding: 0 6px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Row tools are always visible (muted) rather than hover-only: the point
   of a drag handle is that you can see it. */
.rr-actions { display: flex; align-items: center; gap: 0; flex-shrink: 0; margin-top: 3px; opacity: 0.5; transition: opacity 120ms; }
.rr-row:hover .rr-actions, .rr-row:focus-within .rr-actions { opacity: 1; }
.rr-actions button, .rr-handle { width: 22px; height: 24px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--fg-3); background: transparent; border: none; cursor: pointer; padding: 0; }
.rr-actions button:hover { color: var(--fg-1); background: var(--bg-sunken); }
.rr-actions button:disabled { opacity: 0.25; cursor: default; background: transparent; color: var(--fg-3); }
.rr-actions button.rr-del:hover { color: var(--danger); background: var(--danger-bg); }
.rr-handle { cursor: grab; }
.rr-handle:hover { color: var(--fg-1); }
.rr-handle:active { cursor: grabbing; }
/* Drop target: a 2px bar along the top of the entry you'd land before, or
   along the bottom of the one you'd land after. */
.rr-row.is-dragging td { opacity: 0.35; }
.rr-row.is-drop-before td { box-shadow: inset 0 2px 0 var(--fg-1); }
.rr-row.is-drop-after td { box-shadow: inset 0 -2px 0 var(--fg-1); }
/* A wait entry: no step number — a dashed circle with a clock on the
   rail, a dashed badge, a quiet label. Clearly on the rail, clearly not a
   step and not an ingredient. */
.rr-num.is-wait { border-style: dashed; color: var(--fg-3); }
.rr-kind.is-wait { background: transparent; border: 1px dashed var(--border-1); color: var(--fg-3); cursor: default; }
.rr-kind.is-wait:hover { background: var(--bg-sunken); }
/* Linear-only tag beside the badge, and the derived cells it locks. */
.rr-lin { flex-shrink: 0; display: inline-flex; align-items: center; height: 20px; padding: 0 6px; border-radius: 5px; font-size: 9.5px; line-height: 1; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 600; color: var(--fg-2); background: transparent; border: 1px solid var(--border-1); white-space: nowrap; }
/* A name line: the name button plus, on the first line, the Linear tag
   AFTER the name (v23.12). */
.rr-nameline { display: flex; align-items: center; gap: 8px; height: 30px; min-width: 0; }
.rr-nameline .rr-name { width: auto; flex: 0 1 auto; min-width: 0; }
.rr-in:disabled { color: var(--fg-2); background: transparent; cursor: default; }
.rr-in:disabled:hover { background: transparent; }
.rr-cmp.is-locked, .rr-cmp.is-locked:hover { cursor: default; border-color: transparent; background: transparent; color: var(--fg-3); }
.rr-waitname { height: 30px; line-height: 30px; padding: 0 6px; font-size: 12.5px; color: var(--fg-3); white-space: nowrap; }
/* The seconds are a muted pill, not a number-with-unit like the
   ingredients — a column of "5 s" next to a column of "15 g" read as one
   kind of thing. */
.rr-waitpill { display: inline-flex; align-items: center; gap: 1px; height: 22px; margin-left: 4px; padding: 0 7px 0 6px; border-radius: 999px; background: var(--bg-sunken); color: var(--fg-3); }
.rr-waitpill:hover { background: #ECECF0; }
.rr-waitpill:focus-within { background: #fff; box-shadow: 0 0 0 1.5px var(--fg-1); }
.rr-waitpill .rr-in { width: 34px; height: 20px; padding: 0 3px; font-size: 12px; color: var(--fg-2); border: none; border-radius: 0; background: transparent; }
.rr-waitpill .rr-in:hover, .rr-waitpill .rr-in:focus { background: transparent; border: none; box-shadow: none; }
.rr-waitunit { font-size: 10px; font-family: var(--rr-mono); color: var(--fg-3); }

.rr-addrow td { display: flex; gap: 18px; }
.rr-endcell { display: flex; align-items: center; gap: 10px; padding: 8px 12px; font-size: 13px; font-weight: 600; color: var(--fg-1); }
.rr-endsum { padding: 13px 6px 12px; text-align: right; padding-right: 31px; font-size: 11.5px; font-family: var(--rr-mono); font-variant-numeric: tabular-nums; color: var(--fg-3); white-space: nowrap; }
.rr-emptyrow td { padding: 26px 12px; text-align: center; color: var(--fg-3); font-size: 12.5px; }
.rr-addrow td { padding: 7px 12px 7px 50px; }
.rr-addbtn { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--fg-2); background: transparent; border: none; cursor: pointer; padding: 4px 0; }
.rr-addbtn:hover { color: var(--fg-1); }

/* ---- Scale with Claude popup ---- */
.rr-ai { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 12.5px; }
.rr-ai th { position: sticky; top: 0; z-index: 1; background: var(--bg-surface); text-align: left; padding: 6px 8px; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fg-3); font-weight: 600; border-bottom: 1px solid var(--border-1); white-space: nowrap; }
.rr-ai td { padding: 5px 8px; border-bottom: 1px solid var(--border-2); vertical-align: top; }
.rr-ai td.rr-ai-num, .rr-ai th.rr-ai-num { text-align: right; font-family: var(--rr-mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
.rr-ai-sec td { background: var(--bg-sunken); padding: 4px 8px; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fg-2); font-weight: 600; }
.rr-ai-label { font-weight: 500; color: var(--fg-1); white-space: nowrap; }
.rr-ai-label .rr-tag { margin-left: 4px; }
.rr-ai-was { display: block; font-size: 10px; color: var(--fg-3); }
.rr-ai-same { color: var(--fg-3); }
.rr-ai-why { color: var(--fg-2); font-size: 11.5px; line-height: 1.35; min-width: 180px; }
.rr-ai-rule { display: inline-block; margin-right: 5px; padding: 0 5px; border-radius: 4px; font-size: 9.5px; letter-spacing: 0.05em; text-transform: uppercase; font-weight: 600; color: var(--fg-2); background: var(--bg-sunken); white-space: nowrap; }
.rr-ai-rule.is-fixed { color: var(--fg-3); }
.rr-ai-rule.is-linear { color: #fff; background: var(--fg-1); }
.rr-ai-busy { display: flex; align-items: center; gap: 10px; padding: 26px 0; color: var(--fg-2); font-size: 13px; }
.rr-ai-dot { width: 9px; height: 9px; border-radius: 999px; background: var(--fg-1); animation: rr-pulse 1s ease-in-out infinite; }
@keyframes rr-pulse { 0%, 100% { opacity: 0.25; transform: scale(0.8); } 50% { opacity: 1; transform: scale(1); } }

/* ---- context file card (Scale with Claude) ---- */
.rr-ctxbox { border: 1px solid var(--border-2); border-radius: 8px; background: var(--bg-surface); }
.rr-ctxrow { display: flex; align-items: center; gap: 10px; padding: 8px 10px; }
.rr-ctxname { font-size: 13px; font-weight: 500; color: var(--fg-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rr-ctxmeta { font-size: 11px; color: var(--fg-3); white-space: nowrap; }
.rr-ctxbtn { height: 26px; padding: 0 9px; border-radius: 6px; border: 1px solid var(--border-1); background: var(--bg-surface); color: var(--fg-2); font-size: 12px; cursor: pointer; white-space: nowrap; }
.rr-ctxbtn:hover { color: var(--fg-1); border-color: var(--fg-1); }
.rr-ctxbtn.is-danger:hover { color: var(--danger); border-color: var(--danger); }
.rr-ctxbtn:disabled { opacity: 0.5; cursor: default; }
.rr-ctxpre { margin: 0; padding: 10px 12px; border-top: 1px solid var(--border-2); max-height: 260px; overflow: auto; font-size: 11.5px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; font-family: var(--rr-mono); color: var(--fg-2); background: var(--bg-sunken); border-radius: 0 0 8px 8px; }
.rr-ctxdrop { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 14px; border: 1px dashed var(--border-1); border-radius: 8px; background: transparent; color: var(--fg-1); font-size: 13px; font-weight: 500; cursor: pointer; }
.rr-ctxdrop:hover { border-color: var(--fg-1); background: var(--bg-sunken); }
.rr-ctxdrop span { font-size: 11.5px; font-weight: 400; color: var(--fg-3); text-align: center; line-height: 1.4; }
.rr-ctxdrop:disabled { opacity: 0.5; cursor: default; }

/* ---- step popup ---- */
.rr-mhead { display: grid; grid-template-columns: minmax(0, 1fr) 84px 96px 28px; gap: 8px; margin-bottom: 4px; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-3); font-weight: 600; }
.rr-mrow { display: grid; grid-template-columns: minmax(0, 1fr) 84px 96px 28px; gap: 8px; align-items: center; margin-bottom: 6px; }
.rr-mdel { width: 28px; height: 28px; border-radius: 6px; border: none; background: transparent; color: var(--fg-3); cursor: pointer; font-size: 16px; line-height: 1; }
.rr-mdel:hover { color: var(--danger); background: var(--danger-bg); }
.rr-mdel:disabled { opacity: 0.25; cursor: default; background: transparent; color: var(--fg-3); }
`}</style>
);

// ------------------------------------------------------------------
// A number box that commits on blur / Enter and abandons on Escape.
// ------------------------------------------------------------------
// The draft lives in a ref as well as state: a blur handler only sees the
// state of the render that created it, so anything that focuses, types
// and blurs faster than React re-renders (autofill, a paste macro) would
// otherwise commit a stale draft.
const RrNumInput = ({ value, onCommit, className, placeholder, title, row, col, mark, disabled }) => {
  const [draft, setDraft] = useState(null);   // null = not editing
  const draftRef = useRef(null);
  const escRef = useRef(false);
  const start = (e) => {
    escRef.current = false;
    draftRef.current = value;
    setDraft(value);
    if (e && e.target && e.target.select) e.target.select();
  };
  const finish = () => {
    const d = draftRef.current;
    draftRef.current = null;
    setDraft(null);
    if (escRef.current) { escRef.current = false; return; }
    if (d != null && d !== value) onCommit(d);
  };
  return (
    <input
      className={className || 'rr-in'}
      data-rr={disabled ? undefined : 'r' + row + 'c' + col}
      data-rr-entry={mark || undefined}
      {...RR_NO_PM}
      disabled={!!disabled}
      title={title}
      value={draft == null ? value : draft}
      placeholder={placeholder || ''}
      onFocus={start}
      onChange={e => { draftRef.current = e.target.value; setDraft(e.target.value); }}
      onBlur={finish}
      onKeyDown={e => {
        // Blur BEFORE moving: commit happens on blur, and in the last row
        // of a column focus has nowhere to go, so without the explicit blur
        // the edit would sit uncommitted until you clicked away.
        if (e.key === 'Enter' || e.key === 'ArrowDown') {
          e.preventDefault(); e.currentTarget.blur(); rrFocusCell(row + 1, col, 1);
        } else if (e.key === 'ArrowUp') {
          e.preventDefault(); e.currentTarget.blur(); rrFocusCell(row - 1, col, -1);
        } else if (e.key === 'Escape') {
          escRef.current = true; draftRef.current = null; setDraft(null); e.currentTarget.blur();
        }
      }}
    />
  );
};

// ------------------------------------------------------------------
// The compare multiple, and it is editable (v23.05): click 1.6x, type 1.5,
// and the value becomes baseline × 1.5 rounded for its unit (22.5 g →
// 23 g). A cook scales by multiple — "a bit under double" — so the chip is
// the input, not just the readout. A blank cell shows a faint × that fills
// the same way. Commits go through the normal amount / wait commit, so ⌘Z
// covers them.
// ------------------------------------------------------------------
const RrRatioChip = ({ cur, base, unit, onCommit, title, locked }) => {
  const [draft, setDraft] = useState(null);      // null = showing the chip
  const draftRef = useRef(null);
  const escRef = useRef(false);
  const inputRef = useRef(null);
  const baseN = rrNum(base);
  const curN = rrNum(cur);
  useEffect(() => {
    if (draft != null && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); }
  }, [draft != null]);
  if (baseN == null || !isFinite(baseN) || baseN === 0) return null;
  const text = rrRatioText(curN, baseN);         // null when this portion is blank
  if (locked) {
    return text == null ? null : (
      <span className={'rr-cmp is-locked' + (text === '1.0x' ? ' is-flat' : '')} title={title}>{text}</span>
    );
  }
  const start = () => {
    escRef.current = false;
    const r = (curN == null || !isFinite(curN)) ? '' : rrRatioOf(curN, baseN).toFixed(1);
    draftRef.current = r; setDraft(r);
  };
  const finish = () => {
    const d = draftRef.current;
    draftRef.current = null; setDraft(null);
    if (escRef.current) { escRef.current = false; return; }
    if (d == null) return;
    const r = parseFloat(String(d).replace(/[^0-9.]/g, ''));
    if (!isFinite(r) || r < 0) return;
    const v = rrFmtNum(rrRoundFor(unit, baseN * r));
    if (v !== (cur == null ? '' : String(cur))) onCommit(v);
  };
  if (draft != null) {
    return (
      <input
        ref={inputRef}
        className="rr-cmp-in"
        {...RR_NO_PM}
        value={draft}
        title={title}
        onChange={e => { draftRef.current = e.target.value; setDraft(e.target.value); }}
        onBlur={finish}
        onKeyDown={e => {
          if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); e.currentTarget.blur(); }
          else if (e.key === 'Escape') { escRef.current = true; draftRef.current = null; setDraft(null); }
        }}
      />
    );
  }
  return (
    <button
      className={'rr-cmp' + (text === '1.0x' ? ' is-flat' : '') + (text == null ? ' is-empty' : '')}
      title={title}
      onClick={start}
    >{text == null ? '×' : text}</button>
  );
};

// ------------------------------------------------------------------
// Top-level page
// ------------------------------------------------------------------
const RobotRecipes = () => {
  const [recipes, setRecipes] = window.useSupaList('robot_recipes', {
    fromRow: rrRecipeFromRow, toRow: rrRecipeToRow, initial: [],
  });
  const [steps, setSteps] = window.useSupaList('robot_recipe_steps', {
    fromRow: rrStepFromRow, toRow: rrStepToRow, initial: [],
  });
  const [setups, setSetups] = window.useSupaList('robot_recipe_setup', {
    fromRow: rrSetupFromRow, toRow: rrSetupToRow, initial: [],
  });

  const [openId, setOpenId] = useState(null);
  // List-view state lives HERE, not in RrList — opening a recipe renders
  // the detail instead of the list, which unmounts it (HANDBOOK §9
  // "Early-returned detail views unmount the list").
  const [search, setSearch] = useState('');
  const [showArchived, setShowArchived] = useState(false);
  const [newOpen, setNewOpen] = useState(false);
  const [toast, setToast] = useState('');

  const open = recipes.find(r => r.id === openId) || null;

  const createRecipe = (meta) => {
    const id = crypto.randomUUID();
    setRecipes(prev => [...prev, {
      id,
      name: meta.name,
      category: meta.category || '',
      notes: meta.notes || '',
      portions: meta.portions && meta.portions.length ? meta.portions : RR_ALL_PORTION_KEYS.slice(),
      archived: false,
      updatedAt: new Date().toISOString(),
    }]);
    setNewOpen(false);
    setOpenId(id);
    setToast('Dish added — build the 1x column first, then fill the other portions from it.');
  };

  return (
    <div className="portal-page-wide" style={{ maxWidth: 'none' }}>
      <RrStyles />
      {open ? (
        <RrDetail
          key={open.id}
          recipe={open}
          steps={steps} setSteps={setSteps}
          setups={setups} setSetups={setSetups}
          setRecipes={setRecipes}
          onBack={() => setOpenId(null)}
          setToast={setToast}
        />
      ) : (
        <RrList
          recipes={recipes}
          steps={steps}
          search={search} setSearch={setSearch}
          showArchived={showArchived} setShowArchived={setShowArchived}
          onOpen={setOpenId}
          onNew={() => setNewOpen(true)}
        />
      )}

      {newOpen && (
        <RrMetaModal
          title="New dish"
          initial={{ name: '', category: '', notes: '', portions: RR_ALL_PORTION_KEYS.slice() }}
          onSave={createRecipe}
          onClose={() => setNewOpen(false)}
        />
      )}

      <PToast message={toast} open={!!toast} onClose={() => setToast('')} />
    </div>
  );
};

// ------------------------------------------------------------------
// List — every dish, with its 1x shape at a glance
// ------------------------------------------------------------------
const RrList = ({ recipes, steps, search, setSearch, showArchived, setShowArchived, onOpen, onNew }) => {
  const byRecipe = useMemo(() => {
    const m = {};
    steps.forEach(s => { (m[s.recipeId] = m[s.recipeId] || []).push(s); });
    return m;
  }, [steps]);

  const q = search.trim().toLowerCase();
  const rows = recipes
    .filter(r => showArchived ? true : !r.archived)
    .filter(r => !q || ((r.name || '') + ' ' + (r.category || '')).toLowerCase().indexOf(q) >= 0)
    .sort((a, b) =>
      (a.category || '').localeCompare(b.category || '') ||
      (a.name || '').localeCompare(b.name || ''));

  const archivedCount = recipes.filter(r => r.archived).length;

  return (
    <>
      <div className="portal-page-header">
        <div>
          <h1 className="portal-page-title">Robot Recipes</h1>
          <div className="portal-page-subtitle">
            Every Next Robot program, step by step with all its portions side by side. Tune the numbers here, then copy them into the robot app.
          </div>
        </div>
        <PBtn variant="primary" size="md" icon="ri-add-line" onClick={onNew}>New dish</PBtn>
      </div>

      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
        <div style={{ position: 'relative', flex: 1, maxWidth: 340 }}>
          <input
            className="portal-input"
            {...RR_NO_PM}
            value={search}
            onChange={e => setSearch(e.target.value)}
            placeholder="Search dishes…"
            style={{ paddingLeft: 32 }}
          />
          <div style={{ position: 'absolute', left: 10, top: 0, bottom: 0, display: 'flex', alignItems: 'center', pointerEvents: 'none' }}>
            <PIcon name="search" size={14} color="var(--fg-3)" />
          </div>
        </div>
        {archivedCount > 0 && (
          <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12.5, color: 'var(--fg-2)', cursor: 'pointer' }}>
            <input type="checkbox" checked={showArchived} onChange={e => setShowArchived(e.target.checked)} />
            Show archived ({archivedCount})
          </label>
        )}
      </div>

      <div className="portal-card">
        <table className="portal-table">
          <thead>
            <tr>
              <th>Dish</th>
              <th>Category</th>
              <th style={{ textAlign: 'right' }}>Steps</th>
              <th style={{ textAlign: 'right' }}>1x waits</th>
              <th>Portions</th>
              <th style={{ width: 40 }}></th>
            </tr>
          </thead>
          <tbody>
            {rows.length === 0 && (
              <tr><td colSpan={6} style={{ padding: 30, textAlign: 'center', color: 'var(--fg-3)', fontSize: 12.5 }}>
                {recipes.length === 0
                  ? 'No dishes yet. Add the first one and start with its 1x sequence.'
                  : 'No dishes match that search.'}
              </td></tr>
            )}
            {rows.map(r => {
              const mine = (byRecipe[r.id] || []).slice().sort((a, b) => a.sortOrder - b.sortOrder);
              const stepsOnly = mine.filter(s => !rrIsWait(s));
              const manual = mine.filter(rrIsManual).length;
              const base = (r.portions && r.portions.length) ? r.portions[0] : '1x';
              const waits = mine.some(rrIsWait) ? rrWaitTotal(mine, base) : null;
              return (
                <tr key={r.id} onClick={() => onOpen(r.id)} style={{ cursor: 'pointer', opacity: r.archived ? 0.55 : 1 }}>
                  <td style={{ fontWeight: 500 }}>
                    {r.name || <span style={{ color: 'var(--fg-3)' }}>Untitled dish</span>}
                    {r.archived && <span className="rr-tag">Archived</span>}
                  </td>
                  <td style={{ color: 'var(--fg-2)' }}>{r.category || '—'}</td>
                  <td style={{ textAlign: 'right', fontFamily: 'var(--font-num)', color: 'var(--fg-2)' }}>
                    {stepsOnly.length || '—'}
                    {manual > 0 && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--fg-3)' }}>{manual} manual</span>}
                  </td>
                  <td style={{ textAlign: 'right', fontFamily: 'var(--font-num)', color: waits == null ? 'var(--fg-3)' : 'var(--fg-1)' }}>
                    {waits == null ? '—' : rrFmtTime(waits)}
                  </td>
                  <td style={{ color: 'var(--fg-2)', fontFamily: 'var(--font-num)', fontSize: 12 }}>
                    {(r.portions || []).join(' · ') || '—'}
                  </td>
                  <td style={{ textAlign: 'right', color: 'var(--fg-3)' }}><PIcon name="ri-arrow-right-s-line" size={16} /></td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </>
  );
};

// ------------------------------------------------------------------
// Detail — the sequence down the left, the portions across
// ------------------------------------------------------------------
const RrDetail = ({ recipe, steps, setSteps, setups, setSetups, setRecipes, onBack, setToast }) => {
  const portions = RR_PORTIONS.filter(p => (recipe.portions || []).indexOf(p.key) >= 0);
  const basePortion = portions.length ? portions[0].key : '1x';

  const [cmp, setCmp] = useState('on');             // on | off
  const [stepModal, setStepModal] = useState(null);  // { step, isNew, after }
  const [scaleOpen, setScaleOpen] = useState(false);
  const [aiOpen, setAiOpen] = useState(false);
  const [metaOpen, setMetaOpen] = useState(false);
  const [hist, setHist] = useState({ past: [], future: [] });
  // Drag-to-reorder. `armed` is the step whose handle is pressed — the row
  // is only draggable while that's set, so dragging across an input still
  // selects text instead of picking the row up. `drag` is the live drag:
  // which step, which step it's over, and before/after it.
  const [armed, setArmed] = useState(null);
  const [drag, setDrag] = useState(null);

  const mine = useMemo(
    () => steps.filter(s => s.recipeId === recipe.id).slice().sort((a, b) => a.sortOrder - b.sortOrder),
    [steps, recipe.id]);
  const mySetup = useMemo(
    () => setups.filter(s => s.recipeId === recipe.id),
    [setups, recipe.id]);
  const setupFor = (k) => mySetup.find(s => s.portion === k) || null;

  // Every ingredient name used in any dish, so the same thing stays spelled
  // the same way everywhere (the popup offers them as you type).
  const names = useMemo(() => {
    const set = {};
    steps.forEach(s => (s.items || []).forEach(it => { if (it.name) set[it.name] = true; }));
    return Object.keys(set).sort();
  }, [steps]);
  // Custom units already in use anywhere, offered alongside the built-ins.
  const customUnits = useMemo(() => {
    const set = {};
    steps.forEach(s => (s.items || []).forEach(it => { if (it.unit && RR_UNITS.indexOf(it.unit) < 0) set[it.unit] = true; }));
    return Object.keys(set).sort();
  }, [steps]);

  const compare = cmp !== 'off';
  const manualCount = mine.filter(rrIsManual).length;
  const stepCount = mine.filter(s => !rrIsWait(s)).length;
  const waitCount = mine.filter(rrIsWait).length;
  const colCount = 1 + portions.length;

  // ---- undo / redo -------------------------------------------------
  // A single snapshot covers BOTH tables, because one edit (Fill from 1x,
  // or an undo of it) can touch steps and setup together.
  const snapshot = () => ({
    steps: JSON.parse(JSON.stringify(mine)),
    setup: JSON.parse(JSON.stringify(mySetup)),
  });
  const pushHistory = () => {
    const snap = snapshot();
    setHist(h => ({ past: h.past.concat([snap]).slice(-60), future: [] }));
  };
  const restore = (snap) => {
    setSteps(prev => prev.filter(s => s.recipeId !== recipe.id).concat(snap.steps));
    setSetups(prev => prev.filter(s => s.recipeId !== recipe.id).concat(snap.setup));
  };
  const undo = () => {
    if (!hist.past.length) return;
    const cur = snapshot();
    restore(hist.past[hist.past.length - 1]);
    setHist({ past: hist.past.slice(0, -1), future: [cur].concat(hist.future).slice(0, 60) });
  };
  const redo = () => {
    if (!hist.future.length) return;
    const cur = snapshot();
    restore(hist.future[0]);
    setHist({ past: hist.past.concat([cur]).slice(-60), future: hist.future.slice(1) });
  };
  // No dep array: the handler must close over the CURRENT hist/steps, and
  // one add/removeEventListener per render is free next to the render
  // itself. ⌘Z is claimed globally — the grid's undo matters more here
  // than a browser text-undo inside a 4-character box.
  useEffect(() => {
    const onKey = (e) => {
      if (!(e.metaKey || e.ctrlKey)) return;
      const k = (e.key || '').toLowerCase();
      if (k === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
      else if (k === 'y') { e.preventDefault(); redo(); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  });

  // ---- writes ------------------------------------------------------
  const patchStep = (id, fn) => setSteps(prev => prev.map(s => s.id === id ? fn(s) : s));

  const commitAmount = (step, item, pKey, text) => {
    const v = String(text == null ? '' : text).trim();
    if (rrAmountOf(item, pKey) === v) return;
    pushHistory();
    patchStep(step.id, s => {
      const next = Object.assign({}, s, {
        items: (s.items || []).map(it => {
          if (it.id !== item.id) return it;
          const amounts = Object.assign({}, it.amounts || {});
          if (v === '') delete amounts[pKey]; else amounts[pKey] = v;
          return Object.assign({}, it, { amounts });
        }),
      });
      // A linear-only step: the baseline drives every other portion.
      return (s.linear && pKey === basePortion) ? rrApplyLinear(next, portions, basePortion) : next;
    });
  };

  // "1:30" and "90" both read as seconds; blank clears it.
  const commitWait = (step, pKey, text) => {
    const sec = rrParseTime(text);
    if (rrWaitOf(step, pKey) === sec) return;
    pushHistory();
    patchStep(step.id, s => {
      const wait = Object.assign({}, s.wait || {});
      if (sec == null) delete wait[pKey]; else wait[pKey] = sec;
      return Object.assign({}, s, { wait });
    });
  };

  const commitSetup = (pKey, field, value) => {
    const row = setupFor(pKey);
    if (row && (row[field] == null ? '' : String(row[field])) === String(value)) return;
    if (!row && String(value) === '') return;
    pushHistory();
    if (row) {
      setSetups(prev => prev.map(s => s.id === row.id ? Object.assign({}, s, { [field]: value }) : s));
    } else {
      const blank = { id: crypto.randomUUID(), recipeId: recipe.id, portion: pKey, heatingMode: '', tempC: '', positionDeg: '', speed: '' };
      blank[field] = value;
      setSetups(prev => prev.concat([blank]));
    }
  };

  // Restamp this recipe's steps to an explicit id order; only rows whose
  // sort_order actually changes get written.
  const applyOrder = (ids, extra) => {
    const map = rrOrderMap(ids);
    setSteps(prev => prev
      .map(s => (map.has(s.id) && s.sortOrder !== map.get(s.id)) ? Object.assign({}, s, { sortOrder: map.get(s.id) }) : s)
      .concat(extra ? [Object.assign({}, extra, { sortOrder: map.get(extra.id) })] : []));
  };

  // A new step is a DRAFT until the popup saves it — cancelling leaves no
  // unnamed row behind. `after` = insert right after that step (the + on a
  // row); otherwise it goes on the end.
  const openNewStep = (afterId) => {
    setStepModal({
      isNew: true,
      after: afterId || null,
      step: { id: crypto.randomUUID(), recipeId: recipe.id, kind: 'auto', items: [], wait: {}, note: '', sortOrder: 0 },
    });
  };

  // The popup edits the step's shape (type, ingredients, units, note) plus
  // the baseline amounts. Amounts land on the baseline portion only — a 1x
  // gram count is not a 3x gram count; Fill, Claude or typing gives the
  // others theirs.
  const saveStepConfig = (patch) => {
    pushHistory();
    const step = stepModal.step;
    const oldById = {};
    (step.items || []).forEach(it => { oldById[it.id] = it; });
    const items = patch.items.map(it => {
      const old = oldById[it.id];
      const amounts = Object.assign({}, old ? old.amounts : {});
      if (it.amount === '') delete amounts[basePortion]; else amounts[basePortion] = it.amount;
      return { id: it.id, name: it.name, unit: it.unit, amounts };
    });
    let next = Object.assign({}, step, { kind: patch.kind, note: patch.note, items, linear: !!patch.linear });
    if (next.linear) next = rrApplyLinear(next, portions, basePortion);
    if (stepModal.isNew) {
      const ids = mine.map(s => s.id);
      const at = stepModal.after ? ids.indexOf(stepModal.after) + 1 : ids.length;
      ids.splice(at > 0 ? at : ids.length, 0, next.id);
      applyOrder(ids, next);
    } else {
      patchStep(step.id, () => next);
    }
    setStepModal(null);
  };

  // A wait needs no popup: it goes straight onto the rail (after `afterId`,
  // or on the end) with blank seconds, and focus lands in its baseline box
  // so the number can be typed at once. Other portions get theirs from
  // Fill, Claude or typing, like an amount.
  const addWait = (afterId) => {
    pushHistory();
    const entry = { id: crypto.randomUUID(), recipeId: recipe.id, kind: 'wait', items: [], wait: {}, note: '', sortOrder: 0 };
    const ids = mine.map(s => s.id);
    const at = afterId ? ids.indexOf(afterId) + 1 : ids.length;
    ids.splice(at > 0 ? at : ids.length, 0, entry.id);
    applyOrder(ids, entry);
    setTimeout(() => {
      const el = document.querySelector('[data-rr-entry="' + entry.id + '"]');
      if (el) { el.focus(); if (el.select) el.select(); }
    }, 60);
  };

  const moveStep = (step, dir) => {
    pushHistory();
    rrMoveSort(mine, step.id, dir, (orderById) => {
      setSteps(prev => prev.map(s => orderById.has(s.id)
        ? Object.assign({}, s, { sortOrder: orderById.get(s.id) })
        : s));
    });
  };

  const reorderStep = (dragId, targetId, pos) => {
    if (dragId === targetId) return;
    const ids = mine.map(s => s.id).filter(id => id !== dragId);
    const ti = ids.indexOf(targetId);
    if (ti < 0) return;
    ids.splice(pos === 'before' ? ti : ti + 1, 0, dragId);
    pushHistory();
    applyOrder(ids);
  };

  const onDragStart = (e, step) => {
    if (armed !== step.id) { e.preventDefault(); return; }
    setDrag({ id: step.id, over: null, pos: null });
    e.dataTransfer.effectAllowed = 'move';
    try { e.dataTransfer.setData('text/plain', step.id); } catch (_) { /* older engines */ }
  };
  // Over the top half of an entry = land before it; bottom half = after.
  const onDragOver = (e, step) => {
    if (!drag || drag.id === step.id) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    const r = e.currentTarget.getBoundingClientRect();
    const pos = (e.clientY - r.top) < r.height / 2 ? 'before' : 'after';
    setDrag(d => (d && (d.over !== step.id || d.pos !== pos)) ? Object.assign({}, d, { over: step.id, pos }) : d);
  };
  const onDrop = (e, step) => {
    e.preventDefault();
    if (drag && drag.id !== step.id) reorderStep(drag.id, step.id, drag.pos || 'after');
    setDrag(null); setArmed(null);
  };
  const onDragEnd = () => { setDrag(null); setArmed(null); };

  const deleteStep = (step) => {
    pushHistory();
    setSteps(prev => prev.filter(s => s.id !== step.id));
    setToast(rrIsWait(step)
      ? 'Deleted the wait — ⌘Z puts it back.'
      : 'Deleted “' + (rrStepText(step.items, basePortion) || 'step') + '” — ⌘Z puts it back.');
  };

  const applyScale = (plan) => {
    pushHistory();
    const targets = plan.targets;
    const overwrite = plan.overwrite;
    if (plan.amounts || plan.times) {
      setSteps(prev => prev.map(s => {
        if (s.recipeId !== recipe.id) return s;
        let touched = false;
        const items = (s.items || []).map(it => {
          if (plan.amounts && s.linear) {
            // Linear-only: exactly the portion multiple, whatever the factors say.
            const li = rrLinearAmounts(it, portions, basePortion);
            if (JSON.stringify(li.amounts) !== JSON.stringify(it.amounts || {})) touched = true;
            return li;
          }
          const base = rrAmountOf(it, basePortion);
          if (!plan.amounts || base === '') return it;
          const amounts = Object.assign({}, it.amounts || {});
          targets.forEach(t => {
            if (overwrite || amounts[t.key] == null || amounts[t.key] === '') {
              const n = rrUnitScales(it.unit) ? rrNum(base) : null;
              amounts[t.key] = n == null ? base : rrFmtNum(n * t.amt);
              touched = true;
            }
          });
          return Object.assign({}, it, { amounts });
        });
        const wait = Object.assign({}, s.wait || {});
        const baseWait = rrWaitOf(s, basePortion);
        if (plan.times && baseWait != null) {
          targets.forEach(t => {
            if (overwrite || wait[t.key] == null) {
              wait[t.key] = Math.max(0, Math.round(baseWait * t.time));
              touched = true;
            }
          });
        }
        return touched ? Object.assign({}, s, { items, wait }) : s;
      }));
    }
    if (plan.setup) {
      const base = setupFor(basePortion);
      if (base) {
        setSetups(prev => {
          let next = prev.slice();
          targets.forEach(t => {
            const existing = next.find(s => s.recipeId === recipe.id && s.portion === t.key);
            const copy = { heatingMode: base.heatingMode, tempC: base.tempC, positionDeg: base.positionDeg, speed: base.speed };
            if (!existing) {
              next = next.concat([Object.assign({ id: crypto.randomUUID(), recipeId: recipe.id, portion: t.key }, copy)]);
            } else {
              const merged = Object.assign({}, existing);
              Object.keys(copy).forEach(k => {
                if (overwrite || merged[k] === '' || merged[k] == null) merged[k] = copy[k];
              });
              next = next.map(s => s.id === existing.id ? merged : s);
            }
          });
          return next;
        });
      }
    }
    setScaleOpen(false);
    setToast('Filled from ' + basePortion + ' — now tune the ones that shouldn’t scale straight.');
  };

  // Claude's proposal, already reduced by RrAiModal to exactly the cells
  // that should change (overwrite / fill-blanks decided there). One history
  // snapshot covers the whole pass.
  const applyAi = (plan) => {
    pushHistory();
    if (plan.steps && Object.keys(plan.steps).length) {
      setSteps(prev => prev.map(s => {
        const ps = plan.steps[s.id];
        if (!ps) return s;
        let touched = false;
        const items = (s.items || []).map(it => {
          const pa = ps.items ? ps.items[it.id] : null;
          if (!pa) return it;
          const amounts = Object.assign({}, it.amounts || {});
          Object.keys(pa).forEach(k => { if (amounts[k] !== pa[k]) { amounts[k] = pa[k]; touched = true; } });
          return Object.assign({}, it, { amounts });
        });
        const wait = Object.assign({}, s.wait || {});
        Object.keys(ps.wait || {}).forEach(k => { if (wait[k] !== ps.wait[k]) { wait[k] = ps.wait[k]; touched = true; } });
        return touched ? Object.assign({}, s, { items, wait }) : s;
      }));
    }
    if (plan.setup && Object.keys(plan.setup).length) {
      setSetups(prev => {
        let next = prev.slice();
        Object.keys(plan.setup).forEach(pKey => {
          const vals = plan.setup[pKey];
          const existing = next.find(x => x.recipeId === recipe.id && x.portion === pKey);
          if (existing) next = next.map(x => x.id === existing.id ? Object.assign({}, x, vals) : x);
          else next = next.concat([Object.assign({ id: crypto.randomUUID(), recipeId: recipe.id, portion: pKey, heatingMode: '', tempC: '', positionDeg: '', speed: '' }, vals)]);
        });
        return next;
      });
    }
    setAiOpen(false);
    setToast('Applied Claude’s numbers to ' + plan.portionCount + ' portion' + (plan.portionCount === 1 ? '' : 's') + ' (' + plan.changes + ' change' + (plan.changes === 1 ? '' : 's') + ') — ⌘Z takes it all back.');
  };

  const saveMeta = (meta) => {
    setRecipes(prev => prev.map(r => r.id === recipe.id ? Object.assign({}, r, meta) : r));
    setMetaOpen(false);
  };
  const saveRecipeContext = (text) => {
    setRecipes(prev => prev.map(r => r.id === recipe.id ? Object.assign({}, r, { aiContext: text }) : r));
  };

  const toggleArchive = () => {
    setRecipes(prev => prev.map(r => r.id === recipe.id ? Object.assign({}, r, { archived: !r.archived }) : r));
    setToast(recipe.archived ? 'Dish restored.' : 'Dish archived.');
  };

  const deleteRecipe = () => {
    if (!window.confirm('Delete “' + (recipe.name || 'this dish') + '” and all of its steps? This cannot be undone.')) return;
    setSteps(prev => prev.filter(s => s.recipeId !== recipe.id));
    setSetups(prev => prev.filter(s => s.recipeId !== recipe.id));
    setRecipes(prev => prev.filter(r => r.id !== recipe.id));
    onBack();
  };

  // ---- render ------------------------------------------------------
  // Rows are numbered top to bottom for Enter/↓/↑: the setup rows first,
  // then one row per ingredient line and one per wait. Column = portion.
  let rowIdx = 0;

  const renderSetupRow = (f) => {
    const r = rowIdx++;
    return (
      <tr className="rr-row rr-setuprow" key={f.key}>
        <td className="rr-stick"><div className="rr-setupname">{f.label}</div></td>
        {portions.map((p, pi) => {
          const row = setupFor(p.key);
          const val = row ? (row[f.key] || '') : '';
          return (
            <td className="rr-cell" key={p.key}>
              <div className="rr-cellwrap">
                {f.type === 'enum' ? (
                  <div className="rr-line">
                    <select className="rr-sel" value={val} onChange={e => commitSetup(p.key, f.key, e.target.value)}>
                      <option value="">—</option>
                      {f.options.map(o => <option key={o} value={o}>{o}</option>)}
                    </select>
                  </div>
                ) : (
                  <div className="rr-line">
                    <RrNumInput
                      row={r} col={pi}
                      value={val}
                      placeholder="—"
                      title={f.label + ' for ' + p.label}
                      onCommit={txt => commitSetup(p.key, f.key, String(txt).replace(/[^0-9.\-]/g, ''))}
                    />
                    <span className="rr-unit">{val === '' ? '' : f.suffix}</span>
                  </div>
                )}
                <div className="rr-sub">{f.key === 'tempC' && val !== '' ? rrToF(val) : ''}</div>
              </div>
            </td>
          );
        })}
      </tr>
    );
  };

  // Row tools, shared by ingredient steps and waits. Always visible
  // (muted) — the point of a drag handle is that you can see it.
  const renderActions = (step, i) => (
    <div className="rr-actions">
      <span className="rr-handle" title="Drag to reorder"
        onMouseDown={() => setArmed(step.id)} onMouseUp={() => setArmed(null)}>
        <PIcon name="ri-draggable" size={15} />
      </span>
      <button title="Move up" disabled={i === 0} onClick={() => moveStep(step, -1)}>
        <PIcon name="ri-arrow-up-s-line" size={15} />
      </button>
      <button title="Move down" disabled={i === mine.length - 1} onClick={() => moveStep(step, 1)}>
        <PIcon name="ri-arrow-down-s-line" size={15} />
      </button>
      <button title="Insert a step after this" onClick={() => openNewStep(step.id)}>
        <PIcon name="ri-add-line" size={14} />
      </button>
      <button title="Insert a wait after this" onClick={() => addWait(step.id)}>
        <PIcon name="ri-timer-line" size={14} />
      </button>
      <button className="rr-del" title={rrIsWait(step) ? 'Delete wait' : 'Delete step'} onClick={() => deleteStep(step)}>
        <PIcon name="ri-delete-bin-line" size={14} />
      </button>
    </div>
  );

  const dragProps = (step) => ({
    draggable: armed === step.id,
    onDragStart: (e) => onDragStart(e, step),
    onDragOver: (e) => onDragOver(e, step),
    onDrop: (e) => onDrop(e, step),
    onDragEnd,
  });
  const dragClass = (step) =>
    (drag && drag.id === step.id ? ' is-dragging' : '') +
    (drag && drag.over === step.id ? (drag.pos === 'before' ? ' is-drop-before' : ' is-drop-after') : '');

  // Waits don't take a step number — 1, wait, 2, wait, 3 reads the way the
  // robot's own program does. `stepNo` counts only the ingredient steps.
  const stepNoOf = (i) => mine.slice(0, i).filter(s => !rrIsWait(s)).length + 1;

  const renderWaitRow = (step, i) => {
    const r = rowIdx++;
    return (
      <tr className={'rr-row rr-waitrow' + dragClass(step)} key={step.id} {...dragProps(step)}>
        <td className={'rr-stick rr-railcell' + (i === 0 ? ' is-first' : '')}>
          <div className="rr-stepcell">
            <div className="rr-num is-wait" title="Wait"><PIcon name="ri-timer-line" size={13} color="var(--fg-3)" /></div>
            <span className="rr-kind is-wait">Wait</span>
            <div className="rr-names"><div className="rr-waitname">hold before the next step</div></div>
            {renderActions(step, i)}
          </div>
        </td>
        {portions.map((p, pi) => {
          const w = rrWaitOf(step, p.key);
          const baseW = rrWaitOf(step, basePortion);
          return (
            <td className="rr-cell" key={p.key}>
              <div className="rr-cellwrap">
                <div className="rr-line">
                  <span className="rr-waitpill" title={'Seconds the program holds at ' + p.label}>
                    <PIcon name="ri-timer-line" size={11} color="var(--fg-3)" />
                    <RrNumInput
                      row={r} col={pi}
                      mark={p.key === basePortion ? step.id : undefined}
                      value={w == null ? '' : String(w)}
                      placeholder="0"
                      title={'Seconds the program holds at ' + p.label}
                      onCommit={txt => commitWait(step, p.key, txt)}
                    />
                    <span className="rr-waitunit">s</span>
                  </span>
                  {compare && p.key !== basePortion && (
                    <RrRatioChip
                      cur={w == null ? '' : String(w)}
                      base={baseW == null ? '' : String(baseW)}
                      unit="s"
                      title={'Multiple of the ' + basePortion + ' wait — click and type one'}
                      onCommit={v => commitWait(step, p.key, v)}
                    />
                  )}
                </div>
              </div>
            </td>
          );
        })}
      </tr>
    );
  };

  const renderStepRow = (step, i) => {
    const manual = rrIsManual(step);
    const items = (step.items || []).length ? step.items : [null];
    const firstRow = rowIdx; rowIdx += items.length;
    const openStep = () => setStepModal({ step, isNew: false });
    return (
      <tr className={'rr-row rr-steprow' + dragClass(step)} key={step.id} {...dragProps(step)}>
        <td className={'rr-stick rr-railcell' + (i === 0 ? ' is-first' : '')}>
          <div className="rr-stepcell">
            <div className={'rr-num' + (manual ? ' is-manual' : '')}>{stepNoOf(i)}</div>
            <button className={'rr-kind' + (manual ? ' is-manual' : '')} onClick={openStep} title="Change this step's type, ingredients or note">
              {manual ? 'Manual' : 'Auto'}
            </button>
            <div className="rr-names">
              {items.map((item, k) => (
                <div className="rr-nameline" key={item ? item.id : 'none'}>
                  {item ? (
                    <button className={'rr-name' + (item.name ? '' : ' is-empty')} onClick={openStep} title="Rename, change the unit, or add another ingredient to this step">
                      {item.name || 'Unnamed'}
                    </button>
                  ) : (
                    <button className="rr-name is-empty" onClick={openStep}>Add an ingredient…</button>
                  )}
                  {k === 0 && step.linear && (
                    <span className="rr-lin" title={'Linear only: every portion is the ' + basePortion + ' amount × its multiple. Set the ' + basePortion + ' amount and the others follow; Fill and Scale with Claude keep it linear.'}>Linear</span>
                  )}
                </div>
              ))}
              {step.note ? <div className="rr-note" title={step.note}>{step.note}</div> : null}
            </div>
            {renderActions(step, i)}
          </div>
        </td>
        {portions.map((p, pi) => (
          <td className="rr-cell" key={p.key}>
            <div className="rr-cellwrap">
              {items.map((item, k) => {
                if (!item) return <div className="rr-line rr-line-empty" key="none">—</div>;
                const a = rrAmountOf(item, p.key);
                const locked = !!step.linear && p.key !== basePortion;
                return (
                  <div className="rr-line" key={item.id}>
                    <RrNumInput
                      row={firstRow + k} col={pi}
                      value={a}
                      placeholder="—"
                      disabled={locked}
                      title={locked
                        ? 'Linear only — this follows the ' + basePortion + ' amount × ' + rrLinearFactor(p.key, basePortion) + '. Set the ' + basePortion + ' amount, or untick Linear only in the step popup.'
                        : (item.name || 'Amount') + ' for ' + p.label}
                      onCommit={txt => commitAmount(step, item, p.key, txt)}
                    />
                    <span className="rr-unit">{a === '' ? '' : item.unit}</span>
                    {compare && p.key !== basePortion && !locked && (
                      <RrRatioChip
                        cur={a}
                        base={rrAmountOf(item, basePortion)}
                        unit={item.unit}
                        locked={locked}
                        title={locked ? 'Linear only — exactly the portion multiple' : 'Multiple of ' + basePortion + ' — click and type one (1.5 → ' + basePortion + ' × 1.5, rounded)'}
                        onCommit={v => commitAmount(step, item, p.key, v)}
                      />
                    )}
                  </div>
                );
              })}
            </div>
          </td>
        ))}
      </tr>
    );
  };

  const renderEntry = (step, i) => rrIsWait(step) ? renderWaitRow(step, i) : renderStepRow(step, i);

  return (
    <div className="rr-page">
      <div className="portal-page-header" style={{ alignItems: 'flex-start' }}>
        <div>
          <button onClick={onBack} style={{
            display: 'inline-flex', alignItems: 'center', gap: 5,
            fontSize: 12.5, color: 'var(--fg-2)', background: 'transparent',
            border: 'none', cursor: 'pointer', padding: 0, marginBottom: 8,
          }}>
            <PIcon name="ri-arrow-left-line" size={14} /> All dishes
          </button>
          <h1 className="portal-page-title">{recipe.name || 'Untitled dish'}</h1>
          <div className="portal-page-subtitle">
            {recipe.category ? recipe.category + ' · ' : ''}
            {portions.length} portion{portions.length === 1 ? '' : 's'} · {stepCount} step{stepCount === 1 ? '' : 's'}
            {manualCount > 0 ? ' (' + manualCount + ' manual)' : ''}
            {' · ' + waitCount + ' wait' + (waitCount === 1 ? '' : 's')}
            {' · ' + basePortion + ' is the baseline every comparison is measured against.'}
            {recipe.notes ? ' — ' + recipe.notes : ''}
          </div>
        </div>
        <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
          <PBtn size="md" icon="ri-arrow-go-back-line" title="Undo (⌘Z)" ariaLabel="Undo" disabled={!hist.past.length} onClick={undo} />
          <PBtn size="md" icon="ri-arrow-go-forward-line" title="Redo (⌘⇧Z)" ariaLabel="Redo" disabled={!hist.future.length} onClick={redo} />
          <PBtn size="md" icon="ri-settings-3-line" onClick={() => setMetaOpen(true)}>Dish settings</PBtn>
          <PBtn size="md" icon="ri-magic-line" title={'Copy the ' + basePortion + ' column across, multiplied by a plain factor per portion'} onClick={() => setScaleOpen(true)}>Fill from {basePortion}</PBtn>
          <PBtn variant="primary" size="md" icon="ri-sparkling-2-line" disabled={mine.length === 0 || portions.length < 2}
            title={'Have Claude read the ' + basePortion + ' program and propose every other portion with cooking judgement'}
            onClick={() => setAiOpen(true)}>Scale with Claude</PBtn>
        </div>
      </div>

      <div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 14 }}>
        {portions.length > 1 && <RrToggle label={'Compare vs ' + basePortion} options={RR_CMP} value={cmp} onChange={setCmp} />}
        <span style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>
          {compare && portions.length > 1
            ? 'The small multiple beside a number is how it compares to ' + basePortion + ' — click it and type a new multiple to set the number from it.'
            : 'Steps run top to bottom; every portion runs the same steps with its own numbers.'}
        </span>
      </div>

      <div className="portal-card" style={{ padding: 0, overflow: 'hidden' }}>
        <div className="rr-scroll">
          <table className="rr-grid">
            <thead>
              <tr>
                <th className="rr-stick"><div className="rr-thlabel">Step</div></th>
                {portions.map(p => (
                  <th key={p.key} className="rr-phead">
                    <div className="rr-pname">
                      {p.label}
                      {p.sub && <span className="rr-tag">{p.sub}</span>}
                      {p.key === basePortion && portions.length > 1 && <span className="rr-tag">base</span>}
                    </div>
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              <tr className="rr-secrow"><td colSpan={colCount}>Setup — machine settings the program opens with, per portion</td></tr>
              {RR_SETUP_FIELDS.map(renderSetupRow)}

              <tr className="rr-secrow"><td colSpan={colCount}>Steps — in the order the robot runs them</td></tr>
              {mine.map(renderEntry)}
              {mine.length === 0 && (
                <tr className="rr-emptyrow"><td colSpan={colCount}>No steps yet. Add the first one — an auto dispense like oil or sauce, or a manual add like dry chili — then a wait after it.</td></tr>
              )}
              {mine.length > 0 && (
                <tr className="rr-row rr-endrow">
                  <td className="rr-stick rr-railcell is-end">
                    <div className="rr-endcell">
                      <div className="rr-num is-end"><PIcon name="ri-check-line" size={14} /></div>
                      Complete
                    </div>
                  </td>
                  {portions.map(p => (
                    <td className="rr-cell" key={p.key}>
                      <div className="rr-endsum" title={'Total of the waits at ' + p.label}>{rrFmtTime(rrWaitTotal(mine, p.key))} waits</div>
                    </td>
                  ))}
                </tr>
              )}
              <tr className="rr-addrow"><td colSpan={colCount}>
                <button className="rr-addbtn" onClick={() => openNewStep(null)}>
                  <PIcon name="ri-add-line" size={13} /> Add step
                </button>
                <button className="rr-addbtn" onClick={() => addWait(null)}>
                  <PIcon name="ri-timer-line" size={13} /> Add wait
                </button>
              </td></tr>
            </tbody>
          </table>
        </div>
      </div>

      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, marginTop: 14, fontSize: 11.5, color: 'var(--fg-3)', lineHeight: 1.5 }}>
        <span>Each entry is what the robot dispenses (Auto), what the crew adds (Manual), or a wait — how long the program holds before the next entry. Click a name or the type badge to change a step. Type straight into any number, or click its multiple and type one (1.5 makes it 1.5 × the {basePortion} value, rounded): Tab moves across a row, Enter or ↓ down a column, ⌘Z undoes. Drag an entry by its handle to reorder, or use the arrows; + inserts a step after it and ⏱ inserts a wait. A step marked Linear only follows its {basePortion} amount exactly in every portion. Scale with Claude proposes the other portions from {basePortion} with cooking judgement and shows every number before writing; Fill from {basePortion} is the plain multiplier.</span>
        <div style={{ flex: 1 }} />
        <button onClick={toggleArchive} style={{ fontSize: 11.5, color: 'var(--fg-2)', background: 'transparent', border: 'none', cursor: 'pointer', textDecoration: 'underline', whiteSpace: 'nowrap' }}>
          {recipe.archived ? 'Restore dish' : 'Archive dish'}
        </button>
        <button onClick={deleteRecipe} style={{ fontSize: 11.5, color: 'var(--danger)', background: 'transparent', border: 'none', cursor: 'pointer', textDecoration: 'underline', whiteSpace: 'nowrap' }}>
          Delete dish
        </button>
      </div>

      {stepModal && (
        <RrStepModal
          step={stepModal.step}
          isNew={stepModal.isNew}
          index={stepModal.isNew
            ? (stepModal.after ? stepNoOf(mine.findIndex(s => s.id === stepModal.after) + 1) : stepNoOf(mine.length))
            : stepNoOf(mine.findIndex(s => s.id === stepModal.step.id))}
          portionKey={basePortion}
          names={names}
          customUnits={customUnits}
          onSave={saveStepConfig}
          onClose={() => setStepModal(null)}
        />
      )}
      {scaleOpen && (
        <RrScaleModal
          basePortion={basePortion}
          portions={portions}
          onApply={applyScale}
          onClose={() => setScaleOpen(false)}
        />
      )}
      {aiOpen && (
        <RrAiModal
          recipe={recipe}
          portions={portions}
          basePortion={basePortion}
          steps={mine}
          setupFor={setupFor}
          onApply={applyAi}
          onSaveRecipeContext={saveRecipeContext}
          onClose={() => setAiOpen(false)}
        />
      )}
      {metaOpen && (
        <RrMetaModal
          title="Dish settings"
          initial={{ name: recipe.name, category: recipe.category, notes: recipe.notes, portions: recipe.portions }}
          onSave={saveMeta}
          onClose={() => setMetaOpen(false)}
        />
      )}
    </div>
  );
};

// ------------------------------------------------------------------
// Small segmented toggle used by the toolbar
// ------------------------------------------------------------------
const RrToggle = ({ label, options, value, onChange }) => (
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
    <span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--fg-3)', fontWeight: 600 }}>{label}</span>
    <div style={{ display: 'inline-flex', background: 'var(--bg-sunken)', borderRadius: 8, padding: 2 }}>
      {options.map(o => (
        <button
          key={o.value}
          onClick={() => onChange(o.value)}
          style={{
            padding: '4px 10px', fontSize: 12, borderRadius: 6,
            fontWeight: value === o.value ? 600 : 500,
            background: value === o.value ? 'var(--bg-surface)' : 'transparent',
            color: value === o.value ? 'var(--fg-1)' : 'var(--fg-2)',
            boxShadow: value === o.value ? 'var(--shadow-1)' : 'none',
            border: 'none', cursor: 'pointer',
          }}
        >{o.label}</button>
      ))}
    </div>
  </div>
);

// ------------------------------------------------------------------
// Step popup — type, the ingredients it dispenses / adds, the wait after
// ------------------------------------------------------------------
// The step's SHAPE lives here (what kind it is, which ingredients, their
// units, a note) together with the baseline (1x) amounts. Waits are not
// part of a step any more — they're their own rail entries (v23.02). A
// multi-ingredient auto step — sauce, sugar, starch water, water, MSG in
// one pour — is one step with several rows, not five steps.
const RrStepModal = ({ step, isNew, index, portionKey, names, customUnits, onSave, onClose }) => {
  const [kind, setKind] = useState(rrIsManual(step) ? 'manual' : 'auto');
  const [linear, setLinear] = useState(!!step.linear);
  const blankItem = (k) => ({ id: crypto.randomUUID(), name: '', unit: k === 'manual' ? 'x' : 'g', amount: '', custom: false });
  const [items, setItems] = useState(() => {
    const src = (step.items || []).map(it => ({ id: it.id, name: it.name || '', unit: it.unit || '', amount: rrAmountOf(it, portionKey), custom: false }));
    return src.length ? src : [blankItem(step.kind)];
  });
  const [note, setNote] = useState(step.note || '');

  // Built-ins, then anything already typed elsewhere, then this step's own
  // units if they're somehow neither — so a current value is always shown
  // as selected rather than silently dropped.
  const unitChoices = RR_UNITS.slice();
  (customUnits || []).forEach(u => { if (unitChoices.indexOf(u) < 0) unitChoices.push(u); });
  items.forEach(it => { if (it.unit && unitChoices.indexOf(it.unit) < 0) unitChoices.push(it.unit); });

  const patchItem = (id, p) => setItems(prev => prev.map(it => it.id === id ? Object.assign({}, it, p) : it));
  const removeItem = (id) => setItems(prev => prev.length > 1 ? prev.filter(it => it.id !== id) : prev);
  const addItem = () => setItems(prev => prev.concat([blankItem(kind)]));

  const valid = items.filter(it => it.name.trim());
  const canSave = valid.length > 0;
  const save = () => {
    if (!canSave) return;
    onSave({
      kind,
      linear,
      note: note.trim(),
      items: valid.map(it => ({ id: it.id, name: it.name.trim(), unit: (it.unit || '').trim().slice(0, RR_UNIT_MAX), amount: String(it.amount == null ? '' : it.amount).trim() })),
    });
  };
  const onEnter = (e) => { if (e.key === 'Enter') { e.preventDefault(); save(); } };

  const previewItems = valid.map(it => ({ name: it.name.trim(), unit: it.unit, amounts: { [portionKey]: String(it.amount || '').trim() } }));

  return (
    <PModal open onClose={onClose} title={isNew ? 'New step' : 'Step ' + index} width={580} footer={
      <>
        <PBtn variant="ghost" onClick={onClose}>Cancel</PBtn>
        <PBtn variant="primary" disabled={!canSave} onClick={save}>{isNew ? 'Add step' : 'Save'}</PBtn>
      </>
    }>
      <div style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 16 }}>
        <PField label="Step type" hint={(RR_KINDS.find(k => k.value === kind) || RR_KINDS[0]).hint}>
          <div style={{ display: 'flex', gap: 6 }}>
            {RR_KINDS.map(k => {
              const on = kind === k.value;
              return (
                <button key={k.value} onClick={() => setKind(k.value)} style={{
                  flex: 1, padding: '8px 12px', borderRadius: 8, fontSize: 12.5,
                  fontWeight: on ? 600 : 500,
                  border: '1px solid ' + (on ? 'var(--fg-1)' : 'var(--border-1)'),
                  background: on ? 'var(--fg-1)' : 'var(--bg-surface)',
                  color: on ? '#fff' : 'var(--fg-2)', cursor: 'pointer',
                }}>{k.label}</button>
              );
            })}
          </div>
        </PField>

        <PField
          label={kind === 'manual' ? 'What the crew adds' : 'What the robot dispenses'}
          hint={'Several ingredients in one step is fine — that is one pour. Amounts here are the ' + portionKey + ' numbers; the other portions get theirs from Fill from ' + portionKey + ' or by typing into the grid.'}
        >
          <div className="rr-mhead"><span>Ingredient / action</span><span style={{ textAlign: 'right' }}>Amount · {portionKey}</span><span>Unit</span><span /></div>
          {items.map((it, i) => (
            <div className="rr-mrow" key={it.id}>
              <input
                className="portal-input"
                list="rr-names"
                {...RR_NO_PM_ATTRS}
                autoFocus={i === 0 && !it.name}
                value={it.name}
                onChange={e => patchItem(it.id, { name: e.target.value })}
                placeholder={kind === 'manual' ? 'e.g. Dry Chili' : 'e.g. Oil'}
              />
              <input
                className="portal-input"
                {...RR_NO_PM}
                value={it.amount}
                onChange={e => patchItem(it.id, { amount: e.target.value })}
                onKeyDown={onEnter}
                placeholder="—"
                inputMode="decimal"
                style={{ textAlign: 'right', fontFamily: 'var(--font-num)' }}
              />
              {it.custom ? (
                <input
                  className="portal-input"
                  {...RR_NO_PM}
                  autoFocus
                  value={it.unit}
                  maxLength={RR_UNIT_MAX}
                  placeholder="unit"
                  onChange={e => patchItem(it.id, { unit: e.target.value })}
                  onBlur={() => patchItem(it.id, { custom: false })}
                  onKeyDown={e => { if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); patchItem(it.id, { custom: false }); } }}
                  style={{ fontFamily: 'var(--font-num)' }}
                />
              ) : (
                <select
                  className="portal-input"
                  value={it.unit}
                  onChange={e => {
                    const v = e.target.value;
                    if (v === '__custom') patchItem(it.id, { custom: true, unit: '' });
                    else patchItem(it.id, { unit: v });
                  }}
                  style={{ fontFamily: 'var(--font-num)', cursor: 'pointer' }}
                >
                  <option value="">none</option>
                  {unitChoices.map(u => <option key={u} value={u}>{u}</option>)}
                  <option value="__custom">other…</option>
                </select>
              )}
              <button className="rr-mdel" onClick={() => removeItem(it.id)} disabled={items.length === 1} title="Remove this ingredient">×</button>
            </div>
          ))}
          <datalist id="rr-names">{names.map(n => <option key={n} value={n} />)}</datalist>
          <button className="rr-addbtn" onClick={addItem}><PIcon name="ri-add-line" size={13} /> Add another ingredient</button>
        </PField>

        <PField label="Scaling" hint={'Tick this for things that must scale exactly with the servings — the protein, the veggies, a garnish that decorates each box. Every other portion becomes the ' + portionKey + ' amount × its multiple (2x is double) and is locked in the grid; Fill from ' + portionKey + ' and Scale with Claude keep it linear.'}>
          <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
            <input type="checkbox" checked={linear} onChange={e => setLinear(e.target.checked)} />
            Linear only — scale exactly with the portion multiple
          </label>
        </PField>

        <PField label="Note" hint="Optional — anything the numbers don't say.">
          <input className="portal-input" {...RR_NO_PM} value={note} onChange={e => setNote(e.target.value)} onKeyDown={onEnter} placeholder="e.g. stir until fragrant" />
        </PField>

        <div style={{ fontSize: 12, color: 'var(--fg-3)', lineHeight: 1.5, background: 'var(--bg-sunken)', borderRadius: 8, padding: '9px 11px' }}>
          Preview: <strong style={{ color: 'var(--fg-1)' }}>{index}. {kind === 'manual' ? 'Manual' : 'Auto'}{linear ? ' · linear' : ''} · {rrStepText(previewItems, portionKey) || 'Untitled'}</strong>
          <span> — add a wait after it from the rail if the program should hold before the next step.</span>
        </div>
      </div>
    </PModal>
  );
};

// ------------------------------------------------------------------
// Scale with Claude — the baseline expanded to the other portions with
// cooking judgement, shown as a proposal before anything is written
// ------------------------------------------------------------------
const RR_AI_RULES = {
  linear:      { label: 'Linear',       cls: 'is-linear' },
  sublinear:   { label: 'Under linear', cls: '' },
  superlinear: { label: 'Over linear',  cls: '' },
  fixed:       { label: 'Fixed',        cls: 'is-fixed' },
};

// The tool schema says arrays, but a model can still hand back a map keyed
// by portion / id, or a JSON string where an object was expected (Sonnet 5
// did, first try). Coerce every level to the array shape the plan reads,
// carrying the key in as the id when the entry left it out.
const rrAiList = (v, keyField) => {
  if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_) { return []; } }
  if (Array.isArray(v)) return v;
  if (v && typeof v === 'object') {
    return Object.keys(v).map(k => {
      const e = v[k];
      if (e && typeof e === 'object' && !Array.isArray(e)) return Object.assign({ [keyField]: k }, e);
      return { [keyField]: k, value: e };
    });
  }
  return [];
};
const rrAiNormalize = (raw) => {
  let p = raw;
  if (typeof p === 'string') { try { p = JSON.parse(p); } catch (_) { p = {}; } }
  if (!p || typeof p !== 'object') p = {};
  // Sonnet 5 once returned the WHOLE proposal as a JSON string inside
  // `portions` — unwrap that before reading anything else.
  if (typeof p.portions === 'string') {
    try {
      const inner = JSON.parse(p.portions);
      if (inner && typeof inner === 'object' && !Array.isArray(inner) && inner.portions != null) p = Object.assign({}, p, inner);
    } catch (_) { /* leave it; rrAiList will report no portions */ }
  }
  const portions = rrAiList(p.portions, 'portion').map(po => Object.assign({}, po, {
    portion: String(po.portion || ''),
    setup: (po.setup && typeof po.setup === 'object') ? po.setup : null,
    steps: rrAiList(po.steps, 'step_id').map(st => Object.assign({}, st, {
      step_id: String(st.step_id || ''),
      // A wait handed back as { value: n } from a map, or as a string.
      wait_s: st.wait_s != null ? st.wait_s : (st.value != null && st.items == null ? st.value : null),
      items: rrAiList(st.items, 'item_id').map(it => Object.assign({}, it, {
        item_id: String(it.item_id || ''),
        amount: it.amount != null ? it.amount : (it.value != null ? it.value : null),
      })),
    })),
  }));
  return {
    summary: typeof p.summary === 'string' ? p.summary : '',
    rules: rrAiList(p.rules, 'item_id'),
    wait_rules: rrAiList(p.wait_rules, 'step_id'),
    portions,
  };
};

// Turn Claude's proposal into review rows and the exact write plan. Cells
// where nothing changes (or that `overwrite` off keeps) are marked so the
// table can show them muted and the plan leaves them alone.
const rrAiPlan = (rawProposal, ctx) => {
  const { steps, targets, basePortion, setupFor, withSetup, withWaits, overwrite } = ctx;
  const proposal = rrAiNormalize(rawProposal);
  const byPortion = {};
  proposal.portions.forEach(p => { byPortion[p.portion] = p; });
  const ruleById = {}; proposal.rules.forEach(r => { ruleById[r.item_id] = r; });
  const waitRuleById = {}; proposal.wait_rules.forEach(r => { waitRuleById[r.step_id] = r; });
  const rows = [];
  const plan = { steps: {}, setup: {}, changes: 0, portionCount: 0, proposedCells: 0 };
  const touchedPortions = {};
  const mark = (pKey) => { plan.changes += 1; touchedPortions[pKey] = true; };
  const seen = (proposed) => { if (proposed != null) plan.proposedCells += 1; };

  if (withSetup) {
    RR_SETUP_FIELDS.forEach(f => {
      const snake = { heatingMode: 'heating_mode', tempC: 'temp_c', positionDeg: 'position_deg', speed: 'speed' }[f.key];
      const row = { key: 'setup|' + f.key, section: 'setup', label: f.label, unit: f.suffix || '', why: null, cells: {} };
      const baseRow = setupFor(basePortion);
      row.base = baseRow ? (baseRow[f.key] || '') : '';
      let any = false;
      targets.forEach(t => {
        const cur = setupFor(t.key);
        const curVal = cur ? (cur[f.key] || '') : '';
        const ps = byPortion[t.key] && byPortion[t.key].setup ? byPortion[t.key].setup[snake] : undefined;
        let proposed = (ps == null || ps === '') ? null : (f.type === 'enum' ? String(ps) : rrFmtNum(Number(ps)));
        if (proposed != null && f.type === 'enum' && f.options.indexOf(proposed) < 0) proposed = null;
        const keep = !overwrite && curVal !== '';
        const changed = proposed != null && !keep && proposed !== curVal;
        seen(proposed);
        row.cells[t.key] = { current: curVal, proposed, changed, keep };
        if (changed) { any = true; (plan.setup[t.key] = plan.setup[t.key] || {})[f.key] = proposed; mark(t.key); }
      });
      row.any = any;
      rows.push(row);
    });
  }

  let stepNo = 0;
  steps.forEach((s) => {
    if (rrIsWait(s)) {
      if (!withWaits) return;
      const wr = waitRuleById[s.id];
      const bw = rrWaitOf(s, basePortion);
      const row = { key: 'wait|' + s.id, section: 'step', step: null, kind: 'wait', label: 'wait', isWait: true, unit: 's', why: wr ? { rule: wr.rule, text: wr.why } : null, cells: {}, base: bw == null ? '' : String(bw) };
      let any = false;
      targets.forEach(t => {
        const cw = rrWaitOf(s, t.key);
        const curVal = cw == null ? '' : String(cw);
        const pst = byPortion[t.key] ? (byPortion[t.key].steps || []).find(x => x.step_id === s.id) : null;
        const proposedN = (pst && pst.wait_s != null && isFinite(Number(pst.wait_s)))
          ? Math.max(0, Math.round(rrSnapToBase(Number(pst.wait_s), bw, 's')))
          : null;
        const proposed = proposedN == null ? null : String(proposedN);
        const keep = !overwrite && curVal !== '';
        const changed = proposed != null && !keep && proposed !== curVal;
        seen(proposed);
        row.cells[t.key] = { current: curVal, proposed, changed, keep };
        if (changed) {
          any = true;
          const ps = plan.steps[s.id] = plan.steps[s.id] || { items: {}, wait: {} };
          ps.wait[t.key] = proposedN;
          mark(t.key);
        }
      });
      row.any = any;
      rows.push(row);
      return;
    }
    stepNo += 1;
    const label = stepNo;
    (s.items || []).forEach(it => {
      const r = ruleById[it.id];
      const linear = !!s.linear;
      const row = { key: 'item|' + it.id, section: 'step', step: label, kind: s.kind, label: it.name || 'Unnamed', unit: it.unit || '',
        why: linear ? { rule: 'linear', text: 'Linear only — set on the step; Claude’s number is not used.' } : (r ? { rule: r.rule, text: r.why } : null),
        cells: {}, base: rrAmountOf(it, basePortion) };
      const linearAmounts = linear ? rrLinearAmounts(it, targets.concat([{ key: basePortion }]), basePortion).amounts : null;
      let any = false;
      targets.forEach(t => {
        const curVal = rrAmountOf(it, t.key);
        const pst = byPortion[t.key] ? (byPortion[t.key].steps || []).find(x => x.step_id === s.id) : null;
        const pit = pst ? (pst.items || []).find(x => x.item_id === it.id) : null;
        const proposed = linear
          ? (linearAmounts[t.key] == null ? null : String(linearAmounts[t.key]))
          : ((pit && pit.amount != null && isFinite(Number(pit.amount)))
            ? rrFmtNum(rrSnapToBase(Number(pit.amount), rrNum(rrAmountOf(it, basePortion)), it.unit))
            : null);
        const keep = !overwrite && curVal !== '';
        const changed = proposed != null && !keep && proposed !== curVal;
        seen(proposed);
        row.cells[t.key] = { current: curVal, proposed, changed, keep };
        if (changed) {
          any = true;
          const ps = plan.steps[s.id] = plan.steps[s.id] || { items: {}, wait: {} };
          (ps.items[it.id] = ps.items[it.id] || {})[t.key] = proposed;
          mark(t.key);
        }
      });
      row.any = any;
      rows.push(row);
    });
  });
  plan.portionCount = Object.keys(touchedPortions).length;
  return { rows, plan };
};

// The models on offer, most capable first; Fable 5.1 is the default. The
// function falls through to the next one the key can use if the pick isn't
// available, and the review says which one answered — and why it fell
// back, if it did. (Fable rejects a FORCED tool call, so the function
// retries it unforced — see robot-recipe-scale; that first looked like
// "not available" and cost an afternoon.)
const RR_AI_MODELS = [
  { id: 'claude-fable-5-1', label: 'Fable 5.1', hint: 'Most intelligent — the default. About 15–30 s and the priciest per pass.' },
  { id: 'claude-opus-5',    label: 'Opus 5',    hint: 'Very careful — about 20 s.' },
  { id: 'claude-sonnet-5',  label: 'Sonnet 5',  hint: 'Fastest; fine for a first pass.' },
];
const RR_AI_DEFAULT_MODEL = 'claude-fable-5-1';
const rrAiModelLabel = (id) => { const m = RR_AI_MODELS.find(x => x.id === id); return m ? m.label : id; };

const RrAiModal = ({ recipe, portions, basePortion, steps, setupFor, onApply, onClose, onSaveRecipeContext }) => {
  const targetsAll = portions.filter(p => p.key !== basePortion);
  const [on, setOn] = useState(() => new Set(targetsAll.map(p => p.key)));
  const [model, setModel] = useState(RR_AI_DEFAULT_MODEL);

  // Context for Claude (v23.08, file upload v23.10). What every dish shares
  // — the kitchen and the machine — is a markdown file Ryan maintains: its
  // text is saved on the restaurant the moment it's picked (with the file's
  // name / size / date beside it) and rides along with every request. What
  // only this dish needs is still a typed note on the recipe, saved when
  // you ask or close.
  const [globalCtx, setGlobalCtx] = useState(null);    // the file's text; null = loading
  const [globalFile, setGlobalFile] = useState(null);  // { name, size, uploaded_at } | null
  const [showCtx, setShowCtx] = useState(false);
  const [fileBusy, setFileBusy] = useState(false);
  const [fileErr, setFileErr] = useState('');
  const fileRef = useRef(null);
  const [recipeCtx, setRecipeCtx] = useState(recipe.aiContext || '');
  useEffect(() => {
    let cancelled = false;
    window.supa.from('restaurants').select('robot_ai_context, robot_ai_context_file').eq('id', window.RESTAURANT_ID).maybeSingle()
      .then(({ data }) => {
        if (cancelled) return;
        setGlobalCtx((data && data.robot_ai_context) || '');
        const f = data && data.robot_ai_context_file;
        setGlobalFile(f && typeof f === 'object' && f.name ? f : null);
      })
      .catch(() => { if (!cancelled) setGlobalCtx(''); });
    return () => { cancelled = true; };
  }, []);
  const saveGlobal = async (text, meta) => {
    const { error } = await window.supa.from('restaurants')
      .update({ robot_ai_context: text, robot_ai_context_file: meta || {} })
      .eq('id', window.RESTAURANT_ID);
    if (error) throw error;
    setGlobalCtx(text);
    setGlobalFile(meta && meta.name ? meta : null);
  };
  const onPickFile = async (e) => {
    const f = e.target.files && e.target.files[0];
    e.target.value = '';
    if (!f) return;
    setFileErr('');
    if (f.size > RR_CTX_MAX_BYTES) { setFileErr('Keep the file under 200 KB — this one is ' + rrFmtBytes(f.size) + '.'); return; }
    setFileBusy(true);
    try {
      const text = await f.text();
      if (!text.trim()) { setFileErr('That file is empty.'); return; }
      await saveGlobal(text, { name: f.name, size: f.size, uploaded_at: new Date().toISOString() });
      setShowCtx(false);
    } catch (err) {
      setFileErr('Couldn’t save the file: ' + ((err && err.message) || err));
    } finally {
      setFileBusy(false);
    }
  };
  const removeGlobal = async () => {
    setFileErr('');
    setFileBusy(true);
    try { await saveGlobal('', {}); setShowCtx(false); }
    catch (err) { setFileErr('Couldn’t remove it: ' + ((err && err.message) || err)); }
    finally { setFileBusy(false); }
  };
  const persistContext = async () => {
    const rc = recipeCtx.trim();
    if (rc !== (recipe.aiContext || '').trim()) onSaveRecipeContext(rc);
    return { global: globalCtx || '', recipe: rc };
  };
  const closeAndSave = async () => { try { await persistContext(); } catch (_) { /* keep closing */ } onClose(); };
  const [withSetup, setWithSetup] = useState(true);
  const [withWaits, setWithWaits] = useState(true);
  const [overwrite, setOverwrite] = useState(true);
  const [phase, setPhase] = useState('ask');   // ask | busy | review | error
  const [error, setError] = useState('');
  const [result, setResult] = useState(null);  // { model, summary, rows, plan, targets }

  const toggle = (k) => setOn(prev => { const n = new Set(prev); if (n.has(k)) n.delete(k); else n.add(k); return n; });
  const targets = targetsAll.filter(p => on.has(p.key));

  const ask = async () => {
    if (!targets.length) return;
    setPhase('busy'); setError('');
    try {
      const context = await persistContext();
      // Baseline only (v23.09): Claude scales from 1x every time. The other
      // portions' numbers stay out of the request so a previous pass can't
      // anchor this one — and the request is a quarter of the size.
      const onlyBase = (m) => (m && m[basePortion] != null && m[basePortion] !== '') ? { [basePortion]: m[basePortion] } : {};
      const setup = {};
      const baseRow = setupFor(basePortion);
      if (baseRow) setup[basePortion] = { heating_mode: baseRow.heatingMode || null, temp_c: rrSetupNum(baseRow.tempC), position_deg: rrSetupNum(baseRow.positionDeg), speed: rrSetupNum(baseRow.speed) };
      const body = {
        recipe: { name: recipe.name || '', category: recipe.category || '', notes: recipe.notes || '' },
        base: basePortion,
        targets: targets.map(p => ({ key: p.key, mult: p.mult })),
        setup,
        steps: steps.map((s, i) => ({
          id: s.id, index: i + 1, kind: s.kind, note: s.note || '', linear: !!s.linear, wait: onlyBase(s.wait || {}),
          items: (s.items || []).map(it => ({ id: it.id, name: it.name, unit: it.unit, amounts: onlyBase(it.amounts || {}) })),
        })),
        options: { setup: withSetup, waits: withWaits },
        model,
        context,
      };
      const { data, error: err } = await window.supa.functions.invoke('robot-recipe-scale', { body });
      if (err) {
        // A non-2xx answer carries the function's own message in the body.
        let msg = err.message || 'Could not reach Claude.';
        try { const jb = await err.context.json(); if (jb && jb.error) msg = jb.error; } catch (_) { /* keep msg */ }
        throw new Error(msg);
      }
      if (!data || !data.ok || !data.proposal) throw new Error((data && data.error) || 'Claude returned nothing usable.');
      console.info('robot-recipe-scale proposal (' + data.model + ')', data);
      const built = rrAiPlan(data.proposal, { steps, targets, basePortion, setupFor, withSetup, withWaits, overwrite });
      if (built.plan.proposedCells === 0) {
        throw new Error(data.truncated
          ? 'Claude’s answer was cut off before the numbers arrived — try again, or pick a faster model.'
          : 'Claude’s answer came back without numbers for these portions — try again, or pick another model.');
      }
      const norm = rrAiNormalize(data.proposal);
      setResult({
        model: data.model, fellBack: !!data.fell_back, requested: data.requested || model, fallbackError: data.fallback_error || '',
        truncated: !!data.truncated,
        summary: norm.summary, rows: built.rows, plan: built.plan, targets,
      });
      setPhase('review');
    } catch (e) {
      console.error('robot-recipe-scale failed', e);
      setError((e && e.message) || 'Could not reach Claude.');
      setPhase('error');
    }
  };

  const checkbox = (checked, onChange, text, disabled) => (
    <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1 }}>
      <input type="checkbox" checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)} /> {text}
    </label>
  );

  const busy = phase === 'busy';
  const title = phase === 'review' ? 'Claude’s proposal' : 'Scale with Claude';
  const footer = phase === 'review' ? (
    <>
      <div style={{ marginRight: 'auto', fontSize: 12, color: 'var(--fg-3)', alignSelf: 'center' }}>
        {result.plan.changes === 0
          ? 'Nothing to change — every number Claude proposed is already in the grid' + (overwrite ? '.' : ', or sits in a cell you already filled.')
          : result.plan.changes + ' change' + (result.plan.changes === 1 ? '' : 's') + ' across ' + result.plan.portionCount + ' portion' + (result.plan.portionCount === 1 ? '' : 's') + '. ⌘Z takes the whole pass back.'}
      </div>
      <PBtn variant="ghost" onClick={() => setPhase('ask')}>Back</PBtn>
      <PBtn variant="primary" disabled={result.plan.changes === 0} onClick={() => onApply(result.plan)}>Apply</PBtn>
    </>
  ) : (
    <>
      <PBtn variant="ghost" onClick={closeAndSave} disabled={busy}>Cancel</PBtn>
      <PBtn variant="primary" disabled={busy || targets.length === 0} onClick={ask}>{phase === 'error' ? 'Try again' : 'Ask Claude'}</PBtn>
    </>
  );

  return (
    <PModal open onClose={busy ? () => {} : (phase === 'review' ? onClose : closeAndSave)} title={title} width={phase === 'review' ? 900 : 560} footer={footer}>
      {phase !== 'review' && (
        <div style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 16 }}>
          <div style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5 }}>
            Claude reads the <strong style={{ color: 'var(--fg-1)' }}>{basePortion}</strong> program only — every ingredient, every wait, the machine setup — and proposes the numbers for the portions you pick, with cooking judgement rather than a flat multiplier: oil coats the wok and grows well under linear, seasoning tracks the servings, water evaporates proportionally less in a big batch, a manual add gets a little more time. Numbers already in the other portions are not shown to it, so every pass starts fresh from {basePortion}. You see every number and the reason before anything is written.
          </div>

          <PField label="Portions to propose" hint={'Everything is measured from ' + basePortion + '. Untick a portion you have already tuned by hand.'}>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              {targetsAll.map(p => {
                const isOn = on.has(p.key);
                return (
                  <button key={p.key} onClick={() => toggle(p.key)} disabled={busy} style={{
                    padding: '6px 12px', borderRadius: 999, fontSize: 12.5, fontWeight: 500,
                    border: '1px solid ' + (isOn ? 'var(--fg-1)' : 'var(--border-1)'),
                    background: isOn ? 'var(--fg-1)' : 'var(--bg-surface)',
                    color: isOn ? '#fff' : 'var(--fg-2)', cursor: 'pointer',
                  }}>{p.label}{p.sub ? ' ' + p.sub : ''}</button>
                );
              })}
            </div>
          </PField>

          <PField label="Model" hint={(RR_AI_MODELS.find(m => m.id === model) || RR_AI_MODELS[0]).hint}>
            <div style={{ display: 'flex', gap: 6 }}>
              {RR_AI_MODELS.map(m => {
                const isOn = model === m.id;
                return (
                  <button key={m.id} onClick={() => setModel(m.id)} disabled={busy} style={{
                    flex: 1, padding: '8px 12px', borderRadius: 8, fontSize: 12.5,
                    fontWeight: isOn ? 600 : 500,
                    border: '1px solid ' + (isOn ? 'var(--fg-1)' : 'var(--border-1)'),
                    background: isOn ? 'var(--fg-1)' : 'var(--bg-surface)',
                    color: isOn ? '#fff' : 'var(--fg-2)', cursor: 'pointer',
                  }}>{m.label}</button>
                );
              })}
            </div>
          </PField>

          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
            {checkbox(withWaits, setWithWaits, 'Propose the waits too', busy)}
            {checkbox(withSetup, setWithSetup, 'Propose the machine setup too (heating mode, temperature, position, speed)', busy)}
            {checkbox(overwrite, setOverwrite, 'Replace numbers that are already there', busy)}
            {!overwrite && <div style={{ fontSize: 11.5, color: 'var(--fg-3)', paddingLeft: 22, marginTop: -3 }}>Off: only blank cells get Claude’s number; anything you typed stays.</div>}
          </div>

          <PField label="Context for Claude" hint="Facts the general rules can’t know. Claude treats these as true and lets them outrank its own assumptions. The file is saved the moment you pick it; the dish note when you ask or close.">
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
              <div>
                <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>Every dish — the kitchen and the machine</div>
                <input ref={fileRef} type="file" accept=".md,.markdown,.txt,text/markdown,text/plain" style={{ display: 'none' }} onChange={onPickFile} />
                {globalCtx == null ? (
                  <div className="rr-ctxbox"><div className="rr-ctxrow" style={{ color: 'var(--fg-3)', fontSize: 12.5 }}>Loading…</div></div>
                ) : globalCtx ? (
                  <div className="rr-ctxbox">
                    <div className="rr-ctxrow">
                      <PIcon name="ri-markdown-line" size={16} color="var(--fg-2)" />
                      <div style={{ flex: 1, minWidth: 0 }}>
                        <div className="rr-ctxname">{globalFile ? globalFile.name : 'Typed note (no file yet)'}</div>
                        <div className="rr-ctxmeta">
                          {rrFmtBytes(globalFile && globalFile.size ? globalFile.size : globalCtx.length)} · {globalCtx.split('\n').length} line{globalCtx.split('\n').length === 1 ? '' : 's'}
                          {globalFile && globalFile.uploaded_at ? ' · uploaded ' + rrFmtDay(globalFile.uploaded_at) : ''}
                        </div>
                      </div>
                      <button className="rr-ctxbtn" onClick={() => setShowCtx(v => !v)}>{showCtx ? 'Hide' : 'Show'}</button>
                      <button className="rr-ctxbtn" disabled={busy || fileBusy} onClick={() => fileRef.current && fileRef.current.click()}>{fileBusy ? 'Saving…' : 'Replace'}</button>
                      <button className="rr-ctxbtn is-danger" disabled={busy || fileBusy} onClick={removeGlobal}>Remove</button>
                    </div>
                    {showCtx && <pre className="rr-ctxpre">{globalCtx}</pre>}
                  </div>
                ) : (
                  <button className="rr-ctxdrop" disabled={busy || fileBusy} onClick={() => fileRef.current && fileRef.current.click()}>
                    <PIcon name="ri-upload-2-line" size={16} />
                    {fileBusy ? 'Saving…' : 'Upload a .md file'}
                    <span>Markdown or plain text, up to 200 KB. Its text goes with every Scale with Claude request, for every dish.</span>
                  </button>
                )}
                {fileErr && <div style={{ fontSize: 11.5, color: 'var(--danger)', marginTop: 5 }}>{fileErr}</div>}
              </div>
              <div>
                <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>This dish — {recipe.name || 'untitled'}</div>
                <textarea
                  className="portal-input"
                  {...RR_NO_PM}
                  rows={2}
                  value={recipeCtx}
                  disabled={busy}
                  onChange={e => setRecipeCtx(e.target.value)}
                  placeholder="e.g. The chicken is pre-fried, so the wait after it is only to coat it in sauce."
                  style={{ height: 'auto', padding: 10, resize: 'vertical', fontSize: 12.5, lineHeight: 1.45 }}
                />
              </div>
            </div>
          </PField>

          {busy && (
            <div className="rr-ai-busy">
              <span className="rr-ai-dot" />
              {(RR_AI_MODELS.find(m => m.id === model) || RR_AI_MODELS[0]).label} is reading the {basePortion} program and working out {targets.length} portion{targets.length === 1 ? '' : 's'}… this takes a little while.
            </div>
          )}
          {phase === 'error' && (
            <div style={{ fontSize: 12.5, color: 'var(--danger)', background: 'var(--danger-bg)', borderRadius: 8, padding: '9px 11px', lineHeight: 1.45 }}>
              Couldn’t get a proposal: {error}
            </div>
          )}
        </div>
      )}

      {phase === 'review' && (
        <div style={{ padding: '14px 18px 18px', display: 'flex', flexDirection: 'column', gap: 12 }}>
          {result.truncated && (
            <div style={{ fontSize: 12.5, color: 'var(--danger)', background: 'var(--danger-bg)', borderRadius: 8, padding: '9px 11px', lineHeight: 1.45 }}>
              Claude’s answer was cut off before it finished, so some cells may be missing below. Apply what is here, or go back and ask again.
            </div>
          )}
          {result.summary && (
            <div style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5, background: 'var(--bg-sunken)', borderRadius: 8, padding: '9px 11px' }}>
              {result.summary}
              <span style={{ color: 'var(--fg-3)' }}> — {rrAiModelLabel(result.model)}{result.fellBack ? ' · ' + rrAiModelLabel(result.requested) + ' didn’t answer' + (result.fallbackError ? ' (' + result.fallbackError + ')' : '') + ', so this one did' : ''}</span>
            </div>
          )}
          <div style={{ border: '1px solid var(--border-2)', borderRadius: 10, overflow: 'auto' }}>
            <table className="rr-ai">
              <thead>
                <tr>
                  <th>Step</th>
                  <th className="rr-ai-num">{basePortion}</th>
                  {result.targets.map(t => <th key={t.key} className="rr-ai-num">{t.label}</th>)}
                  <th>Why</th>
                </tr>
              </thead>
              <tbody>
                {result.rows.map((row, ri) => {
                  const prev = ri > 0 ? result.rows[ri - 1] : null;
                  const sec = row.section === 'setup'
                    ? (!prev || prev.section !== 'setup' ? 'Setup' : null)
                    : (!prev || prev.section !== 'step' ? 'Steps' : null);
                  const rule = row.why && RR_AI_RULES[row.why.rule] ? RR_AI_RULES[row.why.rule] : null;
                  return (
                    <React.Fragment key={row.key}>
                      {sec && <tr className="rr-ai-sec"><td colSpan={3 + result.targets.length}>{sec}</td></tr>}
                      <tr style={{ opacity: row.any ? 1 : 0.55 }}>
                        <td>
                          <div className="rr-ai-label">
                            {row.section === 'step' && <span className="rr-tag" style={{ marginLeft: 0, marginRight: 6 }}>{row.isWait ? 'wait' : row.step + ' · ' + row.kind}</span>}
                            {row.isWait ? <span style={{ color: 'var(--fg-3)' }}>wait</span> : row.label}
                          </div>
                        </td>
                        <td className="rr-ai-num rr-ai-same">{row.base === '' ? '—' : row.base + (row.unit ? ' ' + row.unit : '')}</td>
                        {result.targets.map(t => {
                          const c = row.cells[t.key] || {};
                          const show = c.proposed == null ? (c.current === '' ? '—' : c.current) : c.proposed;
                          return (
                            <td key={t.key} className={'rr-ai-num' + (c.changed ? '' : ' rr-ai-same')}>
                              {show}{row.unit && show !== '—' ? ' ' + row.unit : ''}
                              {c.changed && c.current !== '' && <span className="rr-ai-was">was {c.current}</span>}
                              {!c.changed && c.keep && c.proposed != null && c.proposed !== c.current && <span className="rr-ai-was">kept · Claude: {c.proposed}</span>}
                            </td>
                          );
                        })}
                        <td className="rr-ai-why">
                          {rule && <span className={'rr-ai-rule ' + rule.cls}>{rule.label}</span>}
                          {row.why ? row.why.text : (row.section === 'setup' ? '' : '')}
                        </td>
                      </tr>
                    </React.Fragment>
                  );
                })}
              </tbody>
            </table>
          </div>
        </div>
      )}
    </PModal>
  );
};

// ------------------------------------------------------------------
// Fill-from-baseline modal
// ------------------------------------------------------------------
// The nominal multiplier is only a starting point — the factors are
// editable per portion precisely because real recipes don't scale
// straight (a 2x portion might want ×1.75 of the sauce and ×1.2 on the
// waits). Waits default to ×1, which copies every wait across exactly:
// that is the useful starting point to tune from, and it means a fill
// never invents a cook duration nobody chose.
const RrScaleModal = ({ basePortion, portions, onApply, onClose }) => {
  const targets = portions.filter(p => p.key !== basePortion);
  const [rows, setRows] = useState(() => targets.map(p => ({
    key: p.key, on: true, amt: String(p.mult), time: '1',
  })));
  const [amounts, setAmounts] = useState(true);
  const [times, setTimes] = useState(true);
  const [setup, setSetup] = useState(true);
  const [overwrite, setOverwrite] = useState(false);

  const patch = (key, p) => setRows(prev => prev.map(r => r.key === key ? Object.assign({}, r, p) : r));

  const apply = () => {
    const plan = {
      targets: rows.filter(r => r.on).map(r => ({
        key: r.key,
        amt: parseFloat(r.amt) || 0,
        time: parseFloat(r.time) || 0,
      })),
      amounts, times, setup, overwrite,
    };
    if (!plan.targets.length || (!amounts && !times && !setup)) { onClose(); return; }
    onApply(plan);
  };

  const numCell = (val, on, onChangeVal) => (
    <input
      className="portal-input"
      {...RR_NO_PM}
      value={val}
      disabled={!on}
      onChange={e => onChangeVal(e.target.value)}
      style={{ width: 72, height: 30, textAlign: 'right', fontFamily: 'var(--font-num)', opacity: on ? 1 : 0.4 }}
    />
  );

  return (
    <PModal open onClose={onClose} title={'Fill from ' + basePortion} width={520} footer={
      <>
        <PBtn variant="ghost" onClick={onClose}>Cancel</PBtn>
        <PBtn variant="primary" onClick={apply}>Fill</PBtn>
      </>
    }>
      <div style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 16 }}>
        <div style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5 }}>
          Copies the {basePortion} column into the other portions, multiplied by the factors below. It’s a starting point — the numbers that shouldn’t scale straight are exactly what you’ll adjust afterwards, and the multiples in the grid show you where they landed.
        </div>
        <div style={{ fontSize: 12, color: 'var(--fg-3)', lineHeight: 1.5, background: 'var(--bg-sunken)', borderRadius: 8, padding: '9px 11px' }}>
          Only amounts in <strong style={{ color: 'var(--fg-2)' }}>{RR_SCALABLE_UNITS.join(' · ')}</strong> get multiplied. Anything in °C or kW, custom units, unitless items — and every setup field — is copied across unchanged, because a bigger batch doesn’t want a hotter wok.
        </div>

        <div>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 88px 88px', gap: 8, alignItems: 'center', marginBottom: 6 }}>
            <span style={{ fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--fg-3)', fontWeight: 600 }}>Portion</span>
            <span style={{ fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--fg-3)', fontWeight: 600, textAlign: 'right' }}>Amount ×</span>
            <span style={{ fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--fg-3)', fontWeight: 600, textAlign: 'right' }}>Wait ×</span>
          </div>
          {rows.map(r => (
            <div key={r.key} style={{ display: 'grid', gridTemplateColumns: '1fr 88px 88px', gap: 8, alignItems: 'center', padding: '5px 0' }}>
              <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13.5, cursor: 'pointer' }}>
                <input type="checkbox" checked={r.on} onChange={e => patch(r.key, { on: e.target.checked })} />
                {r.key}
              </label>
              <div style={{ display: 'flex', justifyContent: 'flex-end' }}>{numCell(r.amt, r.on && amounts, v => patch(r.key, { amt: v }))}</div>
              <div style={{ display: 'flex', justifyContent: 'flex-end' }}>{numCell(r.time, r.on && times, v => patch(r.key, { time: v }))}</div>
            </div>
          ))}
        </div>

        <div style={{ display: 'flex', flexDirection: 'column', gap: 8, borderTop: '1px solid var(--border-2)', paddingTop: 14 }}>
          <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
            <input type="checkbox" checked={amounts} onChange={e => setAmounts(e.target.checked)} /> Fill amounts
          </label>
          <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
            <input type="checkbox" checked={times} onChange={e => setTimes(e.target.checked)} /> Fill waits
          </label>
          {times && <div style={{ fontSize: 11.5, color: 'var(--fg-3)', paddingLeft: 22, marginTop: -3 }}>At ×1 every wait copies across unchanged, ready to tune per portion.</div>}
          <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
            <input type="checkbox" checked={setup} onChange={e => setSetup(e.target.checked)} /> Fill setup (copied as-is)
          </label>
          <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', marginTop: 4 }}>
            <input type="checkbox" checked={overwrite} onChange={e => setOverwrite(e.target.checked)} />
            Overwrite numbers that are already there
          </label>
          {!overwrite && <div style={{ fontSize: 11.5, color: 'var(--fg-3)', paddingLeft: 22 }}>Off by default — only blanks get filled, so tuned numbers survive. ⌘Z undoes either way.</div>}
        </div>
      </div>
    </PModal>
  );
};

// ------------------------------------------------------------------
// Dish settings / new-dish modal
// ------------------------------------------------------------------
const RrMetaModal = ({ title, initial, onSave, onClose }) => {
  const [name, setName] = useState(initial.name || '');
  const [category, setCategory] = useState(initial.category || '');
  const [notes, setNotes] = useState(initial.notes || '');
  const [portions, setPortions] = useState(() => (initial.portions && initial.portions.length ? initial.portions.slice() : RR_ALL_PORTION_KEYS.slice()));

  const togglePortion = (key) => setPortions(prev =>
    prev.indexOf(key) >= 0 ? prev.filter(k => k !== key) : RR_ALL_PORTION_KEYS.filter(k => prev.indexOf(k) >= 0 || k === key));

  return (
    <PModal open onClose={onClose} title={title} width={480} footer={
      <>
        <PBtn variant="ghost" onClick={onClose}>Cancel</PBtn>
        <PBtn variant="primary" disabled={!name.trim() || portions.length === 0}
          onClick={() => onSave({ name: name.trim(), category: category.trim(), notes: notes.trim(), portions })}>
          Save
        </PBtn>
      </>
    }>
      <div style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 14 }}>
        <PField label="Dish">
          <input className="portal-input" {...RR_NO_PM} autoFocus value={name} onChange={e => setName(e.target.value)} placeholder="e.g. T Sauce Chicken" />
        </PField>
        <PField label="Category" hint="Optional — groups the list.">
          <input className="portal-input" {...RR_NO_PM} value={category} onChange={e => setCategory(e.target.value)} placeholder="e.g. Bento entree" />
        </PField>
        <PField label="Portions" hint="Untick any this dish doesn’t run. Numbers in a hidden portion are kept, not deleted.">
          <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
            {RR_PORTIONS.map(p => {
              const on = portions.indexOf(p.key) >= 0;
              return (
                <button key={p.key} onClick={() => togglePortion(p.key)} style={{
                  padding: '6px 12px', borderRadius: 999, fontSize: 12.5, fontWeight: 500,
                  border: '1px solid ' + (on ? 'var(--fg-1)' : 'var(--border-1)'),
                  background: on ? 'var(--fg-1)' : 'var(--bg-surface)',
                  color: on ? '#fff' : 'var(--fg-2)', cursor: 'pointer',
                }}>{p.label}{p.sub ? ' ' + p.sub : ''}</button>
              );
            })}
          </div>
        </PField>
        <PField label="Notes" hint="Optional — anything the numbers don’t say.">
          <textarea className="portal-input" {...RR_NO_PM} value={notes} onChange={e => setNotes(e.target.value)} rows={3} style={{ height: 'auto', padding: 10, resize: 'vertical' }} />
        </PField>
      </div>
    </PModal>
  );
};

window.RobotRecipes = RobotRecipes;
