/* IPS Builder — wired to the Flask backend (no build step; in-browser Babel).
 * React + ReactDOM are loaded as UMD globals by index.html. */
const { useState, useEffect, useMemo, useRef } = React;

// ── Design tokens (matches savingsphase.ca exactly) ──────────────────────────
const T = {
  bg:         "#f5f0e8",
  surface:    "#ffffff",
  card:       "#faf9f7",
  border:     "#d4cfc5",
  borderSoft: "#e3ddd1",
  muted:      "#647071",
  subtle:     "#a0aab4",
  text:       "#1a1a1a",
  soft:       "#5a6a72",
  gold:       "#f5a623",
  goldLight:  "rgba(245,166,35,.16)",
};

// ── Data ─────────────────────────────────────────────────────────────────────

const GOAL_TYPES = [
  { id: "retirement",  label: "Retirement",        icon: "🪺", desc: "Long-term financial independence" },
  { id: "home",        label: "Home purchase",      icon: "🏠", desc: "Down payment or mortgage paydown" },
  { id: "education",   label: "Child's education",  icon: "🎓", desc: "RESP or post-secondary savings" },
  { id: "other",       label: "Other goal",         icon: "◎",  desc: "Emergency fund, sabbatical, etc." },
];

const ALL_ACCOUNTS = [
  { id: "tfsa",    label: "TFSA",    desc: "Tax-Free Savings Account",                           goals: ["retirement","home","other"] },
  { id: "rrsp",    label: "RRSP",    desc: "RRSP / Spousal RRSP / LIRA / RRIF",                  goals: ["retirement"] },
  { id: "taxable", label: "Taxable", desc: "Non-registered (corporate or personal)",              goals: ["retirement","home","other"] },
  { id: "resp",    label: "RESP",    desc: "Registered Education Savings Plan",                   goals: ["education"] },
  { id: "fhsa",    label: "FHSA",    desc: "First Home Savings Account",                          goals: ["home"] },
];

// Offline fallback only — the server's /api/allocation is the live source.
const CMA = [
  { eq:0,   ret:3.8, vol:3.2,  label:"All bonds",    dd8:"5-10%" },
  { eq:20,  ret:4.4, vol:4.1,  label:"Conservative", dd8:"10-16%" },
  { eq:40,  ret:5.0, vol:5.8,  label:"Moderate",     dd8:"14-20%" },
  { eq:60,  ret:5.7, vol:8.8,  label:"Balanced",     dd8:"18-26%" },
  { eq:80,  ret:6.3, vol:12.1, label:"Growth",       dd8:"26-36%" },
  { eq:100, ret:6.9, vol:15.4, label:"All equity",   dd8:"35-50%" },
];

const BENCHMARK_NAMES = {
  "VAB":  "VAB – Vanguard Canadian Aggregate Bond Index ETF",
  "ESGB": "ESGB – BMO ESG Corporate Bond Index ETF",
  "VCIP": "VCIP – Vanguard Conservative Income ETF Portfolio",
  "TCON": "TCON – TD One-Click Conservative ETF Portfolio",
  "ZCON": "ZCON – BMO Conservative ETF",
  "ZESG": "ZESG – BMO Balanced ESG ETF",
  "VBAL": "VBAL – Vanguard Balanced ETF Portfolio",
  "VGRO": "VGRO – Vanguard Growth ETF Portfolio",
  "VEQT": "VEQT – Vanguard All-Equity ETF Portfolio",
};

const CADENCES = [
  { id:"monthly",    label:"Monthly",      sub:"Track and document; adjust only when justified" },
  { id:"quarterly",  label:"Quarterly",    sub:"Standard for most self-directed investors" },
  { id:"semiannual", label:"Semi-annually",sub:"Appropriate for simple single-ETF portfolios" },
];

const TRIGGERS = [
  { id:"3",  label:"±3%",  sub:"Tighter — suits larger or more complex portfolios" },
  { id:"5",  label:"±5%",  sub:"Standard" },
  { id:"10", label:"±10%", sub:"Looser — suits simpler or smaller portfolios" },
];

const GOAL_YEAR_LABEL   = { home: "Target purchase year", education: "Start of school year", _: "Target year" };
const GOAL_AMOUNT_LABEL = { home: "Down payment target ($)", education: "Education savings target ($)", other: "Savings target ($)", _: "Target income/year (after tax, today's $)" };

function goalYearLabel(id)   { return GOAL_YEAR_LABEL[id]   || GOAL_YEAR_LABEL._; }
function goalAmountLabel(id) { return GOAL_AMOUNT_LABEL[id] || GOAL_AMOUNT_LABEL._; }

const OTHER_GOAL_PRESETS = [
  { id: "Emergency Fund", icon: "shield-bolt" },
  { id: "Car Fund",       icon: "car"         },
  { id: "Wedding Fund",   icon: "diamond"     },
];

const EMPTY_FORM = {
  name: "",
  goalName: "",
  goalPreset: "",
  retireYear: "", retireAge: "", targetIncome: "",
  cppYear: "", cppAnnual: "", cppStartAge: "",
  oasYear: "", oasAnnual: "", oasStartAge: "",
  // Employer defined-benefit pension / purchased annuity. Defaults to $0 —
  // most users have none, and $0 is what keeps the projection identical to a
  // plan without one. Unlike CPP/OAS there is no reference table behind it.
  pensionAnnual: "0", pensionStartAge: "65",
  estateTarget: "0",
  lifestyleChoices: [], lifestyle: "",
  accounts: [],
  accountBalances: {},
  annualSavings: "",
  climateOn: true,
  equity: 60,
  equityTouched: false,
  // "Set my allocation based on existing Portfolio" (plan-tab.md § same name) —
  // a one-time snapshot from the goal's real Save-tab holdings, not a live
  // link. singleFundOverride is only ever set when those holdings are a
  // single registry-matched all-in-one fund; allocFromPortfolio just tracks
  // whether the current equity value came from the sync button (for the
  // confirmation-vs-button UI state) and is cleared the moment the slider is
  // dragged manually.
  singleFundOverride: "",
  singleFundMer: null,
  singleFundYield: null,
  allocFromPortfolio: false,
  benchmark: "ZESG",
  cadence: "quarterly",
  trigger: "5",
  additionalInfo: "",
};

// Applies the exact default-merge + equityTouched coercion that enterGoal/
// applyDraft use when hydrating a raw stored form (sv.form, or a localStorage
// draft's form) into wizard state. Shared so the "has this goal's wizard form
// changed since it was saved" check (formDirtyCheck, below) compares against
// the SAME baseline the wizard itself renders on a fresh open — never the raw
// stored JSON directly. Comparing against raw sv.form would false-positive
// "dirty" on every plan saved before some field existed (estateTarget,
// singleFundOverride, ...) the instant it's reopened, since EMPTY_FORM fills
// those keys in with defaults that the stored JSON simply doesn't have.
// ── Untrusted text: the client half of the sanitizer ───────────────────
// Mirror of app._clean_text. The SERVER is the authority — it re-cleans every
// field on the way in, because a client check is a UX affordance and not a
// boundary. This copy exists for two reasons that the server copy cannot cover:
//
//   1. Feedback. A cap the field enforces as you type is visible; one applied
//      silently at save time is not.
//   2. formDirtyCheck parity, which is load-bearing. That check compares the live
//      wizard form against hydrateForm(sv.form). If the server normalizes
//      anything the client does not, the persisted value differs from what is on
//      screen and the plan reads as PERMANENTLY UNSAVED right after being saved.
//      So any transform added on one side must be added to the other, and
//      dev/test_goal_name.py diffs the two implementations to enforce that.
//
// TEXT_LIMITS mirrors app._TEXT_LIMITS. The whitespace classes are spelled out
// rather than written \s because JS and Python disagree about \s in BOTH
// directions (JS matches U+FEFF; Python matches U+001C-001F and U+0085) — a
// leading BOM once became a leading SPACE in the browser while the server
// dropped it, so one input produced two different labels.
const TEXT_LIMITS = { goalName: 25, lifestyle: 500, additionalInfo: 2000 };
const GOAL_NAME_MAX = TEXT_LIMITS.goalName;
const WS_ALL    = /[\t\n\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/g;
const WS_INLINE = /[\t\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/g;
const CTRL_CHARS = /[\p{Cc}\p{Cf}\u200b\ufeff]/gu;

// A non-string yields "" rather than being coerced: String({}) would store the
// literal text "[object Object]" as a label. Slicing is by CODE POINT
// (Array.from), so a cut can never sever a surrogate pair into a lone half.
// Never trim on keystroke — it would eat the space in "Car Fund" the moment you
// typed it. Call sites that need a trimmed value do their own .trim().
function cleanText(v, limit, multiline) {
  if (typeof v !== "string") return "";
  let s;
  if (multiline) {
    s = v.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
         .replace(WS_INLINE, " ")
         .replace(/ *\n */g, "\n")
         .replace(/\n{3,}/g, "\n\n")
         .replace(/[\p{Cc}\p{Cf}\u200b\ufeff]/gu, (c) => (c === "\n" ? c : ""));
  } else {
    s = v.replace(WS_ALL, " ").replace(CTRL_CHARS, "");
  }
  return Array.from(s).slice(0, limit).join("");
}

// ── Custom "other" goal name ─────────────────────────────────────
// `goalName` is the only free-text field in the wizard that becomes a LABEL, and
// it is rendered on six surfaces that each have their own width budget: the hub
// card, the goals timeline node, the step-bar kicker, the step-1 plan title, the
// Save-tab ribbon card, and the clear-plan confirmation. 25 code points is long
// enough for "Kitchen renovation" and short enough that a hub card's one-line
// name never has to wrap. An unbounded name grew one card to nine lines tall and
// pushed its own projection chart out of view — that is what the cap is for.
// Capping alone does not make it FIT, though; see plan-tab.md for the CSS half.
//
// Applied on every keystroke AND in hydrateForm, so a plan saved before the cap
// existed is clamped the moment it is reopened instead of staying oversized
// forever. Safe for formDirtyCheck because both sides of that comparison run
// through hydrateForm and cleanText is idempotent.
// Mirrored server-side by app._clean_goal_name — see plan-tab.md § "Custom goal
// name (the 'other' goal)".
function cleanGoalName(v) { return cleanText(v, GOAL_NAME_MAX, false); }

function hydrateForm(rawForm, userName) {
  const rf = rawForm || {};
  return { ...EMPTY_FORM, name: userName || "", ...rf,
    goalName: cleanGoalName(rf.goalName),
    lifestyle: cleanText(rf.lifestyle, TEXT_LIMITS.lifestyle, true),
    additionalInfo: cleanText(rf.additionalInfo, TEXT_LIMITS.additionalInfo, true),
    equityTouched: ("equityTouched" in rf) ? rf.equityTouched : true };
}

// ── Duration-matched allocation defaults (methodology.md § 13) ────────────────
// Every goal is a dated liability; until the user touches the slider, its
// position tracks the years remaining to the goal (Roche's defined-duration
// bands: cash ≈ 0y, bonds ≈ 3–7y, equities 15y+). Educational only — the user
// can always override (the site educates, never enforces), and reopening a
// stored plan never moves a chosen slider (`equityTouched` hydrates to true
// for saved forms/drafts that pre-date the flag).
function planHorizonYears(form) {
  const yr = parseInt(form.retireYear);
  if (!yr || isNaN(yr)) return null;
  const h = yr - new Date().getFullYear();
  return h > 0 ? h : null;
}
function suggestedEquityForHorizon(h) {
  if (h == null) return null;
  if (h <= 2)  return 10;
  if (h <= 4)  return 25;
  if (h <= 7)  return 40;
  if (h <= 10) return 55;
  if (h <= 14) return 70;
  if (h <= 19) return 80;
  return 90;
}

const LIFESTYLE_OPTIONS = [
  { id: "community",   icon: "users",    label: "Deepen community ties",      desc: "Volunteer, mentor, and show up for the people and places around you" },
  { id: "family",      icon: "heart",    label: "Be there for family",        desc: "More time with kids, grandkids, partners and family" },
  { id: "nature",      icon: "trees",    label: "Live closer to nature",      desc: "Time outdoors, tending land, hiking, paddling — a life shaped by seasons, not schedules" },
  { id: "intentional", icon: "leaf",     label: "Live more intentionally",    desc: "Less consumption, more meaning — a smaller footprint and a richer everyday life" },
  { id: "travel",      icon: "compass",  label: "Explore Sustainably",        desc: "Experience the beauty of the planet by immersing yourself in local traditions, supporting conservation, and travelling at a rhythm that respects the earth." },
  { id: "contribute",  icon: "briefcase",label: "Contribute professionally",  desc: "Consult or start a small business you've long been passionate about — on your own terms" },
  { id: "passions",    icon: "palette",  label: "Pursue passions",            desc: "Creative work, crafts, growing food, or projects you never had time for" },
  { id: "health",      icon: "run",      label: "Invest in health",           desc: "Time and energy for movement, rest, good food, and mental wellbeing" },
  { id: "learning",    icon: "book",     label: "Keep learning",              desc: "Courses, reading, new skills — intellectual curiosity with no deadline" },
  { id: "planet",      icon: "world",    label: "Give back to the planet",    desc: "Restoration, conservation, or advocacy work — leaving things better than you found them" },
  { id: "legacy",      icon: "plant",    label: "Leave a legacy",             desc: "Build something lasting — wealth, values, or impact for the next generation" },
  { id: "security",    icon: "shield",   label: "Peace of mind",              desc: "Never worry about money — know the bills are covered no matter what" },
];

const LIFESTYLE_GROUPS = [
  {
    id: "roots", label: "Roots & place", icon: "ti-leaf",
    color: "#2d7a47", tagBg: "rgba(45,122,71,.10)", tagColor: "#1a5c35",
    items: ["nature", "intentional", "planet", "community"],
  },
  {
    id: "people", label: "People & purpose", icon: "ti-heart",
    color: "#b34050", tagBg: "rgba(179,64,80,.09)", tagColor: "#7a2030",
    items: ["family", "health", "contribute", "legacy"],
  },
  {
    id: "growth", label: "Growth & experience", icon: "ti-compass",
    color: "#5b4fa8", tagBg: "rgba(91,79,168,.09)", tagColor: "#3b2f80",
    items: ["travel", "passions", "learning", "security"],
  },
];

// ── Helpers ───────────────────────────────────────────────────────────────────
function lerp(a, b, t) { return a + (b - a) * t; }
function getCMA(eq) {
  const lo = [...CMA].reverse().find(d => d.eq <= eq) || CMA[0];
  const hi = CMA.find(d => d.eq >= eq) || CMA[CMA.length-1];
  if (lo.eq === hi.eq) return lo;
  const t = (eq - lo.eq) / (hi.eq - lo.eq);
  return { eq, ret: +lerp(lo.ret,hi.ret,t).toFixed(1), vol: +lerp(lo.vol,hi.vol,t).toFixed(1), label: hi.label, dd8: hi.dd8 };
}
function today(d) {
  return (d ? new Date(d) : new Date()).toLocaleDateString("en-CA", { year:"numeric", month:"long", day:"numeric" });
}
function fmt$(n) { return "$" + Number(n||0).toLocaleString("en-CA"); }
function wpct(w) { const v = Number(w) * 100; return (Math.abs(v % 1) < 0.05 ? v.toFixed(0) : v.toFixed(1)) + "%"; }
function pct(x, d) { if (x == null || isNaN(x)) return "—"; const f = Math.pow(10, d == null ? 1 : d); return (Math.round(Number(x)*f)/f) + "%"; }
function ts(s) { if (!s) return ""; const d = new Date(s); return d.toLocaleString("en-CA", { dateStyle:"medium", timeStyle:"short" }); }

// Plain-text default generators for the Plan document's editable blocks. Each
// mirrors the equivalent JSX paragraph/list in IPSBuilder's case-5 render
// exactly (minus <strong> formatting, which editable text loses once it can
// be freely rewritten) — used both to seed a block's editor buffer and to
// detect whether a stored edit's baseline has gone stale against the current
// wizard fields. `ctx` is assembled once per render in case 6 from locals it
// already computes (form, metrics, activeGoal, activeGoalLabel, bondPct,
// withdrawOrderList, assetLocationRows).
const DOC_BLOCK_DEFAULTS = {
  purpose1: () =>
    "This is my personal investment plan. It serves as a reference point during periods of market volatility, uncertainty, or personal financial change — a framework to consult before acting.",
  purpose2: (ctx) => {
    const chosen = ctx.form.lifestyleChoices || [];
    if (!chosen.length) return "";
    const labels = LIFESTYLE_OPTIONS.filter(o => chosen.includes(o.id)).map(o => o.label.toLowerCase());
    const joined = labels.map((l, i, arr) => (i === 0 ? l : (i === arr.length - 1 ? ", and " : ", ") + l)).join("");
    return `I'm investing toward a future where I can ${joined}.`;
  },
  goals1: (ctx) => {
    const currentYear = new Date().getFullYear();
    const retireYr = parseInt(ctx.form.retireYear);
    const horizon = ctx.form.retireYear && retireYr > currentYear ? retireYr - currentYear : null;
    const goal = `My goal is ${ctx.activeGoalLabel.toLowerCase()} by ${ctx.form.retireYear || "—"} (age ${ctx.form.retireAge || "—"})${horizon ? ` — ${horizon} years from today` : ""}.`;
    const target = ctx.activeGoal === "retirement"
      ? ` I want to generate ${fmt$(ctx.form.targetIncome)}/year in after-tax income, in today's dollars.`
      : (ctx.form.targetIncome ? ` Target: ${fmt$(ctx.form.targetIncome)}.` : "");
    return goal + target;
  },
  goals2: (ctx) =>
    `I'm aiming for roughly ${ctx.metrics.ret}%/year in growth, with a ${ctx.form.equity}/${ctx.bondPct} stocks/bonds split — a ${ctx.metrics.label} portfolio. These numbers are a guide, not a guarantee. I'll only change my plan if my life circumstances change — not because the market went up or down.`,
  climate1: () =>
    "Climate change is a real financial risk. Severe weather damages businesses and real estate. High-pollution industries are losing ground as policy and technology shift to clean energy. Governments and courts are increasingly penalizing heavy polluters.",
  climate2: (ctx) => ctx.form.climateOn
    ? "I've chosen to reduce my exposure to these risks. My portfolio will favour eco-friendly, low-carbon funds, and I'll verify that any “green” fund I hold is genuinely different from a standard market index — not just re-labelled."
    : "I've chosen not to apply a climate screen right now. I'll keep an eye on these risks at each annual review and can add a screen in the future.",
  alloc1: (ctx) =>
    `I'm investing ${ctx.form.equity}% in stocks (spread around the world) and ${ctx.bondPct}% in bonds — a ${ctx.metrics.label} portfolio. I'm expecting roughly ${ctx.metrics.ret}%/year in growth. In a really bad year (like 2008), this mix could temporarily lose around ${ctx.metrics.dd8} before recovering.`,
  alloc2: (ctx) =>
    `I'll rebalance if any part of my portfolio drifts more than ±${ctx.form.trigger}% from its target, or at my regular check-in, using a tax-smart approach: allocate new contributions first, trade inside registered accounts to avoid triggering capital gains in a taxable account, and treat taxable-account trades as a last but necessary step. I stick to low-cost, broadly diversified ETFs and avoid unnecessary trades.`,
  whereIntro: () =>
    "Each account type has different tax rules. Here's where I put different investments to keep more of what I earn:",
  withdraw: (ctx) => ctx.withdrawOrderList,
  discipline1: () =>
    "I won't make changes to my portfolio based on a scary week in the news, a hot tip, or a gut feeling. Any change has to be deliberate — with a clear reason written down.",
  discipline2: () =>
    "Before I make any change, I'll re-read this plan first.",
  measure1: (ctx) =>
    `Once a year, I'll compare my results against ${BENCHMARK_NAMES[ctx.form.benchmark] || ctx.form.benchmark}. My portfolio won't match it exactly — and that's fine. But if I'm consistently more than 1.5%/year behind it for 3 years running, I'll dig into why before making any changes.`,
  rules: (ctx) => {
    const cadenceText = ctx.form.cadence === "semiannual" ? "semi-annually" : ctx.form.cadence;
    return [
      `Check in: I'll review this plan and my portfolio ${cadenceText}.`,
      `Rebalance: I'll rebalance if anything drifts more than ±${ctx.form.trigger}% from its target, or at my regular check-in, using a tax-smart approach — new contributions first, registered-account trades before taxable ones.`,
      "Write it down: Any change I make gets a date and a reason — no undocumented moves.",
      "Stay consistent: I hold myself to the same standards whether markets are up, down, or sideways.",
    ];
  },
};
const DOC_LIST_BLOCKS = new Set(["withdraw", "rules"]);
// Asset classes the allocation engine can actually produce (allocation.py ROLE_CLASS) —
// the only options offered in the editable holdings grid's asset-class dropdown.
const DOC_HOLDING_CLASSES = ["Canadian Equity", "Developed Markets", "Emerging Markets", "Fixed Income"];

// ── Asset-class palette ───────────────────────────────────────────────────────
// Comes from the DB (asset_class_style), served by app.py as /asset-classes.js and
// loaded in index.html BEFORE this file — a classic <script src>, so the global is
// already set when this module scope runs. Edit the colours in admin.html.
//
// The literals below are a FALLBACK, used only when that script failed to load.
// They are not a second source of truth: dev/test_asset_classes.py asserts they
// still match the seed, so drift is a test failure instead of a wrong colour on
// screen. Before this table existed the palette lived in six hand-edited copies,
// and US Equity / Alternatives / Cash each ended up with two different values.
const SP_AC = (typeof window !== "undefined" && window.SP_ASSET_CLASSES) || null;
const _acPick = (key, fallback) => {
  const v = SP_AC && SP_AC[key];
  return v && Object.keys(v).length ? v : fallback;
};

const CLASS_COLORS_FALLBACK = {
  "Fixed Income":        "#2d8cff",
  "Bond":                "#2d8cff",
  "LDI":                 "#5c9ae0",
  "Preferred Shares":    "#2e9ec5",
  "Canadian Equity":     "#d35400",
  "US Equity":           "#e67e22",
  "Developed Markets":   "#249e7f",
  "Developed ex-NA":     "#249e7f",
  "Emerging Markets":    "#9e2a5b",
  "Emerging Mkts":       "#9e2a5b",
  "Multi-Asset":         "#4a6b3a",
  "Alternatives":        "#ffd700",
  "Cash":                "#887f73",
};
const CLASS_COLORS = _acPick("fill", CLASS_COLORS_FALLBACK);
function classColor(cls) { return CLASS_COLORS[cls] || "#a0aab4"; }

// Optional per-class override for the few places a class colour is small type: the
// ticker in the holdings-search dropdown and in each HoldingsRow, and the <h1> on
// asset.html. It DEFAULTS TO THE FILL — an unset override means "use the class's
// colour", not "compute a darker one". The panel offers a suggested legible step and
// reports contrast, but only an admin can adopt it. Do not reintroduce automatic
// derivation: it made a saved colour silently not the rendered colour.
const CLASS_COLORS_TEXT = _acPick("text", {});
function classTextColor(cls) {
  return CLASS_COLORS_TEXT[cls] || CLASS_COLORS[cls] || "#a0aab4";
}

// Short display labels for portfolio overview allocation table
// (asset_class_style.label — blank there means "use the class name as-is").
const AC_LABELS = _acPick("labels", {
  "Canadian Equity":   "Cdn equity",
  "Developed Markets": "Dev. equity",
  "Emerging Markets":  "Emg. equity",
  "Fixed Income":      "Bonds",
  "Preferred Shares":  "Preferred",
  "Cash":              "Cash / HISA",
  "Alternatives":      "Alternatives",
  "Multi-Asset":       "All-in-one",
});

// Classes offered by the holdings editor's custom-holding picker — every row with
// in_picker set, in admin sort order, so the full set is always offered independent
// of whichever subset happens to be in the loaded registry snapshot.
//
// This is PRESENTATION. The classes the allocation engine can actually target are
// allocation.py's ROLE_CLASS, mirrored in DOC_HOLDING_CLASSES below — adding a class
// in admin.html makes it holdable and colourable, not something the engine allocates
// to. Those four names are behaviour and stay in code.
const ASSET_CLASS_OPTIONS = (SP_AC && SP_AC.classes && SP_AC.classes.length)
  ? SP_AC.classes
  : ["Canadian Equity", "Developed Markets", "Emerging Markets", "US Equity",
     "Fixed Income", "Preferred Shares", "Multi-Asset", "Alternatives", "Cash"];

// portfolio_holdings.custom_income_type — how a manually-entered holding's income
// is TAXED. That, not its asset class, is what app._goal_bucket_mix classifies on:
// the model prices exactly one difference, fully-taxable interest vs eligible
// dividends. A GIC is "income".
//
// The `id`s are a CHECK-constrained value set living in four places that must stay
// EQUAL — this list, app._VALID_INCOME_TYPES, pr_db.ensure_schema's DDL,
// and migrate_holding_economics.py (dev/test_input_limits.py cross-checks all
// four). The LABELS are ours to reword freely; the ids are not.
const INCOME_TYPE_OPTIONS = [
  { id: "income",   label: "Interest / fully taxable" },
  { id: "eligible", label: "Eligible Canadian dividends" },
  { id: "foreign",  label: "Foreign / equity distributions" },
  { id: "none",     label: "None — capital gains only" },
];

// Mirrors allocation.py's EQUITY_SPLIT (Canada 29% / Developed 60% / Emerging
// 11%, VEQT-proxied) — kept as an intentional duplicate, same convention as
// _yf_symbol / _CORR_BUCKETS being mirrored across app.py and allocation.py.
// Used only to decompose an all-in-one fund's equity sleeve into the app's
// real asset classes for the holdings editor's allocation-vs-plan bar.
const EQUITY_SLEEVE_SPLIT = {
  "Canadian Equity":   0.29,
  "Developed Markets": 0.60,
  "Emerging Markets":  0.11,
};

// Small descriptive risk-tier label for the holdings editor's "your allocation
// vs plan" chip (e.g. "Growth · 80% equity") — generic tiering, not tied to any
// specific fund family's naming.
function equityTierLabel(equityPct) {
  if (equityPct == null) return "";
  if (equityPct >= 90) return "All-Equity";
  if (equityPct >= 70) return "Growth";
  if (equityPct >= 50) return "Balanced Growth";
  if (equityPct >= 30) return "Balanced";
  if (equityPct >= 10) return "Conservative";
  return "Conservative Income";
}

function fmtDollar(n) {
  return "$" + Number(n || 0).toLocaleString("en-CA");
}
function fmtCompact(n) {
  if (n == null || isNaN(n)) return "—";
  const v = Number(n);
  if (v >= 1e6) { const m = v / 1e6; return "$" + (m % 1 < 0.05 ? Math.round(m) : m.toFixed(1)) + "M"; }
  if (v >= 1e3) return "$" + Math.round(v / 1e3) + "k";
  return "$" + Math.round(v);
}

function fmtDateShort(iso) {
  if (!iso) return "";
  const d = new Date(iso + "T00:00:00");
  return d.toLocaleDateString("en-CA", { year: "numeric", month: "short", day: "numeric" }).toUpperCase();
}

function SignalDot({ signal }) {
  const col = signal === "green" ? "#2d7a47" : signal === "amber" ? "#c2571a" : "#b34030";
  return (
    <span style={{display:"inline-block",width:7,height:7,borderRadius:"50%",
      background:col,marginRight:5,flexShrink:0,verticalAlign:"middle"}} />
  );
}

// ── API wrapper ────────────────────────────────────────────────────────────────
async function api(path, opts) {
  const o = Object.assign({ credentials: "include", headers: {} }, opts || {});
  if (o.body && typeof o.body !== "string") {
    o.headers["Content-Type"] = "application/json";
    o.body = JSON.stringify(o.body);
  }
  try {
    const res = await fetch(path, o);
    let data = null;
    try { data = await res.json(); } catch (e) {}
    return { ok: res.ok, status: res.status, data };
  } catch (e) {
    return { ok: false, status: 0, data: null, error: e };
  }
}

// Debounced live allocation fetch; falls back to local CMA on failure.
function useAllocation(equity, climate) {
  const [alloc, setAlloc] = useState(null);
  useEffect(() => {
    let cancel = false;
    const id = setTimeout(async () => {
      const r = await api(`/api/allocation?equity=${equity}&climate=${climate}`, { cache: "no-store" });
      if (!cancel && r.ok && r.data) setAlloc(r.data);
    }, 180);
    return () => { cancel = true; clearTimeout(id); };
  }, [equity, climate]);
  return alloc;
}

// The old single "Details" step carried three unrelated concepts (the goal
// itself, government/pension income, and the accounts funding it) and had grown
// past a screenful. Split into Goals (1) + Accounts (2); everything after it
// shifted up one. WIZARD_STEP_MIGRATION below remaps drafts saved pre-split.
const STEPS = ["Goals","Accounts","Climate","Allocation","Governance","Plan"];
// Steps 1..N-1 collect input; the last one ("Plan") is the generated document,
// which is why the step rail's meta row counts "of STEPS.length - 1".
const INPUT_STEPS = STEPS.length - 1;
// Per-step rail presentation — one Tabler glyph and (for the last node) a
// longer label than the terse STEPS name. Index-aligned with STEPS. The icon
// language is deliberately borrowed from the "what matters" lifestyle grid
// (.rp-card icons) so a step is recognisable by shape before it is read; if a
// step is ever added or reordered, this array moves with STEPS.
const STEP_RAIL = [
  { icon: "ti-target-arrow", label: "Goals" },
  { icon: "ti-wallet",       label: "Accounts" },
  { icon: "ti-leaf",         label: "Climate" },
  { icon: "ti-chart-pie",    label: "Allocation" },
  { icon: "ti-shield-check", label: "Governance" },
  { icon: "ti-file-text",    label: "Your plan" },
];

// ── Styles ────────────────────────────────────────────────────────────────────
const css = `
@import url('https://fonts.googleapis.com/css2?family=Inter+Tight:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Dancing+Script:wght@600&display=swap');
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{background:#f5f0e8;font-family:'Inter Tight',system-ui,sans-serif;-webkit-font-smoothing:antialiased;}

.sp-root{background:#f5f0e8;color:#1a1a1a;min-height:100vh;font-family:'Inter Tight',system-ui,sans-serif;font-size:14px;line-height:1.5;}

/* Sub-nav styles live in nav.css (loaded via index.html) */

/* ── Page ── */
.sp-page{max-width:720px;margin:0 auto;padding:28px 24px 80px;}

/* ── Typography ── */
.sp-eyebrow{
  font-family:'IBM Plex Mono',monospace;font-size:13px;letter-spacing:.16em;
  text-transform:uppercase;color:#647071;margin-bottom:10px;
}
.sp-h1{font-size:28px;font-weight:500;letter-spacing:-.02em;line-height:1.15;color:#1a1a1a;margin-bottom:10px;}
.sp-lead{font-size:14.5px;line-height:1.65;color:#5a6a72;margin-bottom:36px;}
.sp-lead strong{color:#1a1a1a;font-weight:600;}
.sp-section-label{
  font-family:'IBM Plex Mono',monospace;font-size:18px;font-weight:500;
  letter-spacing:.02em;font-variant:small-caps;color:#8a5709;margin-bottom:18px;
}

/* ── Fields ── */
.sp-field{margin-bottom:22px;}
.sp-label{display:block;font-size:13px;font-weight:500;color:#1a1a1a;margin-bottom:5px;}
.sp-hint{font-size:12px;color:#647071;margin-bottom:7px;line-height:1.5;}
.sp-input{
  width:100%;padding:8px 12px;
  font-family:'Inter Tight',system-ui,sans-serif;font-size:13.5px;color:#1a1a1a;
  background:#fff;border:1px solid #8b9199;border-radius:4px;outline:none;
  transition:border-color .12s;
}
.sp-input:focus{border-color:#1a4a6b;}
.sp-input.pfx{padding-left:22px;}
.sp-input-wrap{position:relative;}
.sp-input-wrap .pfx-sym{position:absolute;left:10px;top:50%;transform:translateY(-50%);font-size:13px;color:#647071;pointer-events:none;}
textarea.sp-input{resize:vertical;min-height:80px;line-height:1.6;}
select.sp-input{-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='none' stroke='%237f8c8d' stroke-width='1.4' d='M2 4l3 3 3-3'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;padding-right:28px;}

/* ── Goal cards ── */
.goal-card{
  padding:16px;border:1px solid #d4cfc5;border-radius:4px;background:#fff;
  cursor:pointer;transition:border-color .12s,background .12s,box-shadow .12s;
  display:flex;flex-direction:column;gap:4px;user-select:none;
}
/* Selection is navy-bordered, not gold: a 1px gold border is 1.8:1 on
   parchment and "selected" would be invisible. The navy carries the contrast,
   the gold tint fill carries the warmth. Same pattern on .acct-card.sel,
   .radio-opt.sel and .rp-card.selected. */
.goal-card:hover{border-color:#1a4a6b;box-shadow:0 0 0 3px rgba(26,74,107,.12);}
.goal-card.sel{border-color:#1a4a6b;background:rgba(245,166,35,.09);}
/* One line, always. The "other" goal's name is user text (capped at 25 chars by
   cleanGoalName), and 25 semibold characters still exceed what's left of a
   half-width hub card once the icon and the signal chip have taken their share —
   so it truncates rather than wrapping the card to nine lines tall. */
.goal-name{font-size:14px;font-weight:600;color:#1a1a1a;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}

/* ── Account cards ── */
.acct-list{display:flex;flex-direction:column;gap:8px;}
.acct-card{
  padding:12px 14px;border:1px solid #d4cfc5;border-radius:4px;background:#fff;
  cursor:pointer;display:flex;align-items:center;gap:12px;
  transition:border-color .12s;user-select:none;
}
.acct-card:hover{border-color:#5a6a72;}
.acct-card.sel{border-color:#1a4a6b;background:rgba(245,166,35,.09);}
.acct-check{
  width:16px;height:16px;border:1.5px solid #d4cfc5;border-radius:3px;flex-shrink:0;
  display:flex;align-items:center;justify-content:center;background:#fff;
  transition:all .12s;
}
.acct-card.sel .acct-check{background:#f5a623;border-color:#8a5709;color:#1a1a1a;}
.acct-name{font-size:13.5px;font-weight:600;color:#1a1a1a;}
.acct-desc{font-size:12px;color:#647071;margin-top:1px;}
.acct-tag{
  margin-left:auto;font-family:'IBM Plex Mono',monospace;font-size:12px;
  letter-spacing:.08em;text-transform:uppercase;color:#647071;
  background:#f5f0e8;border:1px solid #e3ddd1;border-radius:99px;
  padding:2px 8px;white-space:nowrap;
}
/* Funded goal: the account list mirrors the real portfolio instead of editing
   it (see plan-tab.md § "Accounts on a funded goal"). Selected-looking, but
   inert — no pointer, no hover, and a padlock where the checkbox would be.
   The padlock replaces the checkmark deliberately: a tick reads as "you
   ticked this, you can untick it", which is the one thing these rows can't do.
   Specificity has to beat .acct-card.sel .acct-check (which fills it gold),
   hence the doubled class on the card.
   NB: this whole block lives inside a JS template literal — never use a
   backtick in these comments, it terminates the css string. */
.acct-card.acct-locked{cursor:default;}
.acct-card.acct-locked:hover{border-color:#1a4a6b;}
.acct-card.sel.acct-locked .acct-check{
  background:transparent;border-color:transparent;
  color:#5a6a72;font-size:14px;line-height:1;
}
.acct-bal{
  margin-left:auto;font-family:'IBM Plex Mono',monospace;font-size:13.5px;
  color:#1a1a1a;white-space:nowrap;
}
.acct-bal-none{color:#647071;}

/* ── Climate ── */
.climate-card{border:1px solid #d4cfc5;border-radius:4px;background:#fff;overflow:hidden;margin-bottom:22px;}
.climate-footer{padding:14px 16px;border-top:1px solid #e3ddd1;display:flex;align-items:center;justify-content:space-between;gap:12px;background:#faf9f7;}
.climate-footer-text{font-size:13px;font-weight:500;color:#1a1a1a;}
.climate-footer-sub{font-size:12px;color:#647071;margin-top:2px;}
.toggle{position:relative;width:40px;height:22px;flex-shrink:0;}
.toggle input{opacity:0;width:0;height:0;position:absolute;}
.toggle-track{
  position:absolute;inset:0;border-radius:11px;background:#d4cfc5;
  cursor:pointer;transition:background .15s;
}
.toggle-track::after{
  content:'';position:absolute;left:3px;top:3px;
  width:16px;height:16px;border-radius:50%;background:#fff;
  transition:transform .15s;box-shadow:0 1px 3px rgba(0,0,0,.18);
}
.toggle input:checked+.toggle-track{background:#f5a623;box-shadow:inset 0 0 0 1px #8a5709;}
.toggle input:checked+.toggle-track::after{transform:translateX(18px);}
.climate-on-note{
  padding:11px 14px;border-radius:4px;background:rgba(245,166,35,.08);
  border:1px solid rgba(138,87,9,.28);
  font-size:12.5px;color:#8a5709;line-height:1.55;
}

/* ── Allocation ── */
.alloc-card{border:1px solid #d4cfc5;border-radius:4px;background:#fff;padding:20px;margin-bottom:22px;}
.alloc-bar-wrap{display:flex;height:6px;border-radius:3px;overflow:hidden;margin-bottom:6px;}
.alloc-bar-eq{background:#f5a623;transition:width .15s;}
.alloc-bar-fi{background:#d4cfc5;transition:width .15s;}
.alloc-bar-labels{display:flex;justify-content:space-between;font-family:'IBM Plex Mono',monospace;font-size:12px;color:#647071;margin-bottom:18px;}
.alloc-bar-labels .eq{color:#8a5709;font-weight:500;}
input[type=range].sp-range{
  width:100%;-webkit-appearance:none;height:3px;border-radius:2px;
  background:#d4cfc5;outline:none;margin-bottom:20px;cursor:pointer;
}
input[type=range].sp-range::-webkit-slider-thumb{
  -webkit-appearance:none;width:18px;height:18px;border-radius:50%;
  background:#1a1a1a;cursor:pointer;border:2.5px solid #fff;
  box-shadow:0 1px 4px rgba(0,0,0,.22);
}
input[type=range].sp-range::-moz-range-thumb{
  width:18px;height:18px;border-radius:50%;
  background:#1a1a1a;cursor:pointer;border:2.5px solid #fff;
  box-shadow:0 1px 4px rgba(0,0,0,.22);
}
/* ── CPP/OAS government-benefit inputs ── */
.gov-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
@media(max-width:700px){.gov-grid{grid-template-columns:1fr;}}
.gov-block{border:1px solid #d4cfc5;border-radius:4px;background:#fff;padding:16px 18px;min-width:0;}
.gov-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;}
.gov-name{font-size:13px;font-weight:500;color:#1a1a1a;}
.gov-sub{font-family:'IBM Plex Mono',monospace;font-size:11px;color:#647071;}
.gov-year-sel{font-family:'IBM Plex Mono',monospace;font-size:12.5px;color:#1a1a1a;
  border:1px solid #d4cfc5;border-radius:4px;padding:5px 8px;background:#faf9f7;cursor:pointer;}
.gov-slider-wrap{position:relative;padding-top:2px;}
.gov-slider-wrap input[type=range].sp-range{margin-bottom:8px;}
/* med-marker reference tick sitting on the track */
.gov-med-tick{position:absolute;top:-1px;width:2px;height:12px;background:#8a5709;
  border-radius:1px;transform:translateX(-50%);pointer-events:none;}
.gov-slider-labels{display:flex;justify-content:space-between;
  font-family:'IBM Plex Mono',monospace;font-size:10px;color:#647071;}
.gov-amount{font-family:'IBM Plex Mono',monospace;font-size:14px;font-weight:500;color:#8a5709;}
.gov-hint{font-size:11.5px;color:#647071;line-height:1.5;margin-top:10px;}
/* Inline help trigger — same colours/hover as the nav .sp-help-btn chip, sized for mid-sentence use */
.sp-inline-help{display:inline-flex;align-items:center;justify-content:center;
  width:17px;height:17px;border-radius:50%;background:#d4cfc5;color:#fff;flex-shrink:0;
  font-family:'Inter Tight',system-ui,sans-serif;font-size:11px;font-weight:600;line-height:1;
  border:none;cursor:pointer;vertical-align:middle;margin-left:3px;padding:0;
  transition:background .15s;}
.sp-inline-help:hover{background:#f5a623;color:#1a1a1a;}
.help-cite{font-size:11.5px;color:#647071;margin-top:10px;}
.help-cite a{color:#1a4a6b;}
/* Help Center — category index (article cards) + article breadcrumb */
/* Category filter row. This is what's left of the old .sp-subnav bar: the bar
   itself is gone (its four names duplicated the sidebar's group headings), but
   the filtering it did survives here, next to the grid it filters. */
.help-cat-pills{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 20px;}
.help-cat-pill{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.08em;text-transform:uppercase;
  color:#647071;background:transparent;border:1px solid #d4cfc5;border-radius:99px;padding:6px 13px;
  cursor:pointer;font-weight:400;transition:color .12s,border-color .12s,background .12s;}
.help-cat-pill:hover{color:#1a4a6b;border-color:#1a4a6b;}
.help-cat-pill.on{color:#faf9f7;background:#1a1a1a;border-color:#1a1a1a;}
.help-cat-pill.on:hover{color:#faf9f7;background:#1a1a1a;border-color:#1a1a1a;}
.help-index-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(224px,1fr));gap:14px;margin-top:8px;}
.help-index-card{text-align:left;background:#fff;border:1px solid #d4cfc5;border-radius:4px;padding:16px 18px;cursor:pointer;font-family:inherit;transition:border-color .12s,box-shadow .12s;}
.help-index-card:hover{border-color:#1a4a6b;box-shadow:0 0 0 3px rgba(26,74,107,.12);}
.help-index-card-title{font-size:14px;font-weight:600;color:#1a1a1a;margin-bottom:4px;}
.help-index-card-dek{font-size:12.5px;color:#5a6a72;line-height:1.5;}
.help-breadcrumb{display:flex;align-items:center;gap:8px;font-family:'IBM Plex Mono',monospace;font-size:11.5px;letter-spacing:.04em;color:#647071;margin-bottom:22px;}
.help-breadcrumb a{color:#1a4a6b;text-decoration:none;}
.help-breadcrumb-sep{color:#a0aab4;}
/* Help Center — sidebar rail (article navigator across all categories, mirrors
   asset.html's watch-rail). Since the .sp-subnav bar was removed this is the
   Help Center's ONLY navigation, so it is also what answers "where am I": the
   active group's heading goes bronze and the open article gets a navy left
   rule + gold tint. 246px/1px/28px here must stay in sync with the 1200 /
   wide?925:820 maxWidth math on HelpPage's two inline style attrs — 1200
   matches --sp-nav-max (nav.css) so the page never runs wider than the header
   bar above it; 925 = 1200 - 246 (rail) - 1 (divider) - 28 (gap).
   top:96px = the 68px nav + .sp-page's 28px top padding; with no second sticky
   bar above it any more, that lands the rail exactly under the nav. */
.help-shell{display:grid;grid-template-columns:246px minmax(0,1fr);gap:28px;align-items:start;}
.help-main{min-width:0;}
.help-rail{position:sticky;top:96px;max-height:calc(100vh - 116px);overflow-y:auto;
  padding-right:16px;border-right:1px solid #e3ddd1;}
.help-rail-group{border-bottom:1px solid #e3ddd1;padding-bottom:8px;margin-bottom:8px;}
.help-rail-group:last-child{border-bottom:none;}
.help-rail-head{display:flex;align-items:center;justify-content:space-between;width:100%;
  background:transparent;border:none;padding:8px 4px;cursor:pointer;text-align:left;
  font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.1em;text-transform:uppercase;
  color:#647071;font-weight:500;transition:color .12s;}
.help-rail-head:hover{color:#1a4a6b;}
.help-rail-head.current{color:#8a5709;}
.help-rail-chev{font-size:14px;transition:transform .15s;flex-shrink:0;}
.help-rail-chev.open{transform:rotate(180deg);}
.help-rail-list{display:flex;flex-direction:column;padding:2px 0 4px;}
.help-rail-item{text-align:left;background:transparent;border:none;border-left:2px solid transparent;
  padding:7px 4px 7px 12px;font-size:13.5px;line-height:1.4;color:#5a6a72;cursor:pointer;border-radius:0 3px 3px 0;
  transition:background .12s,color .12s,border-color .12s;}
.help-rail-item:hover{background:rgba(0,0,0,.03);color:#1a1a1a;}
.help-rail-item.active{background:rgba(245,166,35,.10);border-left-color:#1a4a6b;color:#1a1a1a;font-weight:500;}
@media(max-width:900px){.help-shell{grid-template-columns:1fr;}.help-rail{display:none;}}
/* Help Center — illustrated "chapter": prose pinned beside a live product figure */
.help-chapter{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,478px);gap:60px;align-items:start;padding:32px 0;border-top:1px solid #e3ddd1;}
.help-sources{margin-top:16px;display:flex;flex-wrap:wrap;gap:7px 14px;align-items:baseline;font-size:12.5px;}
.help-sources-tag{font-family:'IBM Plex Mono',monospace;font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:#647071;}
.help-sources a{color:#1a4a6b;}
.help-figcol{position:sticky;top:96px;}
.help-figure{margin:0;animation:help-pin-in .4s ease both;}
.help-fig-caption{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 12px;align-items:baseline;}
.help-fig-caption strong{color:#5a6a72;}
.help-fig-caption a{white-space:nowrap;}
.help-fig2-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
.help-callouts{margin-top:14px;display:flex;flex-direction:column;gap:8px;}
.help-callout{display:flex;gap:9px;align-items:flex-start;font-size:12.5px;line-height:1.5;color:#5a6a72;}
.help-callout-badge{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;background:#f5a623;color:#1a1a1a;box-shadow:inset 0 0 0 1px #8a5709;font-family:'IBM Plex Mono',monospace;font-size:10px;font-weight:600;}
.pl-sec-lbl .help-callout-badge{margin-left:6px;vertical-align:middle;}
/* Help Center — bottom-of-article redirect banner */
.help-cta-banner{margin-top:24px;padding:24px 26px;display:flex;justify-content:space-between;align-items:center;gap:20px;flex-wrap:wrap;}
.help-cta-heading{font-size:16px;font-weight:500;color:#1a1a1a;margin-bottom:4px;}
.help-cta-body{font-size:13.5px;color:#5a6a72;}
@keyframes help-pin-in{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
@media(max-width:880px){.help-chapter{grid-template-columns:minmax(0,1fr);gap:20px;padding:24px 0;}.help-figcol{position:static;top:auto;}.help-fig2-grid{grid-template-columns:minmax(0,1fr);}}
.alloc-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:10px;}
.alloc-metric{background:#f5f0e8;border-radius:4px;padding:12px 14px;}
.alloc-metric-label{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#647071;margin-bottom:5px;}
.alloc-metric-val{font-family:'IBM Plex Mono',monospace;font-size:20px;font-weight:400;color:#1a1a1a;letter-spacing:-.01em;}
.alloc-metric-unit{font-family:'IBM Plex Mono',monospace;font-size:11px;color:#647071;}
.alloc-metric-label-val{font-size:13px;font-weight:500;color:#1a1a1a;margin-top:2px;}
.alloc-dd-note{font-size:12px;color:#647071;text-align:center;line-height:1.5;border-top:1px solid #e3ddd1;padding-top:10px;margin-top:4px;}
.alloc-disclaimer{font-size:11px;color:#647071;text-align:center;margin-top:5px;}

/* ── Suggested holdings table ── */
.hold-head{display:flex;align-items:baseline;justify-content:space-between;margin:4px 0 8px;}
.hold-head-title{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:#647071;}
.hold-head-sub{font-family:'IBM Plex Mono',monospace;font-size:12px;color:#647071;}
.hold-table{width:100%;border-collapse:collapse;font-size:12.5px;}
.hold-table th{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#647071;font-weight:400;text-align:left;padding:6px 8px 6px 0;border-bottom:1px solid #d4cfc5;}
.hold-table th.num,.hold-table td.num{text-align:right;padding-right:0;}
.hold-table td{padding:8px 8px 8px 0;border-bottom:1px solid #e3ddd1;color:#1a1a1a;vertical-align:middle;}
.hold-table tr:last-child td{border-bottom:none;}
.hold-tk{font-family:'IBM Plex Mono',monospace;font-weight:500;}
.hold-esg{font-family:'IBM Plex Mono',monospace;font-size:8.5px;letter-spacing:.06em;color:#8a5709;background:rgba(245,166,35,.16);border-radius:99px;padding:1px 5px;margin-left:6px;vertical-align:middle;}
.hold-w{font-family:'IBM Plex Mono',monospace;}
.hold-cls{color:#5a6a72;}
.hold-bar-cell{width:74px;}
.hold-bar{height:5px;border-radius:3px;background:#e3ddd1;overflow:hidden;}
.hold-bar-fill{height:100%;background:#f5a623;}
.hold-bar-fill.fi{background:#2980b9;}

/* ── Radio options ── */
.radio-list{display:flex;flex-direction:column;gap:7px;}
.radio-opt{
  display:flex;align-items:center;gap:10px;padding:10px 12px;
  border:1px solid #8b9199;border-radius:4px;background:#fff;
  cursor:pointer;transition:border-color .12s;user-select:none;
}
.radio-opt:hover{border-color:#5a6a72;}
.radio-opt.sel{border-color:#1a4a6b;background:rgba(245,166,35,.09);}
.radio-dot{width:15px;height:15px;border-radius:50%;border:1.5px solid #8b9199;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:#fff;transition:all .12s;}
.radio-opt.sel .radio-dot{border-color:#8a5709;background:#f5a623;}
/* Ink, not white: a white pip on a gold dot is 1.9:1. */
.radio-inner{width:5px;height:5px;border-radius:50%;background:#1a1a1a;display:none;}
.radio-opt.sel .radio-inner{display:block;}
.radio-text{font-size:13.5px;color:#1a1a1a;}
.radio-sub{font-size:12px;color:#647071;margin-top:1px;}

/* ── Buttons ── */
.sp-btn-row{display:flex;gap:8px;margin-top:36px;padding-top:20px;border-top:1px solid #e3ddd1;}
/* Navy is the button colour; gold is what the button does on interaction.
   Secondary at rest is white with a gold outline and a navy label — the gold
   reads as brand at 1px because it sits on white as an OUTLINE, not as text. */
.sp-btn{
  height:36px;padding:0 18px;border-radius:4px;font-family:'Inter Tight',system-ui;
  font-size:14px;font-weight:500;border:1px solid #f5a623;background:#fff;
  color:#1a4a6b;cursor:pointer;transition:background .12s,border-color .12s,color .12s;
  display:inline-flex;align-items:center;gap:6px;box-sizing:border-box;
}
/* Secondary fills to solid gold, keeping the navy label (4.64:1). The old hover
   was #f5f0e8 — the page background — so the button vanished on rollover.
   ORDER MATTERS: .sp-btn:hover and .sp-btn-primary:hover have equal
   specificity (0,2,0) and primary buttons carry BOTH classes, so the primary
   rules must stay below this one or the primary would hover to gold. */
.sp-btn:hover{background:#f5a623;border-color:#f5a623;color:#1a4a6b;}
/* Primary is the navy fill / white label (8.3:1). On hover it deepens to
   navy-dark and the label turns gold (5.1:1) — the accent arrives on
   interaction rather than sitting on the page. */
.sp-btn-primary{background:#1a4a6b;color:#fff;border-color:#1a4a6b;}
.sp-btn-primary:hover{background:#153d58;border-color:#153d58;color:#f5a623;}
.sp-btn:disabled{opacity:.38;cursor:not-allowed;}

/* ── Premium tag (shared — see the PremiumTag component) ── */
/* ONE chip for the whole app. Gold carries "premium" in both states and the
   glyph carries entitlement: a closed lock when the account cannot use the
   feature, an open one when it can. Never render a bare ti-lock beside this —
   the doubled glyph is what the Optimizer's universe pills used to do.
   Contrast follows style.md rules 1-2: LOCKED is the gold WASH with a bronze
   #8a5709 label (gold is illegal as text), UNLOCKED is the solid gold state fill
   which therefore takes the bronze 1px boundary and an INK glyph (white on gold
   is 1.9:1). Both are legible on parchment, on white, and on the ink fill of an
   active .op-uni-pill. */
.sp-prem-tag{
  display:inline-flex;align-items:center;gap:4px;flex-shrink:0;
  font:9.5px/1 'IBM Plex Mono',monospace;letter-spacing:.08em;
  text-transform:uppercase;border-radius:99px;padding:3px 6px;
  color:#8a5709;background:rgba(245,166,35,.16);border:1px solid #8a5709;
}
.sp-prem-tag i{font-size:11px;line-height:1;}
.sp-prem-tag.on{background:#f5a623;color:#1a1a1a;}

/* ── IPS Document ── */
.ips-doc{
  background:#fff;border:1px solid #d4cfc5;border-radius:4px;
  padding:32px 48px 44px;
}
.doc-header{border-bottom:1.5px solid #1a1a1a;padding-bottom:14px;margin-bottom:20px;}
.doc-status-row{display:flex;justify-content:flex-end;margin-bottom:10px;}
.doc-name{font-size:24px;font-weight:500;letter-spacing:-.02em;color:#1a1a1a;margin-bottom:4px;}
.doc-meta{font-family:'IBM Plex Mono',monospace;font-size:11px;color:#647071;}
.doc-section{margin-bottom:26px;}
.doc-section-label{
  font-family:'IBM Plex Mono',monospace;font-size:18px;font-weight:500;
  letter-spacing:.02em;font-variant:small-caps;color:#8a5709;margin-bottom:12px;
}
.doc-body{font-size:13.5px;line-height:1.75;color:#1a1a1a;}
.doc-body p{margin-bottom:10px;}
.doc-body ul{padding-left:18px;margin-bottom:10px;}
.doc-body li{margin-bottom:5px;}
.doc-body strong{font-weight:600;}
.doc-table{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:10px;}
.doc-table th{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#647071;padding:6px 10px 6px 0;border-bottom:1px solid #d4cfc5;text-align:left;font-weight:400;}
.doc-table td{padding:8px 10px 8px 0;border-bottom:1px solid #e3ddd1;color:#1a1a1a;vertical-align:top;}
.doc-table tr:last-child td{border-bottom:none;}
.ips-comp{display:flex;gap:26px;align-items:center;margin-top:12px;flex-wrap:wrap;}
.ips-donut{display:block;flex:0 0 auto;}
.ips-comp-table{flex:1 1 300px;margin-top:0;}
.cls-dot{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:7px;vertical-align:middle;}
.doc-divider{border:none;border-top:1px solid #e3ddd1;margin:22px 0;}
.doc-sig{margin-top:36px;padding-top:20px;border-top:1px solid #d4cfc5;}
.doc-sig-note{font-size:12.5px;color:#647071;margin-bottom:28px;}
.doc-sig-lines{display:flex;gap:32px;}
.doc-sig-field{flex:1;font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:#647071;}
.doc-sig-underline{border-top:1px solid #5a6a72;margin:6px 0;}
.doc-sig-name{font-family:'Dancing Script',cursive;font-size:28px;font-weight:600;color:#1a1a1a;line-height:1.2;margin-bottom:6px;letter-spacing:.01em;}
.doc-sig-date{font-family:'IBM Plex Mono',monospace;font-size:13px;color:#1a1a1a;margin-bottom:6px;}
.climate-badge-inline{
  display:inline-flex;align-items:center;
  font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;
  color:#8a5709;background:rgba(245,166,35,.16);border-radius:99px;padding:2px 8px;
}
.doc-actions{margin-top:16px;display:flex;gap:8px;align-items:center;}
.doc-saved{font-family:'IBM Plex Mono',monospace;font-size:12px;color:#647071;margin-left:auto;}
.doc-saved.ok{color:#2d7a47;}

/* ── Editable Plan document ── */
.doc-status-chip{display:inline-flex;align-items:center;gap:10px;flex-shrink:0;padding:5px 14px;border:1px solid #d4cfc5;border-radius:99px;background:#fff;}
.doc-status-dot{width:8px;height:8px;border-radius:50%;}
.doc-status-text{font-size:12.5px;font-weight:600;color:#1a1a1a;}
.doc-status-div{width:1px;height:13px;background:#e3ddd1;}
.doc-status-sub{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.04em;color:#9aa0a8;}
.doc-resign-btn{font-family:inherit;font-size:11.5px;font-weight:600;color:#1a4a6b;background:none;border:none;cursor:pointer;padding:0 0 0 4px;}
.doc-status-link{font:inherit;font-weight:600;color:#1a4a6b;background:none;border:none;cursor:pointer;padding:0;text-decoration:underline;}
.doc-status-link:hover{color:#153d58;}
.doc-edit-ta{width:100%;font-family:inherit;font-size:14.5px;line-height:1.7;color:#1a1a1a;border:1.5px solid #1a4a6b;border-radius:9px;padding:11px 13px;background:#f7f9fb;outline:none;resize:vertical;box-shadow:0 0 0 3px rgba(26,74,107,.10);box-sizing:border-box;}
.doc-edit-done{font-family:inherit;font-size:12.5px;font-weight:600;color:#fff;background:#1a4a6b;border:none;border-radius:7px;padding:7px 15px;cursor:pointer;}
.doc-edit-done:hover{background:#153d58;color:#f5a623;}
.doc-edit-hint{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.08em;color:#9aa0a8;}
.doc-edited-tag{position:absolute;right:0;top:-13px;font-family:'IBM Plex Mono',monospace;font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:#8a5709;opacity:.75;}
.doc-restore-btn{display:block;font-family:'IBM Plex Mono',monospace;font-size:12px;font-weight:600;letter-spacing:.03em;color:#a1450f;background:none;border:none;cursor:pointer;padding:6px 0 0;}
.doc-add-note-btn{display:inline-flex;align-items:center;gap:8px;font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#1a4a6b;background:none;border:none;cursor:pointer;padding:0;}
.doc-add-note-plus{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;border:1px solid #1a4a6b;font-size:13px;line-height:1;}
.doc-hold-head{display:flex;align-items:center;font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:#647071;padding-bottom:8px;border-bottom:1px solid #e6e0d4;}
.doc-hold-row{display:flex;align-items:center;padding:11px 0;border-bottom:1px solid #f0ebe1;}
.doc-hold-edit-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #f0ebe1;}
.doc-hold-input{font-family:inherit;font-size:13px;border:1px solid #d9d2c6;border-radius:6px;padding:6px 9px;background:#fff;outline:none;}
.doc-hold-mono{font-family:'IBM Plex Mono',monospace;}
.doc-hold-remove{width:34px;height:30px;border:1px solid #ecdada;background:#fdf6f4;color:#b34030;border-radius:6px;cursor:pointer;font-size:15px;line-height:1;flex:none;}
.doc-hold-add{display:inline-flex;align-items:center;gap:7px;font-family:inherit;font-size:12.5px;font-weight:500;color:#1a4a6b;background:#fff;border:1px dashed #b8c3cc;border-radius:7px;padding:7px 13px;cursor:pointer;}
.doc-hold-total{font-family:'IBM Plex Mono',monospace;font-size:12.5px;font-weight:500;}

/* ── Auth screen ── */
.auth-page{min-height:100vh;display:flex;flex-direction:column;}
.auth-wrap{flex:1;display:flex;align-items:center;justify-content:center;padding:24px;}
.auth-card{width:100%;max-width:380px;background:#fff;border:1px solid #d4cfc5;border-radius:4px;padding:32px 30px;}
.auth-h{font-size:21px;font-weight:500;letter-spacing:-.02em;margin-bottom:6px;}
.auth-sub{font-size:13px;color:#5a6a72;margin-bottom:24px;line-height:1.5;}
.auth-err{background:rgba(179,64,48,.08);border:1px solid rgba(179,64,48,.25);color:#b34030;font-size:12.5px;border-radius:4px;padding:9px 12px;margin-bottom:16px;line-height:1.5;}
.auth-switch{font-size:12.5px;color:#647071;margin-top:18px;text-align:center;}
.auth-switch button{background:none;border:none;color:#1a4a6b;font-weight:500;cursor:pointer;font-size:12.5px;font-family:inherit;padding:0;}
.auth-switch button:hover{text-decoration:underline;}
.auth-divider{display:flex;align-items:center;gap:12px;margin:18px 0;color:#9aa7ad;font-size:11px;text-transform:uppercase;letter-spacing:.06em;}
.auth-divider::before,.auth-divider::after{content:"";flex:1;height:1px;background:#e8e4de;}
.google-btn{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;height:38px;background:#fff;border:1px solid #d4cfc5;border-radius:4px;color:#3a4a52;font-size:13px;font-weight:500;font-family:inherit;text-decoration:none;cursor:pointer;transition:background .12s,border-color .12s;}
.google-btn:hover{background:#faf9f7;border-color:#b9b3a7;}
.google-btn svg{flex-shrink:0;}

/* ── Splash ── */
.sp-splash{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:'IBM Plex Mono',monospace;color:#647071;font-size:11px;letter-spacing:.08em;text-transform:uppercase;}

/* ── Confirm modal ── */
.sp-overlay{position:fixed;inset:0;background:rgba(26,26,26,.45);z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;}
.sp-modal{background:#fff;border:1px solid #d4cfc5;border-radius:4px;padding:28px 28px 24px;max-width:380px;width:100%;}
.sp-modal-title{font-size:16px;font-weight:600;color:#1a1a1a;margin-bottom:10px;}
.sp-modal-body{font-size:13.5px;color:#5a6a72;line-height:1.6;margin-bottom:22px;}
.sp-modal-actions{display:flex;gap:8px;justify-content:flex-end;}
.sp-btn-danger{background:#b34030;color:#fff;border-color:#b34030;}
.sp-btn-danger:hover{background:#962f21;border-color:#962f21;}

/* ── Goal card footer ── */
.goal-card-footer{display:flex;align-items:center;justify-content:space-between;margin-top:6px;}
.goal-card-clear{
  font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.06em;text-transform:uppercase;
  color:#1a4a6b;background:transparent;border:none;cursor:pointer;padding:2px 0;line-height:1;
}
.goal-card-clear:hover{color:#b34030;}

/* ── Plan landing (enriched hub) ── */
/* Same 1100px as .pf-page (Save) and .df-page (Track) — the hub is a peer of
   those two full-width tab pages, so its content edges must line up with theirs
   when switching tabs. Do not widen it back. */
.pl-page{max-width:1100px;}

.gt-cap{font-size:13px;line-height:1.6;color:#5a6a72;margin:2px 0 20px;max-width:660px;}
.gt-row{display:flex;align-items:center;padding:8px 4px 4px;overflow-x:auto;}
.gt-seg{height:2px;background:#d4cfc5;min-width:22px;flex-grow:1;}
.gt-node{display:flex;flex-direction:column;align-items:center;gap:7px;flex-shrink:0;}
.gt-node-label{font-size:12.5px;font-weight:500;color:#1a1a1a;display:flex;align-items:center;gap:5px;white-space:nowrap;transition:color .12s;}
.gt-node-label i{font-size:15px;color:#5a6a72;}
/* A 25-char custom goal name is ~160px — wider than a whole timeline node, which
   would stretch the row into a horizontal scroll and squash every gt-seg to its
   22px minimum, destroying the chronological spacing the strip exists to show.
   Capped here instead; the full name is on the card the node jumps to. The span
   is required — text-overflow can't clip a bare text node inside a flex box. */
.gt-node-name{max-width:150px;overflow:hidden;text-overflow:ellipsis;}
.gt-node-dot{width:11px;height:11px;border-radius:50%;border:2.5px solid #faf9f7;box-sizing:content-box;transition:transform .12s;}
.gt-node-dot.now{background:#1a1a1a;}
.gt-node-year{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.04em;color:#647071;}
.gt-node.now .gt-node-label{color:#647071;font-weight:400;}
.gt-node-btn{background:none;border:none;padding:4px 6px;margin:0;font:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;}
.gt-node-btn.below{flex-direction:column-reverse;}
.gt-node-btn:hover .gt-node-label{color:#1a4a6b;}
.gt-node-btn:hover .gt-node-dot{transform:scale(1.3);}

.pl-card-highlight{outline:2px solid #1a4a6b;outline-offset:3px;}

/* minmax(0,1fr), not a bare 1fr: 1fr means minmax(auto,1fr), so the column floors
   at its content's intrinsic width and a long unbroken goal name widens the whole
   column instead of being clipped by .goal-name's ellipsis. Verified by render —
   with plain 1fr, a 25-character name pushed the card clean past the grid. */
.pl-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px;}
.pl-card{padding:14px 16px 12px;gap:0;}
.pl-card-hd{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:10px;}
/* min-width:0 on both the row and its text column is what lets .goal-name's
   ellipsis actually engage — a flex item defaults to min-width:auto, i.e. it
   refuses to shrink below its content, so without these the long name pushes the
   signal chip out of the card instead of being clipped. */
.pl-card-id{display:flex;align-items:center;gap:9px;min-width:0;}
.pl-card-id>div{min-width:0;}
.pl-card-id i{font-size:18px;color:#8a5709;flex-shrink:0;}
.pl-card-empty .pl-card-id i{color:#a0aab4;}
.pl-card-hd>.pf-signal{flex-shrink:0;}
/* Left free to wrap on purpose: unlike .goal-name this is always generated text
   ("$50k planned · $63k projected"), and on a one-column phone layout wrapping
   to a second line beats clipping a number in half. */
.pl-card-meta{font-size:11.5px;color:#647071;margin-top:2px;}
.pl-sec-lbl{display:flex;justify-content:space-between;align-items:baseline;margin:0 0 4px;}
.pl-sec-lbl span:first-child{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#647071;}
.pl-sec-lbl span:last-child{font-family:'IBM Plex Mono',monospace;font-size:12px;color:#1a1a1a;}
.pl-chart{display:block;margin-bottom:10px;}
.pl-alloc-bar{display:flex;height:7px;border-radius:4px;overflow:hidden;gap:1px;margin-bottom:7px;}
.pl-alloc-seg{height:100%;}
.pl-alloc-legend{display:flex;gap:11px;flex-wrap:wrap;margin-bottom:2px;}
.pl-alloc-legend span{display:flex;align-items:center;gap:5px;font-size:11.5px;color:#5a6a72;white-space:nowrap;}
.pl-alloc-legend i{width:7px;height:7px;border-radius:2px;flex-shrink:0;}
.pl-empty-body{margin-top:4px;padding-top:16px;border-top:1px dashed #e3ddd1;font-size:12.5px;color:#647071;line-height:1.5;}
@media(max-width:760px){.pl-grid{grid-template-columns:minmax(0,1fr);}}

/* ── Lifestyle / purpose cards ── */
.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;margin-bottom:16px;}
.rp-card{
  background:#fff;border:1px solid #d4cfc5;border-radius:8px;
  padding:14px 16px;cursor:pointer;text-align:left;
  display:flex;flex-direction:column;gap:8px;position:relative;
  transition:border-color .12s,background .12s;box-shadow:0 1px 2px rgba(0,0,0,.04);
  font-family:inherit;
}
.rp-card:hover{border-color:#5a6a72;}
.rp-card.selected{border:1.5px solid #1a4a6b;background:rgba(245,166,35,.09);}
.rp-check{
  position:absolute;top:10px;right:10px;
  width:18px;height:18px;border-radius:50%;border:1.5px solid #d4cfc5;
  display:flex;align-items:center;justify-content:center;transition:all .12s;
}
.rp-card.selected .rp-check{background:#f5a623;border-color:#8a5709;}
.rp-check-icon{display:none;color:#1a1a1a;font-size:11px;}
.rp-card.selected .rp-check-icon{display:block;}
.rp-icon{font-size:22px;color:#647071;}
.rp-card.selected .rp-icon{color:#8a5709;}
.rp-title{font-size:13px;font-weight:500;color:#1a1a1a;line-height:1.3;padding-right:20px;}
.rp-card.selected .rp-title{color:#1a1a1a;}
.rp-sub{font-size:11px;color:#647071;line-height:1.4;}
.rp-card.selected .rp-sub{color:#8a5709;}
.rp-count{font-size:12px;color:#647071;margin-bottom:14px;min-height:18px;}
.rp-count strong{color:#1a1a1a;}

/* ── Lifestyle tag groups (document) ── */
.lifestyle-tag-groups{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px;margin-top:16px;}
.lifestyle-tag-group{background:#fff;border:1px solid #e3ddd1;border-radius:8px;padding:14px 16px;}
.lifestyle-tag-group-header{display:flex;align-items:center;gap:7px;margin-bottom:10px;}
.lifestyle-tag-group-icon{font-size:16px;}
.lifestyle-tag-group-label{font-size:12.5px;font-weight:600;color:#1a1a1a;}
.lifestyle-tags{display:flex;flex-wrap:wrap;gap:6px;}
.lifestyle-tag{
  display:inline-block;font-size:11.5px;font-weight:500;
  border-radius:4px;padding:4px 9px;line-height:1.3;
}

@media print{
  .sp-doc-print .sp-nav,.sp-doc-print .wz-bar,.sp-doc-print .sp-btn-row,.sp-doc-print .sp-eyebrow,.sp-doc-print .sp-h1,.sp-doc-print .sp-lead,.sp-doc-print .doc-actions,.sp-doc-print .sp-section-label{display:none!important;}
  .ips-doc{border:none;border-radius:0;padding:0;}
  .sp-doc-print .sp-page{padding:0;max-width:100%;}
}


/* ── Portfolio overview page ── */
.pf-page{max-width:1100px;margin:0 auto;padding:28px 24px 80px;}
.pf-h1{font-size:30px;font-weight:500;letter-spacing:-.02em;color:#1a1a1a;margin:0 0 20px;}
.pf-section-head{font-size:19px;font-weight:500;letter-spacing:-.01em;color:#1a1a1a;margin:0 0 16px;padding-bottom:12px;border-bottom:1px solid #e3ddd1;}

/* Portfolio goal cards (Save-tab ribbon) — rich trajectory cards matching the Plan hub */
.pf-gcards{display:grid;grid-template-columns:repeat(auto-fit,minmax(224px,1fr));gap:14px;margin-bottom:24px;}
.pf-gcard{display:flex;flex-direction:column;background:#fff;border:1px solid #d4cfc5;border-radius:6px;padding:12px 14px 10px;cursor:pointer;text-align:left;transition:border-color .12s,box-shadow .12s;min-width:0;}
.pf-gcard:hover{border-color:#5a6a72;}
.pf-gcard.active{border-color:#1a4a6b;box-shadow:0 0 0 3px rgba(26,74,107,.12);}
.pf-gcard-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;}
/* The signal chip / Draft tag keeps its full width; the name column absorbs the
   squeeze via .pf-gcard-name's ellipsis. Without this the chip is the thing that
   collapses, since it's the flex item with no min-width floor of its own. */
.pf-gcard-hd>.pf-signal,.pf-gcard-hd>.pf-gcard-draft-tag{flex-shrink:0;}
.pf-gcard-id{display:flex;align-items:center;gap:7px;min-width:0;}
.pf-gcard-id i{font-size:16px;color:#8a5709;flex-shrink:0;}
.pf-gcard-name{font-size:13.5px;font-weight:500;color:#1a1a1a;letter-spacing:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.pf-gcard-line{margin-bottom:8px;white-space:nowrap;overflow:hidden;}
.pf-gcard-today{font-family:'IBM Plex Mono',monospace;font-size:12px;color:#647071;}
.pf-gcard-sep{font-family:'IBM Plex Mono',monospace;font-size:12px;color:#a0aab4;margin:0 6px;}
.pf-gcard-val{font-family:'IBM Plex Mono',monospace;font-size:13.5px;font-weight:500;color:#1a1a1a;letter-spacing:-.01em;}
.pf-gcard-by{font-family:'IBM Plex Mono',monospace;font-size:12px;font-weight:400;color:#647071;margin-left:4px;}
.pf-gcard-chart{margin-bottom:6px;}
.pf-gcard-foot{font-family:'IBM Plex Mono',monospace;font-size:12px;}
.pf-gcard-add{border-style:dashed;border-color:#c9c2b4;color:#9aa2a8;align-items:center;justify-content:center;min-height:132px;}
.pf-gcard-add:hover{border-color:#1a4a6b;color:#1a4a6b;background:#faf9f7;}
.pf-gcard-add-inner{display:flex;flex-direction:column;align-items:center;gap:8px;font-family:'IBM Plex Mono',monospace;font-size:13px;letter-spacing:.02em;}
.pf-gcard-add-inner i{font-size:26px;}
.pf-gcard-draft{border-style:dashed;border-color:#c9c2b4;}
.pf-gcard-draft:hover{border-color:#1a4a6b;background:#faf9f7;}
.pf-gcard-draft .pf-gcard-id i{color:#a0aab4;}
.pf-gcard-draft-tag{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#a1450f;background:rgba(194,87,26,.1);padding:2px 7px;border-radius:99px;flex-shrink:0;}
.pf-gcard-draft-body{margin-top:2px;padding-top:16px;border-top:1px dashed #e3ddd1;font-size:12.5px;color:#647071;line-height:1.5;}

/* Unified allocation + plan status panel */
.pf-alloc-panel{display:flex;align-items:stretch;background:#fff;border:1px solid #d4cfc5;border-radius:4px;margin-bottom:16px;overflow:hidden;}
.pf-alloc-left{flex:1;min-width:0;padding:20px;border-right:1px solid #d4cfc5;}
.pf-alloc-right{width:220px;flex-shrink:0;}
@media(max-width:700px){.pf-alloc-panel{flex-direction:column;}.pf-alloc-left{border-right:none;border-bottom:1px solid #d4cfc5;}.pf-alloc-right{width:100%;}}
.pf-metric-item{padding:11px 16px;border-bottom:1px solid #f0ede6;}
.pf-metric-item:last-child{border-bottom:none;}
.pf-metric-label{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#647071;margin-bottom:5px;}
.pf-metric-val{font-family:'IBM Plex Mono',monospace;font-size:18px;font-weight:400;letter-spacing:-.02em;color:#1a1a1a;font-variant-numeric:tabular-nums;}

/* Allocation card */
.pf-proj-card,.pf-card{
  background:#fff;border:1px solid #d4cfc5;border-radius:4px;padding:20px;
}
.pf-card{margin-bottom:16px;}
.pf-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;gap:8px;}
.pf-card-title{font-size:13.5px;font-weight:500;color:#1a1a1a;}
.pf-alloc-tabs{display:flex;border:1px solid #d4cfc5;border-radius:4px;overflow:hidden;flex-shrink:0;}
.pf-alloc-tab{font-family:'IBM Plex Mono',monospace;font-size:12px;padding:4px 10px;background:transparent;border:none;border-right:1px solid #d4cfc5;cursor:pointer;color:#647071;transition:background .1s,color .1s;white-space:nowrap;}
.pf-alloc-tab:last-child{border-right:none;}
.pf-alloc-tab:hover{background:#f5f0e8;color:#1a1a1a;}
.pf-alloc-tab.active{background:#faf9f7;color:#1a1a1a;font-weight:500;}

/* Stacked bar */
.pf-stack-bar{display:flex;height:30px;border-radius:4px;overflow:hidden;gap:2px;margin-bottom:18px;}
.pf-stack-seg{height:100%;transition:opacity .1s;}
.pf-stack-seg:hover{opacity:.8;}

/* Allocation table */
.pf-alloc-table{font-size:12.5px;}
.pf-alloc-hdr{display:grid;grid-template-columns:1fr 54px 54px 44px;gap:4px;padding:0 0 7px;border-bottom:1px solid #e3ddd1;font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:#647071;}
.pf-alloc-hdr span:not(:first-child){text-align:right;}
.pf-alloc-row{display:grid;grid-template-columns:1fr 54px 54px 44px;gap:4px;padding:6px 0;border-bottom:1px solid #f0ede6;align-items:center;}
.pf-alloc-row:last-child{border-bottom:none;}
.pf-alloc-row span:not(:first-child){text-align:right;font-family:'IBM Plex Mono',monospace;}
.pf-alloc-name{display:flex;align-items:center;gap:7px;min-width:0;}
.pf-alloc-dot{display:inline-block;width:8px;height:8px;border-radius:2px;flex-shrink:0;}

/* Signal + progress bar (shared) */
.pf-signal{display:flex;align-items:center;font-size:11px;font-family:'IBM Plex Mono',monospace;white-space:nowrap;}
.pf-signal-green{color:#2d7a47;}
.pf-signal-amber{color:#a1450f;}
.pf-signal-red{color:#b34030;}

/* Projection card */
.pf-proj-card{padding:20px 20px 12px;}
.pf-proj-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;gap:8px;flex-wrap:wrap;}

/* Mix view (donut + legend) */
.pf-mix-wrap{display:flex;align-items:center;gap:28px;padding:4px 0 4px 20px;}
.pf-mix-legend{flex:0 1 200px;min-width:0;}
.pf-mix-leg-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid #f0ede6;font-size:12.5px;}
.pf-mix-leg-row:last-child{border-bottom:none;}
.pf-mix-leg-name{display:flex;align-items:center;gap:7px;color:#1a1a1a;}
.pf-mix-leg-pct{font-family:'IBM Plex Mono',monospace;font-size:12px;color:#5a6a72;}

/* By account stacked bars */
.stk-list{display:flex;flex-direction:column;gap:18px;}
.stk-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:7px;}
.stk-name{font-family:'IBM Plex Mono',monospace;font-size:12px;font-weight:500;letter-spacing:.04em;}
.stk-meta{font-family:'IBM Plex Mono',monospace;font-size:11px;color:#647071;}
.stk-bar{display:flex;height:32px;border-radius:4px;overflow:hidden;gap:1.5px;}
.stk-seg{display:flex;align-items:center;justify-content:center;min-width:2px;}
.stk-lbl{font-family:'IBM Plex Mono',monospace;font-size:10.5px;color:#fff;font-weight:500;white-space:nowrap;text-shadow:0 1px 1px rgba(0,0,0,.15);}
.stk-legend{display:flex;flex-wrap:wrap;gap:7px 16px;margin-top:18px;padding-top:14px;border-top:1px solid #e3ddd1;justify-content:center;}
.stk-leg{display:flex;align-items:center;gap:6px;font-size:11px;color:#647071;}
.stk-dot{width:9px;height:9px;border-radius:2px;flex-shrink:0;}

/* Holdings section */
.pf-hold-table{font-size:12.5px;}
.pf-hold-hdr{display:grid;grid-template-columns:72px 1fr 80px 80px 80px 64px;gap:4px;padding:0 0 7px;border-bottom:1px solid #e3ddd1;font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:#647071;}
.pf-hold-hdr span:not(:first-child){text-align:right;}
.pf-hold-row{display:grid;grid-template-columns:72px 1fr 80px 80px 80px 64px;gap:4px;padding:7px 0;border-bottom:1px solid #f0ede6;align-items:center;}
.pf-hold-row:last-child{border-bottom:none;}
.pf-hold-row span:not(:first-child){text-align:right;font-family:'IBM Plex Mono',monospace;font-size:12px;}
.pf-hold-ticker{font-family:'IBM Plex Mono',monospace;font-size:12px;font-weight:500;color:#1a1a1a;}
.pf-hold-cls{display:flex;align-items:center;gap:6px;font-size:12.5px;color:#1a1a1a;min-width:0;}
.pf-hold-zero{color:#c8c0b4;}

/* Error / generating */
.pf-spinner{display:inline-block;width:14px;height:14px;border:2px solid #d4cfc5;border-top-color:#1a4a6b;border-radius:50%;animation:pf-spin 0.8s linear infinite;flex-shrink:0;}
@keyframes pf-spin{to{transform:rotate(360deg);}}
.pf-error{font-size:13px;color:#b34030;background:rgba(179,64,48,.06);border:1px solid rgba(179,64,48,.2);border-radius:4px;padding:10px 14px;margin-bottom:16px;}

/* Tab panel animation */
.sp-app{position:relative;}
.sp-tab-panel{display:none;}
.sp-tab-active{display:block;animation:sp-tab-in .15s ease;}
@keyframes sp-tab-in{from{opacity:0;transform:translateY(3px);}to{opacity:1;transform:translateY(0);}}

/* ── Page wrapper (Dashboard) ── */
.df-page{max-width:1100px;margin:0 auto;padding:28px 24px 80px;}
.db-header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:28px;gap:16px;}
.db-h1{font-size:30px;font-weight:500;letter-spacing:-.02em;color:#1a1a1a;margin-top:4px;}
.db-stat-strip{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid #d4cfc5;border-radius:4px;background:#fff;margin-bottom:18px;overflow:hidden;}
.db-stat{padding:16px 18px;}
.db-stat+.db-stat{border-left:1px solid #e3ddd1;}
.db-stat-label{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#647071;margin-bottom:8px;}
.db-stat-val{font-family:'IBM Plex Mono',monospace;font-size:20px;font-weight:400;letter-spacing:-.02em;color:#1a1a1a;font-variant-numeric:tabular-nums;}
.db-stat-val.amber{color:#a1450f;}
.db-stat-val.red{color:#b34030;}
.db-section-hdr{display:flex;align-items:baseline;gap:10px;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid #e3ddd1;}
.db-section-title{font-size:17px;font-weight:500;color:#1a1a1a;}
.db-section-sub{font-size:12px;color:#647071;}
.db-charts-row{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px;}
.db-card{background:#fff;border:1px solid #d4cfc5;border-radius:4px;padding:18px 20px;margin-bottom:14px;}
.db-chart-label{font-family:'IBM Plex Mono',monospace;font-size:11px;color:#647071;margin-bottom:14px;}
.db-chart-label em{font-style:normal;color:#647071;}
.db-bar-row{display:flex;align-items:center;gap:8px;margin-bottom:7px;}
.db-bar-ticker{font-family:'IBM Plex Mono',monospace;font-size:11px;color:#5a6a72;width:34px;flex-shrink:0;text-align:right;}
.db-bar-track{flex:1;height:16px;background:#f5f0e8;border-radius:2px;overflow:hidden;position:relative;}
.db-bar-fill{height:100%;border-radius:2px;transition:width .3s ease;}
.db-bar-val{font-family:'IBM Plex Mono',monospace;font-size:11px;color:#5a6a72;width:28px;flex-shrink:0;text-align:right;}
.db-bar-axis{display:flex;justify-content:space-between;margin-top:8px;padding:0 42px 0 42px;}
.db-bar-axis span{font-family:'IBM Plex Mono',monospace;font-size:9.5px;color:#647071;}
.db-roc-track{flex:1;height:16px;background:#f5f0e8;border-radius:2px;overflow:visible;position:relative;}
.db-roc-fill{height:100%;border-radius:2px;position:absolute;top:0;transition:width .3s ease,left .3s ease;}
.db-tbl{width:100%;border-collapse:collapse;}
.db-tbl th{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:#647071;padding:0 8px 10px;text-align:left;font-weight:400;}
.db-tbl th.r{text-align:right;}
.db-tbl td{font-size:13px;color:#1a1a1a;padding:10px 8px;border-top:1px solid #f0ece4;}
.db-tbl td.r{text-align:right;font-family:'IBM Plex Mono',monospace;font-size:12px;}
.db-tbl td.mono{font-family:'IBM Plex Mono',monospace;font-size:12px;}
.db-tbl-cls{display:flex;align-items:center;gap:6px;font-size:12px;color:#5a6a72;}
.db-cls-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;}
.db-roc-pos{color:#2d7a47;}
.db-roc-neg{color:#b34030;}
.db-btn-remove{width:24px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;cursor:pointer;color:#647071;font-size:14px;line-height:1;padding:0;border-radius:4px;transition:color .15s;}
.db-btn-remove:hover{color:#b34030;}
.db-add-row td{padding:8px 8px 4px;}
.db-add-form{display:flex;gap:6px;align-items:center;}
.db-add-input{height:28px;border:1px solid #8b9199;border-radius:4px;padding:0 8px;font-size:12px;font-family:'IBM Plex Mono',monospace;width:90px;background:#fff;color:#1a1a1a;}
.db-add-input:focus{outline:none;border-color:#1a4a6b;}
.db-add-submit{height:28px;padding:0 10px;border:1px solid #d4cfc5;border-radius:4px;font-size:12px;background:#fff;cursor:pointer;color:#5a6a72;}
.db-add-submit:hover{border-color:#1a4a6b;color:#1a4a6b;}
/* ── Observed Correlations heatmap (Track tab) — ported from portfolio-report's
   .corr-unified (portfolio_report.py); see the file-header comment on
   CorrelationHeatmap for the two deliberate departures. ── */
.db-corr-tbl{border-collapse:collapse;margin:0 auto;font-family:'IBM Plex Mono',monospace;font-size:11px;table-layout:fixed;font-variant-numeric:tabular-nums;}
.db-corr-tbl td{vertical-align:middle;}
.db-corr-lbl{text-align:right;padding:2px 10px 2px 8px;font-weight:500;color:#5a6a72;white-space:nowrap;font-size:11px;}
.db-corr-cell{text-align:center;font-weight:500;}
.db-corr-empty{background:transparent;border:0;}
.db-corr-gutter{width:1px;padding:0;border-left:1px solid #f0ece4;}
.db-corr-bar-cell{padding:0 0 0 14px;}
.db-corr-bar-axis{font-family:'IBM Plex Mono',monospace;font-size:9.5px;color:#647071;letter-spacing:.04em;text-align:left;padding:0 0 6px 14px;vertical-align:bottom;white-space:nowrap;}
.db-corr-bar-val{text-align:right;padding:0 6px 0 10px;font-weight:500;color:#1a1a1a;white-space:nowrap;font-family:'IBM Plex Mono',monospace;font-size:11px;}
@media(max-width:700px){.db-stat-strip{grid-template-columns:repeat(2,1fr)}.db-charts-row{grid-template-columns:1fr;}}
/* ── Market backdrop (Track "Economic Indicators" section) ── */
.mb-toolbar{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap;}
.mb-period{display:flex;gap:4px;margin-left:auto;}
.mb-pill{background:#faf9f7;border:1px solid #d4cfc5;border-radius:99px;padding:4px 12px;font:10px/1 'IBM Plex Mono',monospace;letter-spacing:.06em;color:#5a6a72;cursor:pointer;}
.mb-pill.active{background:#1a1a1a;border-color:#1a1a1a;color:#fff;}
/* .mb-legend/.mb-swatch is the tab's ONE line-chart legend style — MacroHeroChart
   above its chart, PriceIndexChart below its own once past LEGEND_AT series. */
.mb-legend{display:flex;gap:18px;flex-wrap:wrap;font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.04em;color:#647071;}
.mb-legend span{display:flex;align-items:center;gap:6px;}
.mb-swatch{width:14px;height:3px;border-radius:2px;flex-shrink:0;}
.mb-cap{font-size:12px;line-height:1.55;color:#647071;margin:12px 0 0;max-width:780px;}
.mb-rows{display:flex;flex-direction:column;}
.mb-colhdr,.mb-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:26px;align-items:center;}
.mb-colhdr{padding:0 0 12px;border-bottom:1px solid #e3ddd1;}
.mb-colhdr span{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.11em;text-transform:uppercase;color:#647071;}
.mb-row{padding:18px 0;border-top:1px solid #f0ece4;position:relative;}
.mb-row:first-of-type{border-top:none;}
.mb-row-x{position:absolute;top:8px;right:0;width:24px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;cursor:pointer;color:#c9c2b4;font-size:15px;line-height:1;padding:0;border-radius:4px;transition:color .15s;z-index:2;}
.mb-row-x:hover{color:#b34030;}
.mb-add-btn{background:#faf9f7;border:1px solid #d4cfc5;border-radius:99px;padding:4px 12px;font:10px/1 'IBM Plex Mono',monospace;letter-spacing:.04em;color:#5a6a72;cursor:pointer;display:inline-flex;align-items:center;gap:5px;}
.mb-add-btn:hover{border-color:#1a4a6b;color:#1a4a6b;}
.mb-add-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:20;background:#fff;border:1px solid #d4cfc5;border-radius:6px;box-shadow:0 10px 28px -14px rgba(40,30,15,.4);padding:6px;min-width:240px;max-height:300px;overflow-y:auto;}
.mb-add-item{display:block;width:100%;text-align:left;background:none;border:none;border-radius:4px;padding:7px 9px;font-size:12.5px;color:#1a1a1a;cursor:pointer;}
.mb-add-item:hover{background:#f5f0e8;}
.mb-add-empty{padding:10px 9px;font-size:12px;color:#647071;}
.mb-name{font-size:14px;font-weight:500;color:#1a1a1a;letter-spacing:-.01em;}
.mb-def{font-size:11px;color:#647071;margin-top:2px;line-height:1.4;}
.mb-read{display:flex;align-items:baseline;gap:9px;margin-top:9px;}
.mb-val{font-family:'IBM Plex Mono',monospace;font-size:19px;letter-spacing:-.02em;color:#1a1a1a;font-variant-numeric:tabular-nums;}
.mb-val.green{color:#2d7a47;}.mb-val.amber{color:#a1450f;}.mb-val.red{color:#b34030;}
.mb-delta{font-family:'IBM Plex Mono',monospace;font-size:11px;display:flex;align-items:center;gap:2px;}
.mb-delta.up{color:#2d7a47;}.mb-delta.down{color:#b34030;}.mb-delta.flat{color:#647071;}
.mb-delta i{font-size:13px;}
.mb-expo{display:flex;align-items:center;gap:12px;}
.mb-arrow{color:#c9c2b4;font-size:16px;flex-shrink:0;margin-left:-6px;}
.mb-expo-main{flex:1;min-width:0;}
.mb-bar{display:flex;height:10px;border:1px solid #d4cfc5;border-radius:3px;overflow:hidden;}
.mb-seg{height:100%;}
.mb-expo-meta{display:flex;justify-content:space-between;align-items:baseline;margin-top:7px;gap:12px;}
.mb-touch{font-size:12px;color:#5a6a72;line-height:1.4;}
.mb-touch b{color:#1a1a1a;font-weight:500;}
.mb-amt{font-family:'IBM Plex Mono',monospace;font-size:12px;color:#1a1a1a;font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right;}
.mb-amt small{display:block;font-size:12px;color:#647071;letter-spacing:.04em;margin-top:1px;}
.mb-key{display:flex;gap:18px;flex-wrap:wrap;margin-top:12px;}
.mb-key span{display:flex;align-items:center;gap:6px;font-size:11.5px;color:#5a6a72;}
.mb-key i{width:9px;height:9px;border-radius:2px;flex-shrink:0;}
@media(max-width:900px){.mb-colhdr,.mb-row{grid-template-columns:1fr;gap:12px;}
  .mb-colhdr span:nth-child(2),.mb-colhdr span:nth-child(3){display:none;}}

/* ── Optimizer (Save tab, below Projections) ── */
.op-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:14px;}
.op-controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
/* Universe pills. A locked (premium) pill stays visible and stays a button so it
   can explain itself on click — it is never the only thing standing between a
   user and the data. The server refuses the mode with 402 regardless.
   The .locked class is the ENTITLEMENT state, not the tier: a premium universe
   an entitled user can actually open renders as a normal, selectable pill
   carrying an unlocked .sp-prem-tag. */
.op-uni{display:flex;gap:5px;flex-wrap:wrap;}
.op-uni-pill{background:#faf9f7;border:1px solid #d4cfc5;border-radius:99px;padding:4px 12px;font:10px/1.4 'IBM Plex Mono',monospace;letter-spacing:.06em;color:#5a6a72;cursor:pointer;display:inline-flex;align-items:center;gap:5px;}
.op-uni-pill:hover{border-color:#1a4a6b;color:#1a4a6b;}
.op-uni-pill.active{background:#1a1a1a;border-color:#1a1a1a;color:#fff;}
.op-uni-pill.locked{color:#a0aab4;border-style:dashed;cursor:default;}
.op-uni-pill.locked:hover{border-color:#d4cfc5;color:#a0aab4;}
/* No .op-uni-pill i rule: the pill's only glyph now lives inside .sp-prem-tag
   and sizes itself. A descendant rule here would win on source order (equal
   specificity, later in the sheet) and silently resize the shared chip. */
.op-intro{font-size:13px;color:#5a6a72;line-height:1.65;max-width:640px;margin:0 0 16px;}
.op-meta{font-family:'IBM Plex Mono',monospace;font-size:11px;color:#647071;letter-spacing:.03em;line-height:1.7;margin-top:12px;}
.op-note{font-size:12px;color:#647071;line-height:1.55;margin-top:10px;}
/* Pick cards — the three named allocations, selectable */
/* auto-fit, not repeat(3): the row carries the three suggestions PLUS the user's
   own two selectable portfolios, and a goal with neither drops to three. */
.op-picks{display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));gap:10px;margin:16px 0 0;}
@media(max-width:700px){.op-picks{grid-template-columns:1fr;}}
.op-pick{text-align:left;background:#faf9f7;border:1px solid #d4cfc5;border-radius:4px;padding:11px 13px;cursor:pointer;transition:border-color .1s,background .1s;}
.op-pick:hover{border-color:#1a4a6b;}
.op-pick.sel{border-color:#1a4a6b;background:rgba(245,166,35,.09);}
.op-pick-hd{display:flex;align-items:center;gap:7px;margin-bottom:7px;}
.op-pick-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0;}
/* Diamond = one of the user's OWN two portfolios, matching the marker shape the
   chart draws them with. Shape carries that distinction, not colour. */
.op-pick-dot.diam{border-radius:1px;transform:rotate(45deg);}
.op-pick-name{font-size:12.5px;font-weight:500;color:#1a1a1a;}
.op-pick-val{font-family:'IBM Plex Mono',monospace;font-size:17px;letter-spacing:-.02em;color:#1a1a1a;font-variant-numeric:tabular-nums;}
.op-pick-sub{font-family:'IBM Plex Mono',monospace;font-size:11px;color:#647071;margin-top:3px;letter-spacing:.02em;}
/* Allocation comparison table */
.op-alloc{margin-top:18px;}
.op-alloc-hdr,.op-alloc-row{display:grid;grid-template-columns:1fr 72px 58px 58px 50px;gap:8px;align-items:center;}
.op-alloc-hdr{padding:0 0 7px;border-bottom:1px solid #e3ddd1;font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:#647071;}
.op-alloc-hdr span:not(:first-child){text-align:right;}
.op-alloc-row{padding:7px 0;border-bottom:1px solid #f0ede6;font-size:12.5px;}
.op-alloc-row:last-child{border-bottom:none;}
/* Two-line name cell: fund name on top, ticker + class beneath. align-items is
   flex-start (not center) so the class dot tracks the FIRST line, not the middle
   of a wrapped two-liner. */
.op-alloc-name{display:flex;align-items:flex-start;gap:7px;min-width:0;}
.op-alloc-dot{width:8px;height:8px;border-radius:2px;flex-shrink:0;margin-top:4px;}
.op-alloc-fund{font-size:12.5px;color:#1a1a1a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.op-alloc-sub{font-family:'IBM Plex Mono',monospace;font-size:10.5px;color:#647071;letter-spacing:.03em;margin-top:1px;}
/* The "everything under 1%" summary. Muted, no bar, no delta — it is a total, not
   a recommendation, and must not read as one more fund. */
.op-alloc-rest .op-alloc-fund{color:#647071;font-style:italic;}
.op-alloc-rest .op-num{color:#647071;}
.op-alloc-bar{height:8px;background:#f5f0e8;border-radius:2px;overflow:hidden;}
.op-alloc-fill{height:100%;}
.op-alloc-row span.op-num{text-align:right;font-family:'IBM Plex Mono',monospace;font-size:12px;color:#1a1a1a;font-variant-numeric:tabular-nums;}
.op-alloc-row span.op-num.muted{color:#647071;}
.op-delta-up{color:#2d7a47;}.op-delta-dn{color:#b34030;}
@media(max-width:700px){.op-alloc-hdr,.op-alloc-row{grid-template-columns:1fr 54px 54px 46px;}
  .op-alloc-hdr span:nth-child(2),.op-alloc-row div.op-alloc-bar{display:none;}}
/* Site footer styles live in nav.css */
`;


// ── Premium tag (shared by every gated panel) ────────────────────────────────
//
// **The single premium affordance.** Any control that fronts a premium feature
// renders THIS and nothing else — not a bare lock glyph, not a hand-rolled chip.
// Two panels each invented their own and they disagreed on both the shape and
// the state they showed: the Optimizer's universe pills drew a lock AND a
// "Premium" chip and never dropped either for a paying subscriber, while
// After-Tax Spendable Income drew a lock and no chip at all. Adding the third
// gated panel is what made that a pattern rather than two one-offs.
//
// `premium` is `user.tier === "premium"`, and it is **cosmetics only** — it
// picks a glyph, it decides nothing. `app.require_premium` (and the optimizer's
// own 402) is the boundary; a session proves who, not what. So a tag rendered
// `on` by a tampered client still buys nothing, and a tag rendered locked by a
// stale `user` costs a paying user one 200-response click, not access.
//
// Callers pass `premium` explicitly rather than reading a context, because the
// value that matters is the one the panel already resolved — a second read
// could disagree with the branch the panel took.
function PremiumTag({ premium }) {
  return (
    <span className={"sp-prem-tag" + (premium ? " on" : "")}
          title={premium ? "Premium — included in your plan"
                         : "Premium feature — not on your plan yet"}>
      <i className={premium ? "ti ti-lock-open" : "ti ti-lock"} aria-hidden="true"/>
      Premium
    </span>
  );
}


// ── Site footer ──────────────────────────────────────────────────────────────
function SiteFooter() {
  return (
    <footer className="sp-footer">
      <div className="sp-footer-inner">
        <div className="sp-footer-top">
          <div>
            <div className="sp-footer-brand">
              <svg width="20" height="20" viewBox="0 0 27 27" fill="none" aria-hidden="true">
                <rect x="3" y="15" width="5" height="9" rx="2.5" fill="#f5a623"/>
                <rect x="11" y="9" width="5" height="15" rx="2.5" fill="#f5a623"/>
                <rect x="19.85" y="4.85" width="4.3" height="18.3" rx="2.15" fill="none" stroke="#f5a623" strokeWidth="1.7"/>
              </svg>
              <span style={{fontSize:15,fontWeight:600,letterSpacing:"-.01em"}}>SavingsPhase</span>
            </div>
            <p className="sp-footer-disc">An informational tool, not financial advice. Plans and signals are based on your inputs and historical data. Always do your own research before investing.</p>
          </div>
          <div className="sp-footer-links">
            <div className="sp-footer-col">
              <span className="sp-footer-col-head">The flow</span>
              <a href="/#plan" className="sp-footer-link">Plan</a>
              <a href="/#portfolio" className="sp-footer-link">Save</a>
              <a href="/#dashboard" className="sp-footer-link">Track</a>
            </div>
            <div className="sp-footer-col">
              <span className="sp-footer-col-head">Site</span>
              <a href="/landing" className="sp-footer-link">About</a>
              <a href="/landing#how" className="sp-footer-link">How it works</a>
              <a href="/legal" className="sp-footer-link">Legal</a>
              <a href="/contact" className="sp-footer-link">Contact</a>
            </div>
          </div>
        </div>
        <div className="sp-footer-bottom">
          <span className="sp-footer-copy">&copy; 2026 SavingsPhase</span>
          <span className="sp-footer-made">Made in Canada &middot; savingsphase.ca</span>
        </div>
      </div>
    </footer>
  );
}

// ── Projection fan chart (pure SVG) ──────────────────────────────────────────
// `view` is the shared backend projection (absolute dollars, today's dollars), the
// same object behind the goal card and the plan document — so this chart's median
// line ends exactly on the card's headline number.
function ProjectionChart({ view }) {
  const currentYear = new Date().getFullYear();
  const mc = view;
  const nYears = mc.years.length;
  if (nYears < 2) return null;

  const p25 = mc.p25;
  const p50 = mc.p50;
  const p75 = mc.p75;

  const W = 600, H = 170, PT = 8, PB = 26, PL = 6, PR = 6;
  const cW = W - PL - PR, cH = H - PT - PB;
  const maxY = Math.max(...p75) * 1.06;
  const xp = i => PL + (i / (nYears - 1)) * cW;
  const yp = v => PT + cH * (1 - Math.min(v, maxY) / maxY);

  const linePath = arr => arr.map((v, i) => `${i===0?"M":"L"}${xp(i).toFixed(1)},${yp(v).toFixed(1)}`).join(" ");

  const areaPath = [
    `M${xp(0).toFixed(1)},${(PT+cH).toFixed(1)}`,
    ...p50.map((v, i) => `L${xp(i).toFixed(1)},${yp(v).toFixed(1)}`),
    `L${xp(nYears-1).toFixed(1)},${(PT+cH).toFixed(1)}`, "Z"
  ].join(" ");

  const bandPath = [
    ...p25.map((v, i) => `${i===0?"M":"L"}${xp(i).toFixed(1)},${yp(v).toFixed(1)}`),
    ...[...p75].reverse().map((v, i) => `L${xp(nYears-1-i).toFixed(1)},${yp(v).toFixed(1)}`), "Z"
  ].join(" ");

  // X-axis labels every 4 years, aligned to 4-year calendar intervals
  const xLabels = [];
  const firstFour = Math.ceil(currentYear / 4) * 4;
  const retirementYear = currentYear + mc.years[nYears - 1];
  for (let i = 0; i < nYears; i++) {
    const yr = currentYear + mc.years[i];
    if (yr === firstFour || (yr > firstFour && yr % 4 === 0)) {
      if (Math.abs(yr - retirementYear) >= 2) xLabels.push({ i, yr });
    } else if (i === nYears - 1) {
      xLabels.push({ i, yr });
    }
  }

  return (
    <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
      <defs>
        <linearGradient id="pjGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#f5a623" stopOpacity="0.18"/>
          <stop offset="100%" stopColor="#f5a623" stopOpacity="0.02"/>
        </linearGradient>
      </defs>
      <path d={bandPath} fill="#f5a623" fillOpacity="0.07"/>
      <path d={areaPath} fill="url(#pjGrad)"/>
      <path d={linePath(p50)} fill="none" stroke="#f5a623" strokeWidth="1.5" strokeLinejoin="round"/>
      {xLabels.map(({i, yr}) => (
        <text key={yr} x={xp(i)} y={H - 8}
          textAnchor={i === 0 ? "start" : i === nYears - 1 ? "end" : "middle"}
          fontSize="9.5" fill="#647071" fontFamily="'IBM Plex Mono',monospace">
          {yr}
        </text>
      ))}
    </svg>
  );
}

// Compact goal-card trajectory: the median (p50) projection path in today's dollars,
// an area fill, a dotted horizontal target line, a hollow "today" start dot and a
// filled end dot. `path` is the backend's absolute-dollar p50 series, so its last
// point IS the card's headline projected value — the end dot can never disagree with
// the number above it. Same look as the bigger Save-tab projection charts.
function GoalTrajChart({ uid, path, target, color }) {
  const pts = Array.isArray(path) && path.length >= 2 ? path : null;
  if (!pts) return null;
  const n    = pts.length;
  const proj = pts[n - 1];

  const hasTarget = target != null && target > 0;
  let lo = Math.min(...pts, ...(hasTarget ? [target] : []));
  let hi = Math.max(...pts, ...(hasTarget ? [target] : []));
  if (hi <= lo) hi = lo + (Math.abs(lo) || 1);
  const pad  = (hi - lo) * 0.16;
  const yMin = lo - pad, yMax = hi + pad;

  const W = 400, H = 80, PL = 4, PR = 4, PT = 8, PB = 10;
  const cW = W - PL - PR, cH = H - PT - PB;
  const xp = i => PL + (i / (n - 1)) * cW;
  const yp = v => PT + cH * (1 - (v - yMin) / (yMax - yMin));

  const line = pts.map((v, i) => `${i === 0 ? "M" : "L"}${xp(i).toFixed(1)},${yp(v).toFixed(1)}`).join(" ");
  const area = `M${xp(0).toFixed(1)},${(PT + cH).toFixed(1)} `
    + pts.map((v, i) => `L${xp(i).toFixed(1)},${yp(v).toFixed(1)}`).join(" ")
    + ` L${xp(n - 1).toFixed(1)},${(PT + cH).toFixed(1)} Z`;
  const gid = "gtg-" + uid;

  return (
    <svg className="pf-gcard-chart" viewBox={`0 0 ${W} ${H}`} width="100%" height="68"
         preserveAspectRatio="none" style={{display:"block"}}>
      <defs>
        <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={color} stopOpacity="0.16"/>
          <stop offset="100%" stopColor={color} stopOpacity="0"/>
        </linearGradient>
      </defs>
      <path d={area} fill={`url(#${gid})`}/>
      {hasTarget && (
        <line x1={PL} y1={yp(target).toFixed(1)} x2={W - PR} y2={yp(target).toFixed(1)}
          stroke="#c9c2b4" strokeWidth="1" strokeDasharray="3 3"/>
      )}
      <path d={line} fill="none" stroke={color} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
      <circle cx={xp(n - 1)} cy={yp(proj)} r="3" fill={color}/>
    </svg>
  );
}

// ── Portfolio Lifecycle chart (accumulation fan + decumulation plan path, pure SVG) ──
// `baseline` is the optional benchmark overlay from GET /api/portfolio/baseline/
// <goal> — the same goal run under plan_alpha's managed-account assumptions
// (1.05% all-in, 60/40 in every account, proportional drawdown). Absent or
// `visible:false` draws nothing at all; the backend owns that decision, because
// a balance axis cannot show the sequencing half of the advantage and can
// invert on it. Never re-derive it here — see app._baseline_gate for the
// measured cases and why "mostly below" is not good enough.
function LifecycleChart({ proj, total, currentYear, baseline }) {
  const mc    = proj.accumulation;
  const decum = proj.decumulation;
  if (!mc || !mc.years || mc.years.length < 2) return null;
  total = Math.max(total, 1);
  const bl = (baseline && baseline.visible) ? baseline : null;

  const hasDecum = !!(decum && decum.ages && decum.ages.length > 0);
  const retireYear = currentYear + mc.years[mc.years.length - 1];

  // Accumulation bands in real $ (nom_pct are relative multiples × total)
  const accumYears = mc.years.map(y => currentYear + y);
  const aB = [0,1,2,3,4].map(pi => mc.real_pct[pi].map(v => v * total));
  const retBands$ = [0,1,2,3,4].map(pi => aB[pi][aB[pi].length - 1]);

  // Decumulation center + inherited MC fan
  let decumYears = [], dCenter = [], dP10 = [], dP25 = [], dP75 = [], dP90 = [];
  let cppYear = retireYear + 5;
  // Single "Gov't Pension" marker at the earlier of the two benefit start ages
  // (matches the FI-number phase boundary), regardless of whether they differ.
  const govStartAge = hasDecum
    ? Math.min(decum.cpp_start_age || 70, decum.oas_start_age || 70)
    : 70;
  if (hasDecum) {
    cppYear = retireYear + (govStartAge - decum.retire_age);
    const center0 = Math.max(1, (decum.rrsp[0]||0) + (decum.taxable[0]||0) + (decum.tfsa[0]||0));
    const dSpread = {
      p10: Math.max(0, center0 - retBands$[0]),
      p25: Math.max(0, center0 - retBands$[1]),
      p75: Math.max(0, retBands$[3] - center0),
      p90: Math.max(0, retBands$[4] - center0),
    };
    const decumN = Math.max(1, decum.ages.length - 1);
    const fanVol = (decum.decum_vol || 0.06) * Math.sqrt(decumN);
    decum.ages.forEach((age, t) => {
      const yr = retireYear + (age - decum.retire_age);
      const v  = Math.max(0, (decum.rrsp[t]||0) + (decum.taxable[t]||0) + (decum.tfsa[t]||0));
      const fan = fanVol * Math.pow(t / decumN, 2);
      decumYears.push(yr);
      dCenter.push(v);
      dP10.push(Math.max(0, v - dSpread.p10 * Math.exp(fan)));
      dP25.push(Math.max(0, v - dSpread.p25 * Math.exp(fan * 0.526)));
      dP75.push(v + dSpread.p75 * Math.exp(fan * 0.526));
      dP90.push(v + dSpread.p90 * Math.exp(fan));
    });
  }

  // Chart geometry
  const W = 680, H = 230, PT = 14, PB = 28, PL = 60, PR = 12;
  const cW = W - PL - PR, cH = H - PT - PB;
  const minYear = accumYears[0];
  const maxYear = hasDecum ? decumYears[decumYears.length - 1] : accumYears[accumYears.length - 1];

  const allPeak = [...aB[4], ...(hasDecum ? dP90.slice(0, 6) : [])].filter(v => v > 0);
  const axisMax = Math.max(...allPeak, 1) * 1.12;

  const xp = yr => PL + ((yr - minYear) / Math.max(maxYear - minYear, 1)) * cW;
  const yp = v  => PT + cH * (1 - Math.min(v, axisMax) / axisMax);

  const lp = (yrs, vals) => vals.map((v, i) =>
    `${i===0?"M":"L"}${xp(yrs[i]).toFixed(1)},${yp(v).toFixed(1)}`).join(" ");

  const bp = (yrs, lo, hi) => [
    ...lo.map((v, i)  => `${i===0?"M":"L"}${xp(yrs[i]).toFixed(1)},${yp(v).toFixed(1)}`),
    ...[...hi].reverse().map((v, i) => `L${xp(yrs[lo.length-1-i]).toFixed(1)},${yp(v).toFixed(1)}`),
    "Z"
  ].join(" ");

  // Y ticks
  const rawStep = axisMax / 5;
  const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
  const step = [1,2,2.5,5,10].map(m => m * mag).find(s => rawStep / s <= 1) || mag * 10;
  const yTicks = [];
  for (let v = step; v < axisMax * 1.05; v += step) yTicks.push(Math.round(v));
  const fmtY = v => v >= 1e6 ? `$${(v/1e6).toFixed(1)}M` : `$${Math.round(v/1000)}k`;

  // X ticks: every 5 years + retirement year
  const xTicks = [];
  for (let yr = Math.ceil(minYear / 5) * 5; yr <= maxYear; yr += 5)
    if (Math.abs(yr - retireYear) >= 2) xTicks.push(yr);
  xTicks.push(retireYear);

  const BLUE = "50,102,173";
  // The one deliberate chart neutral (style.md § Line charts) — a reference
  // line, not a fifth categorical hue. Thinner and dashed so the plan's own
  // path stays the subject even where the two run close together.
  const SLATE = "90,106,114";

  // Baseline overlay, mapped onto the axis the plan already established. Both
  // arms share `retire_age` and the same year offsets by construction (same
  // engine, same inputs), so no re-alignment is needed — and none should be
  // invented: a baseline that had to be stretched to fit is a baseline of a
  // different plan.
  const blAccumYears = bl ? bl.accumulation.years.map(y => currentYear + y) : [];
  const blAccum      = bl ? bl.accumulation.p50 : [];
  const blDecum      = bl && bl.decumulation ? bl.decumulation : null;
  const blDecumYears = blDecum
    ? blDecum.ages.map(age => retireYear + (age - blDecum.retire_age)) : [];

  const showCpp = hasDecum && cppYear > retireYear && cppYear <= maxYear;

  return (
    <div>
      <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
        {/* Y gridlines + labels */}
        {yTicks.map(v => (
          <g key={v}>
            <line x1={PL} y1={yp(v)} x2={W-PR} y2={yp(v)} stroke="rgba(0,0,0,0.04)" strokeWidth={1}/>
            <text x={PL-5} y={yp(v)+3.5} textAnchor="end" fontSize={9} fill="#94a3b8"
              fontFamily="system-ui">{fmtY(v)}</text>
          </g>
        ))}
        {/* Accum outer band */}
        <path d={bp(accumYears, aB[0], aB[4])}
          fill={`rgba(${BLUE},0.08)`} stroke={`rgba(${BLUE},0.15)`} strokeWidth={0.5}/>
        {/* Accum inner band */}
        <path d={bp(accumYears, aB[1], aB[3])}
          fill={`rgba(${BLUE},0.18)`} stroke={`rgba(${BLUE},0.30)`} strokeWidth={0.5}/>
        {/* Decum outer band */}
        {hasDecum && dP90.length > 1 && (
          <path d={bp(decumYears, dP10, dP90)}
            fill={`rgba(${BLUE},0.06)`} stroke={`rgba(${BLUE},0.12)`} strokeWidth={0.5}/>
        )}
        {/* Decum inner band */}
        {hasDecum && dP75.length > 1 && (
          <path d={bp(decumYears, dP25, dP75)}
            fill={`rgba(${BLUE},0.13)`} stroke={`rgba(${BLUE},0.22)`} strokeWidth={0.5}/>
        )}
        {/* Baseline overlay — drawn under the plan's own lines, never over them */}
        {bl && blAccum.length > 1 && (
          <path d={lp(blAccumYears, blAccum)} fill="none"
            stroke={`rgba(${SLATE},0.70)`} strokeWidth={1.75}
            strokeDasharray="5 3" strokeLinejoin="round"/>
        )}
        {bl && blDecum && blDecum.balance.length > 1 && (
          <path d={lp(blDecumYears, blDecum.balance)} fill="none"
            stroke={`rgba(${SLATE},0.70)`} strokeWidth={1.75}
            strokeDasharray="5 3" strokeLinejoin="round"/>
        )}
        {/* Accum median */}
        <path d={lp(accumYears, aB[2])} fill="none"
          stroke={`rgba(${BLUE},0.90)`} strokeWidth={2.5} strokeLinejoin="round"/>
        {/* Decum plan path */}
        {hasDecum && dCenter.length > 1 && (
          <path d={lp(decumYears, dCenter)} fill="none"
            stroke={`rgba(${BLUE},0.85)`} strokeWidth={2.5} strokeLinejoin="round"/>
        )}
        {/* Retirement vertical */}
        <line x1={xp(retireYear)} y1={PT} x2={xp(retireYear)} y2={H-PB}
          stroke="rgba(39,174,96,0.55)" strokeWidth={1.5} strokeDasharray="6 4"/>
        {/* Gov't Pension vertical */}
        {showCpp && (
          <line x1={xp(cppYear)} y1={PT} x2={xp(cppYear)} y2={H-PB}
            stroke="rgba(180,83,9,0.45)" strokeWidth={1.5} strokeDasharray="3 4"/>
        )}
        {/* X axis */}
        <line x1={PL} y1={H-PB} x2={W-PR} y2={H-PB} stroke="#e3ddd1" strokeWidth={0.5}/>
        {xTicks.map(yr => (
          <text key={yr} x={xp(yr)} y={H-PB+14} textAnchor="middle"
            fontSize={9} fill="#94a3b8" fontFamily="system-ui">{yr}</text>
        ))}
      </svg>
      {/* Legend */}
      <div style={{display:"flex",gap:16,justifyContent:"center",margin:"8px 0 6px",flexWrap:"wrap",alignItems:"center"}}>
        {/* Shaded band swatch */}
        <span style={{display:"flex",alignItems:"center",gap:5,fontFamily:"system-ui",fontSize:10,color:"#5a6a72"}}>
          <svg width={18} height={10}>
            <rect x={0} y={0} width={18} height={10} rx={2}
              fill={`rgba(${BLUE},0.18)`} stroke={`rgba(${BLUE},0.30)`} strokeWidth={0.75}/>
          </svg>
          Prob. distribution
        </span>
        {[
          // ONE entry for the blue line, not two. It was "Base" + "Plan Path" —
          // the accumulation median and the decumulation path, which are two
          // segments of a single continuous line at the same colour and width
          // (0.90 vs 0.85 alpha is not a distinction anyone can see). A legend
          // that names one line twice makes a reader hunt for a second line
          // that does not exist. The phase boundary is already marked by the
          // retirement rule.
          { label:"Optimized", col:`rgba(${BLUE},0.90)`, dash:false },
          ...(bl ? [{ label:"Typical portfolio", col:`rgba(${SLATE},0.70)`, dash:true }] : []),
          { label:`Retirement ${retireYear}`, col:"rgba(39,174,96,0.65)",  dash:true },
          ...(showCpp ? [{ label:`Gov't Pension (${govStartAge})`, col:"rgba(180,83,9,0.55)", dash:true }] : []),
        ].map(({ label, col, dash }) => (
          <span key={label} style={{display:"flex",alignItems:"center",gap:5,
            fontFamily:"system-ui",fontSize:10,color:"#5a6a72"}}>
            <svg width={18} height={8}>
              <line x1={0} y1={4} x2={18} y2={4} stroke={col} strokeWidth={2}
                strokeDasharray={dash ? "5 3" : "none"}/>
            </svg>
            {label}
          </span>
        ))}
      </div>
      {/* THIS PLAN'S RESULT ONLY. What a "typical portfolio" *is* — 1.05%
          all-in, 60/40 in every account, proportional withdrawals — moved to
          the Help Center (`what-optimized-means`), reachable from the card's
          help chip: it is the same three sentences for every user and does not
          belong under every chart. These two numbers are not: they are this
          plan's, they change with it, and the income pair especially cannot
          move, because both plans exhaust capital at the same age, so the wedge
          closes to $0 at the right edge and a reader with only the lines would
          conclude the advantage evaporated. It did not — it was spent. */}
      {bl && (bl.gap_at_retirement > 0 || (blDecum && blDecum.smile_peak > 0)) && (
        <div style={{fontSize:10,color:"#647071",fontFamily:"system-ui",
          lineHeight:1.55,textAlign:"center",margin:"0 auto 2px",maxWidth:560}}>
          A typical portfolio
          {/* Each clause is guarded on its own number being positive. The gate
              tolerates the baseline leading by up to 0.5% of peak at any single
              year, so `gap_at_retirement` can legitimately come back ~0 or
              slightly negative on a plan whose wedge opens only in
              decumulation — and fmtCompact would render that as "$-7000". */}
          {bl.gap_at_retirement > 0 && <>
            {" "}reaches retirement with <b style={{color:"#1a1a1a",fontWeight:500}}>
            {fmtCompact(bl.gap_at_retirement)} less</b>
          </>}
          {blDecum && blDecum.smile_peak > 0 && bl.plan_smile_peak > 0 && <>
            {bl.gap_at_retirement > 0 ? " and sustains " : " sustains "}
            <b style={{color:"#1a1a1a",fontWeight:500}}>
              {fmtCompact(blDecum.smile_peak)}/yr</b> against your plan's
            {" "}{fmtCompact(bl.plan_smile_peak)}/yr
          </>}.
        </div>
      )}
    </div>
  );
}

// ── Contributions Over Time (grouped bar: market gain + contributions + planned) ─
function ContribHistoryChart({ history, annualSavings }) {
  if (!history || !history.dates || history.dates.length === 0) return null;

  const hasCon = history.contribs && history.contribs.some(v => v != null && v !== 0);

  const byYear = {};
  history.dates.forEach((d, i) => {
    const yr = d.slice(0, 4);
    byYear[yr] = byYear[yr] || {};
    byYear[yr].close = history.values[i];
    if (history.contribs[i] != null) byYear[yr].contrib = history.contribs[i];
  });

  // Year 1's opening balance is the portfolio's seed (earliest snapshot total),
  // not 0 — otherwise the initial funding (e.g. a seeded $750k) is miscounted as
  // a market gain. Later years open at the prior year's close.
  const seed = history.seed || 0;
  const histYears = Object.keys(byYear).sort();
  histYears.forEach((yr, j) => {
    const prev = j > 0 ? byYear[histYears[j - 1]].close : seed;
    byYear[yr].open = prev;
    const c = byYear[yr].contrib ?? 0;
    byYear[yr].gain = byYear[yr].close - prev - c;
  });

  const lastActual = histYears.length > 0 ? parseInt(histYears[histYears.length - 1]) : new Date().getFullYear();
  const mcContrib  = annualSavings || 0;
  const fwdYears   = mcContrib > 0 ? [lastActual + 1, lastActual + 2, lastActual + 3].map(String) : [];
  const allYears   = [...histYears, ...fwdYears];
  const nYears     = allYears.length;
  if (nYears === 0) return null;

  const gainData   = histYears.map(yr => byYear[yr].gain ?? null);
  const contribData= histYears.map(yr => hasCon ? (byYear[yr].contrib ?? null) : null);

  const allGains = gainData.filter(v => v != null);
  const allCon   = contribData.filter(v => v != null);
  if (mcContrib > 0) allCon.push(mcContrib);

  const yMaxRaw = Math.max(0, ...allGains.filter(v => v > 0), ...allCon) * 1.08;
  const yMinRaw = Math.min(0, ...allGains.filter(v => v < 0)) * 1.08;
  const yMax    = yMaxRaw || 1;
  const yMin    = yMinRaw;
  const yRange  = yMax - yMin;

  const W = 680, H = 210, PT = 14, PB = 28, PL = 56, PR = 12;
  const cW = W - PL - PR, cH = H - PT - PB;

  const yp       = v  => PT + cH * (1 - (v - yMin) / yRange);
  const baseline = yp(0);

  const groupW = cW / nYears;
  const barW   = Math.max(3, groupW * 0.38);
  const barGap = 2;
  const xGroup = i => PL + i * groupW + groupW / 2 - barW - barGap / 2;

  const rawStep = (yMax - yMin) / 5;
  const mag  = Math.pow(10, Math.floor(Math.log10(rawStep || 1)));
  const step = ([1, 2, 2.5, 5, 10].map(m => m * mag).find(s => rawStep / s <= 1)) || mag * 10;
  const yTicks = [];
  for (let v = Math.ceil(yMin / step) * step; v <= yMax * 1.01; v = Math.round((v + step) * 1e6) / 1e6) {
    yTicks.push(v);
    if (yTicks.length > 10) break;
  }
  const fmtY  = v => v < 0 ? `-$${Math.round(-v / 1000)}K` : `$${Math.round(v / 1000)}K`;
  const fmtTT = v => (v < 0 ? "-" : "") + "$" + Math.abs(Math.round(v)).toLocaleString("en-CA");

  const [hoverYr, setHoverYr] = React.useState(null);

  const renderBar = (x, value, fill, border) => {
    if (value == null) return null;
    const top = yp(Math.max(value, 0));
    const bot = yp(Math.min(value, 0));
    const h   = Math.max(bot - top, 1);
    return <rect x={x.toFixed(1)} y={top.toFixed(1)} width={barW.toFixed(1)} height={h.toFixed(1)}
                 fill={fill} stroke={border} strokeWidth={0.5} rx={1}/>;
  };

  const ttLines = yr => {
    if (!yr) return [];
    const isFwd = fwdYears.includes(yr);
    if (isFwd) return mcContrib > 0 ? [`Planned: ${fmtTT(mcContrib)}/yr`] : [];
    const gv = byYear[yr]?.gain;
    const cv = byYear[yr]?.contrib;
    const lines = [];
    if (gv != null) lines.push((gv < 0 ? "Market loss: " : "Market gain: ") + fmtTT(gv));
    if (cv != null) lines.push("Contributions: " + fmtTT(cv));
    return lines;
  };

  const ttData  = ttLines(hoverYr);
  const ttIdx   = hoverYr ? allYears.indexOf(hoverYr) : -1;
  const ttX     = ttIdx >= 0 ? Math.min(PL + ttIdx * groupW + groupW, W - 122) : 0;

  const GAIN_F  = "rgba(52,211,153,0.45)",  GAIN_B  = "rgba(52,211,153,0.75)";
  const LOSS_F  = "rgba(248,113,113,0.50)", LOSS_B  = "rgba(248,113,113,0.75)";
  const CON_F   = "rgba(56,189,248,0.35)",  CON_B   = "rgba(56,189,248,0.60)";
  const FWD_F   = "rgba(100,116,139,0.20)", FWD_B   = "rgba(100,116,139,0.40)";

  return (
    <div>
      <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}
           onMouseLeave={() => setHoverYr(null)}>
        {yTicks.map(v => (
          <g key={v}>
            <line x1={PL} y1={yp(v).toFixed(1)} x2={W - PR} y2={yp(v).toFixed(1)}
                  stroke="rgba(0,0,0,0.04)" strokeWidth={1}/>
            <text x={PL - 4} y={(yp(v) + 3.5).toFixed(1)} textAnchor="end"
                  fontSize={9} fill="#94a3b8" fontFamily="system-ui">{fmtY(v)}</text>
          </g>
        ))}
        <line x1={PL} y1={baseline.toFixed(1)} x2={W - PR} y2={baseline.toFixed(1)}
              stroke="#d4cfc5" strokeWidth={0.75}/>
        {allYears.map((yr, i) => {
          const isFwd  = i >= histYears.length;
          const x0     = xGroup(i);
          const gainV  = !isFwd ? gainData[i] : null;
          const conV   = !isFwd ? contribData[i] : null;
          const fwdV   = isFwd ? mcContrib : null;
          return (
            <g key={yr} onMouseEnter={() => setHoverYr(yr)} style={{cursor:"default"}}>
              {!isFwd && renderBar(x0, gainV, gainV != null && gainV < 0 ? LOSS_F : GAIN_F, gainV != null && gainV < 0 ? LOSS_B : GAIN_B)}
              {!isFwd && renderBar(x0 + barW + barGap, conV, CON_F, CON_B)}
              {isFwd  && renderBar(x0, fwdV, FWD_F, FWD_B)}
            </g>
          );
        })}
        <line x1={PL} y1={H - PB} x2={W - PR} y2={H - PB} stroke="#e3ddd1" strokeWidth={0.5}/>
        {allYears.map((yr, i) => {
          if (nYears > 10 && i % 2 !== 0) return null;
          return (
            <text key={yr} x={(PL + i * groupW + groupW / 2).toFixed(1)} y={H - PB + 11}
                  textAnchor="middle" fontSize={9} fill="#94a3b8" fontFamily="system-ui">{yr}</text>
          );
        })}
        {hoverYr && ttData.length > 0 && (
          <g>
            <rect x={ttX} y={PT} width={118} height={ttData.length * 14 + 20}
                  fill="rgba(250,249,247,0.97)" stroke="#d4cfc5" strokeWidth={0.75} rx={3}/>
            <text x={ttX + 7} y={PT + 13} fontSize={9} fill="#1a1a1a"
                  fontFamily="system-ui" fontWeight="600">{hoverYr}</text>
            {ttData.map((l, j) => (
              <text key={j} x={ttX + 7} y={PT + 13 + (j + 1) * 13}
                    fontSize={9} fill="#5a6a72" fontFamily="system-ui">{l}</text>
            ))}
          </g>
        )}
      </svg>
      <div style={{display:"flex",gap:14,justifyContent:"center",margin:"6px 0 4px",flexWrap:"wrap",alignItems:"center"}}>
        {hasCon && (
          <>
            <span style={{display:"flex",alignItems:"center",gap:5,fontFamily:"system-ui",fontSize:10,color:"#5a6a72"}}>
              <svg width={14} height={10}><rect x={0} y={0} width={14} height={10} rx={1} fill={GAIN_F} stroke={GAIN_B} strokeWidth={0.75}/></svg>
              Market gain
            </span>
            <span style={{display:"flex",alignItems:"center",gap:5,fontFamily:"system-ui",fontSize:10,color:"#5a6a72"}}>
              <svg width={14} height={10}><rect x={0} y={0} width={14} height={10} rx={1} fill={CON_F} stroke={CON_B} strokeWidth={0.75}/></svg>
              Contributions
            </span>
          </>
        )}
        {mcContrib > 0 && (
          <span style={{display:"flex",alignItems:"center",gap:5,fontFamily:"system-ui",fontSize:10,color:"#5a6a72"}}>
            <svg width={14} height={10}><rect x={0} y={0} width={14} height={10} rx={1} fill={FWD_F} stroke={FWD_B} strokeWidth={0.75}/></svg>
            Planned ({fmtCompact(mcContrib)}/yr)
          </span>
        )}
      </div>
    </div>
  );
}

// ── Account Balances Over Time (stacked area: RRSP / Taxable / TFSA) ─────────────
function AccountBalancesChart({ decum }) {
  if (!decum || !decum.ages || decum.ages.length < 2) return null;
  const ages    = decum.ages;
  const n       = ages.length;
  const rrsp    = decum.rrsp    || [];
  const taxable = decum.taxable || [];
  const tfsa    = decum.tfsa    || [];
  // Legacy floor, today's dollars. Inert at $0: die-with-zero is the default and
  // a $0 floor sitting on the x-axis is not a reference line.
  const estate  = Math.max(0, Number(decum.estate_target) || 0);

  const W = 680, H = 200, PT = 14, PB = 28, PL = 60, PR = 12;
  const cW = W - PL - PR, cH = H - PT - PB;

  const totals   = ages.map((_, i) => (rrsp[i]||0) + (taxable[i]||0) + (tfsa[i]||0));
  // `estate` participates in the scale so a floor the balances never reach still
  // lands on the chart — an unreachable legacy target is exactly what must be
  // visible, not clipped off the top.
  const axisMax  = Math.max(...totals, estate, 1) * 1.08;

  const xp = i => PL + (i / Math.max(n - 1, 1)) * cW;
  const yp = v  => PT + cH * (1 - Math.min(v, axisMax) / axisMax);

  const stackedArea = (loVals, hiVals) => {
    const fwd = hiVals.map((v, i) => `${i===0?"M":"L"}${xp(i).toFixed(1)},${yp(v).toFixed(1)}`);
    const rev = [...loVals].reverse().map((v, i) =>
      `L${xp(n - 1 - i).toFixed(1)},${yp(v).toFixed(1)}`);
    return [...fwd, ...rev, "Z"].join(" ");
  };

  const zeros   = ages.map(() => 0);
  const tfsaTop = ages.map((_, i) => tfsa[i]||0);
  const taxTop  = ages.map((_, i) => (tfsa[i]||0) + (taxable[i]||0));
  const rrspTop = ages.map((_, i) => (tfsa[i]||0) + (taxable[i]||0) + (rrsp[i]||0));

  const rawStep = axisMax / 5;
  const mag  = Math.pow(10, Math.floor(Math.log10(rawStep)));
  const step = [1,2,2.5,5,10].map(m => m * mag).find(s => rawStep / s <= 1) || mag * 10;
  const yTicks = [];
  for (let v = step; v < axisMax * 1.05; v += step) yTicks.push(Math.round(v));
  const fmtY = v => v >= 1e6 ? `$${(v/1e6).toFixed(1)}M` : `$${Math.round(v/1000)}k`;

  // Single "Gov't Pension" marker at the earlier of the two benefit start ages.
  const cppAge = Math.min(decum.cpp_start_age || 70, decum.oas_start_age || 70);
  const cppIdx = ages.indexOf(cppAge);
  const showCpp = cppIdx > 0 && cppIdx < n - 1;

  // Estate line: the last ~28% of the plot, so it reads as a floor the balances
  // land on rather than a gridline spanning the whole retirement.
  const estX0  = PL + cW * 0.72;
  const ESTATE_C = "#5a6a72";   // neutral on purpose — a reference, not a verdict

  const TFSA_C = "rgba(22,160,133,";
  const TXBL_C = "rgba(194,87,26,";
  const RRSP_C = "rgba(91,79,168,";

  return (
    <div>
      <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
        {yTicks.map(v => (
          <g key={v}>
            <line x1={PL} y1={yp(v)} x2={W-PR} y2={yp(v)} stroke="rgba(0,0,0,0.04)" strokeWidth={1}/>
            <text x={PL-5} y={yp(v)+3.5} textAnchor="end" fontSize={9} fill="#94a3b8" fontFamily="system-ui">{fmtY(v)}</text>
          </g>
        ))}
        <path d={stackedArea(zeros,   tfsaTop)} fill={`${TFSA_C}0.45)`} stroke={`${TFSA_C}0.65)`} strokeWidth={0.5}/>
        <path d={stackedArea(tfsaTop, taxTop)}  fill={`${TXBL_C}0.45)`} stroke={`${TXBL_C}0.65)`} strokeWidth={0.5}/>
        <path d={stackedArea(taxTop,  rrspTop)} fill={`${RRSP_C}0.40)`} stroke={`${RRSP_C}0.60)`} strokeWidth={0.5}/>
        {showCpp && (
          <line x1={xp(cppIdx)} y1={PT} x2={xp(cppIdx)} y2={H-PB}
            stroke="rgba(180,83,9,0.45)" strokeWidth={1.5} strokeDasharray="3 4"/>
        )}
        {estate > 0 && (
          // Drawn after the areas so it sits on top of them: the whole point is
          // that a breach shows as the stack ending BELOW the line, right where
          // the user is already reading the withdrawal chart. Never a clamp on
          // the series — see save-tab.md § What not to change.
          <>
            <line x1={estX0} y1={yp(estate)} x2={W-PR} y2={yp(estate)}
              stroke={ESTATE_C} strokeWidth={1.5} strokeDasharray="5 3"/>
            {/* White halo (paint-order) — the label sits over the stacked fills
                whenever the balances are still above the floor. */}
            <text x={W-PR} y={yp(estate)-5} textAnchor="end" fontSize={9}
              fill={ESTATE_C} fontFamily="system-ui"
              stroke="#fff" strokeWidth={2.5} paintOrder="stroke">Estate</text>
          </>
        )}
        <line x1={PL} y1={H-PB} x2={W-PR} y2={H-PB} stroke="#e3ddd1" strokeWidth={0.5}/>
        {ages.map((age, i) => {
          if (age % 3 !== 0) return null;
          return (
            <text key={age} x={xp(i)} y={H-PB+14} textAnchor="middle"
              fontSize={9} fill="#94a3b8" fontFamily="system-ui">{age}</text>
          );
        })}
      </svg>
      <div style={{display:"flex",gap:16,justifyContent:"center",margin:"8px 0 4px",flexWrap:"wrap",alignItems:"center"}}>
        {[
          {label:"TFSA",    col:TFSA_C},
          {label:"Taxable", col:TXBL_C},
          {label:"RRSP",    col:RRSP_C},
        ].map(({label, col}) => (
          <span key={label} style={{display:"flex",alignItems:"center",gap:5,fontFamily:"system-ui",fontSize:10,color:"#5a6a72"}}>
            <svg width={18} height={10}>
              <rect x={0} y={0} width={18} height={10} rx={2}
                fill={`${col}0.50)`} stroke={`${col}0.70)`} strokeWidth={0.75}/>
            </svg>
            {label}
          </span>
        ))}
        {showCpp && (
          <span style={{display:"flex",alignItems:"center",gap:5,fontFamily:"system-ui",fontSize:10,color:"#5a6a72"}}>
            <svg width={18} height={8}>
              <line x1={0} y1={4} x2={18} y2={4} stroke="rgba(180,83,9,0.55)" strokeWidth={2} strokeDasharray="5 3"/>
            </svg>
            Gov't Pension at {cppAge}
          </span>
        )}
        {estate > 0 && (
          <span style={{display:"flex",alignItems:"center",gap:5,fontFamily:"system-ui",fontSize:10,color:"#5a6a72"}}>
            <svg width={18} height={8}>
              <line x1={0} y1={4} x2={18} y2={4} stroke={ESTATE_C} strokeWidth={2} strokeDasharray="5 3"/>
            </svg>
            Estate target ({fmtY(estate)})
          </span>
        )}
      </div>
    </div>
  );
}

// ── Withdrawal & Income Detail modal ─────────────────────────────────────────
function DecumDetailModal({ decum, onClose }) {
  const ages      = decum.ages       || [];
  const drawRrsp  = decum.draw_rrsp  || [];
  const drawTax   = decum.draw_taxable|| [];
  const drawTfsa  = decum.draw_tfsa  || [];
  const cpp_oas   = decum.cpp_oas    || [];
  // Employer pension / annuity — its own column, and only when there is one.
  // `withdrawal` (Total Gross) includes it, so without the column a pensioner's
  // row would not add up.
  const pension   = decum.pension    || [];
  const hasPension= pension.some(v => v > 0);
  const withdrawal= decum.withdrawal || [];
  const taxPaid   = decum.tax_paid   || [];
  const taxRate   = decum.eff_tax_rate|| [];
  const nBars     = ages.length - 1;

  const fmtD = v => v > 0 ? `$${Number(v).toLocaleString("en-CA")}` : "—";
  const COL = {
    rrsp:   "#5b4fa8",
    taxable:"#c2571a",
    tfsa:   "#16a085",
    cpp:    "#1a1a1a",
    gross:  "#1a1a1a",
    rate:   "#647071",
    net:    "#2d7a47",
  };

  const hdCell = {
    padding:"10px 14px", textAlign:"right", fontFamily:"'IBM Plex Mono',monospace",
    fontSize:12, letterSpacing:".06em", textTransform:"uppercase",
    color:"#647071", borderBottom:"1px solid #d4cfc5", whiteSpace:"nowrap",
  };
  const hdFirst = { ...hdCell, textAlign:"left" };
  const td = (color, right=true) => ({
    padding:"9px 14px", fontFamily:"'IBM Plex Mono',monospace", fontSize:12,
    color, textAlign: right ? "right" : "left",
    borderBottom:"1px solid #f0ebe0", letterSpacing:"-.01em",
  });

  return ReactDOM.createPortal(
    <div style={{position:"fixed",inset:0,background:"rgba(0,0,0,.50)",zIndex:9999,
                 display:"flex",alignItems:"center",justifyContent:"center",padding:24}}
         onClick={onClose}>
      <div style={{background:"#fff",borderRadius:6,width:"100%",maxWidth:900,
                   maxHeight:"85vh",display:"flex",flexDirection:"column",
                   boxShadow:"0 8px 40px rgba(0,0,0,.22)"}}
           onClick={e => e.stopPropagation()}>
        {/* Header */}
        <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",
                     padding:"16px 20px",borderBottom:"1px solid #d4cfc5",flexShrink:0}}>
          <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:11,fontWeight:500,
                        letterSpacing:".12em",textTransform:"uppercase",color:"#647071"}}>
            Withdrawal &amp; Income Detail
          </span>
          <button onClick={onClose} aria-label="Close"
            style={{background:"none",border:"none",cursor:"pointer",fontSize:18,
                    color:"#647071",lineHeight:1,padding:"0 4px"}}>
            ×
          </button>
        </div>
        {/* Table */}
        <div style={{overflowY:"auto",flexGrow:1}}>
          <table style={{width:"100%",borderCollapse:"collapse",tableLayout:"fixed"}}>
            <thead>
              <tr style={{background:"#faf9f7"}}>
                <th style={{...hdFirst, width:54}}>Age</th>
                <th style={hdCell}>RRSP Draw</th>
                <th style={hdCell}>Taxable Draw</th>
                <th style={hdCell}>TFSA Draw</th>
                <th style={hdCell}>CPP + OAS</th>
                {hasPension && <th style={hdCell}>Pension</th>}
                <th style={hdCell}>Total Gross</th>
                <th style={hdCell}>Tax Rate</th>
                <th style={hdCell}>Net Spendable</th>
              </tr>
            </thead>
            <tbody>
              {ages.slice(0, nBars).map((age, i) => {
                const gross   = withdrawal[i] || 0;
                const tax     = taxPaid[i] || 0;
                const net     = Math.max(0, gross - tax);
                const rate    = taxRate[i] || 0;
                const cpp     = cpp_oas[i] || 0;
                const rowBg   = i % 2 === 0 ? "#fff" : "#faf9f7";
                return (
                  <tr key={age} style={{background:rowBg}}>
                    <td style={{...td(COL.cpp, false), fontWeight:500}}>{age}</td>
                    <td style={td(drawRrsp[i] > 0 ? COL.rrsp : "#647071")}>{fmtD(drawRrsp[i])}</td>
                    <td style={td(drawTax[i]  > 0 ? COL.taxable : "#647071")}>{fmtD(drawTax[i])}</td>
                    <td style={td(drawTfsa[i] > 0 ? COL.tfsa : "#647071")}>{fmtD(drawTfsa[i])}</td>
                    <td style={td(cpp > 0 ? COL.cpp : "#647071")}>{fmtD(cpp)}</td>
                    {hasPension && (
                      <td style={td(pension[i] > 0 ? COL.cpp : "#647071")}>{fmtD(pension[i])}</td>
                    )}
                    <td style={td(COL.gross)}>{fmtD(gross)}</td>
                    <td style={td(COL.rate)}>{rate > 0 ? `${rate}%` : "—"}</td>
                    <td style={td(COL.net)}>{fmtD(net)}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </div>
    </div>,
    document.body
  );
}

// ── After-Tax Spendable Income (stacked bars + tax rate line) ─────────────────
function AfterTaxIncomeChart({ decum, showDetail, setShowDetail }) {
  const hasTax = decum && decum.tax_paid && decum.eff_tax_rate && decum.ages && decum.ages.length > 1;
  if (!hasTax) return null;

  const ages       = decum.ages;
  const n          = ages.length;
  const nBars      = n - 1;   // skip terminal row
  const withdrawal = decum.withdrawal  || [];
  const taxPaid    = decum.tax_paid    || [];
  const taxRate    = decum.eff_tax_rate|| [];
  const target     = decum.annual_need || decum.smile_peak || 0;
  // Re-amortized income band (die-with-zero model): p10–p90 of the annually
  // re-planned gross income across MC paths. The DRAWN band stays gross — it
  // must align with the full stacked-bar height (after-tax + tax = gross).
  // The caption prefers income_pct_net (after-tax dollars, absent on stale
  // cached projections) because spendable income is what the user experiences.
  const inc = (decum.income_pct && decum.income_pct.p10 &&
               decum.income_pct.p10.length >= nBars) ? decum.income_pct : null;
  const incNet = (decum.income_pct_net && decum.income_pct_net.p10 &&
                  decum.income_pct_net.p10.length >= nBars) ? decum.income_pct_net : null;

  const W = 680, H = 214, PT = 14, PB = 32, PL = 60, PR = 42;
  const cW = W - PL - PR, cH = H - PT - PB;

  const maxIncome  = Math.max(...withdrawal.slice(0, nBars), target,
                              ...(inc ? inc.p90.slice(0, nBars) : []), 1) * 1.12;
  const rawRate    = Math.max(...taxRate.slice(0, nBars), 1);
  const rateMax    = Math.min(50, Math.ceil(rawRate / 10) * 10 + 10);

  const band = cW / Math.max(nBars, 1);
  const xp   = i  => PL + band * (i + 0.5);   // band-centre: keeps every bar inside [PL, W-PR]
  const yp   = v  => PT + cH * (1 - Math.min(v, maxIncome) / maxIncome);
  const ypR  = pct=> PT + cH * (1 - pct / rateMax);

  const barW = Math.max(2, band * 0.80);

  const rawStep = maxIncome / 5;
  const mag  = Math.pow(10, Math.floor(Math.log10(rawStep)));
  const step = [1,2,2.5,5,10].map(m => m * mag).find(s => rawStep / s <= 1) || mag * 10;
  const yTicks = [];
  for (let v = step; v < maxIncome * 1.05; v += step) yTicks.push(Math.round(v));
  const fmtY = v => v >= 1e6 ? `$${(v/1e6).toFixed(1)}M` : `$${Math.round(v/1000)}k`;

  const rTicks = [];
  for (let r = 10; r <= rateMax; r += 10) rTicks.push(r);

  const GREEN = "rgba(22,160,133,";
  const PINK  = "rgba(238,130,150,";
  const CORAL = "rgba(220,80,80,";

  // Plain-language market-risk caption (no percentile vocabulary).
  let bandNote = null;
  if (inc) {
    const probeAge = Math.min(ages[0] + nBars - 1, 85);
    const pi = probeAge - ages[0];
    const fmtK = v => `$${Math.round(v / 1000)}k`;
    const src  = incNet || inc;
    const kind = incNet ? "after-tax income" : "income";
    bandNote = `Your plan adjusts with markets: in weak markets (1 in 10), ${kind} at ${probeAge} would be about ${fmtK(src.p10[pi])} instead of ${fmtK(src.p50[pi])}; strong markets raise it — the plan spends the upside rather than leaving it behind.`;
  }

  return (
    <div>
      {showDetail && <DecumDetailModal decum={decum} onClose={() => setShowDetail(false)}/>}
      <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
        {yTicks.map(v => (
            <g key={v}>
              <line x1={PL} y1={yp(v)} x2={W-PR} y2={yp(v)} stroke="rgba(0,0,0,0.04)" strokeWidth={1}/>
              <text x={PL-5} y={yp(v)+3.5} textAnchor="end" fontSize={9} fill="#94a3b8" fontFamily="system-ui">{fmtY(v)}</text>
            </g>
          ))}
          {rTicks.map(r => (
            <text key={r} x={W-PR+5} y={ypR(r)+3.5} textAnchor="start" fontSize={9} fill={`${CORAL}0.80)`} fontFamily="system-ui">{r}%</text>
          ))}
          {inc && (
            <path
              d={
                ages.slice(0, nBars).map((_, i) =>
                  `${i === 0 ? "M" : "L"}${xp(i).toFixed(1)},${yp(inc.p90[i]).toFixed(1)}`).join(" ")
                + " " +
                ages.slice(0, nBars).map((_, i) => {
                  const j = nBars - 1 - i;
                  return `L${xp(j).toFixed(1)},${yp(inc.p10[j]).toFixed(1)}`;
                }).join(" ")
                + " Z"
              }
              fill="rgba(245,166,35,0.16)" stroke="rgba(138,87,9,0.28)" strokeWidth={0.75}/>
          )}
          {ages.slice(0, nBars).map((age, i) => {
            const gross    = withdrawal[i] || 0;
            const tax      = Math.min(taxPaid[i] || 0, gross);
            const afterTax = Math.max(0, gross - tax);
            const bx = xp(i) - barW / 2;
            const y0 = yp(0);
            return (
              <g key={i}>
                <rect x={bx} y={yp(afterTax)} width={barW}
                  height={Math.max(0, y0 - yp(afterTax))} fill={`${GREEN}0.70)`}/>
                <rect x={bx} y={yp(gross)} width={barW}
                  height={Math.max(0, yp(afterTax) - yp(gross))} fill={`${PINK}0.70)`}/>
              </g>
            );
          })}
          {target > 0 && (
            <line x1={PL} y1={yp(target)} x2={W-PR} y2={yp(target)}
              stroke="#1a4a6b" strokeWidth={1.5} strokeDasharray="6 4" opacity={0.85}/>
          )}
          {ages.slice(0, nBars).map((age, i) => {
            if (i === 0) return null;
            const r0 = taxRate[i-1] || 0;
            const r1 = taxRate[i]   || 0;
            return (
              <line key={i} x1={xp(i-1)} y1={ypR(r0)} x2={xp(i)} y2={ypR(r1)}
                stroke={`${CORAL}0.75)`} strokeWidth={1.5}/>
            );
          })}
          {ages.slice(0, nBars).map((age, i) => (
            <circle key={i} cx={xp(i)} cy={ypR(taxRate[i]||0)} r={2.5}
              fill={`${CORAL}0.75)`} stroke="#fff" strokeWidth={0.8}/>
          ))}
          <line x1={PL} y1={H-PB} x2={W-PR} y2={H-PB} stroke="#b0a48c" strokeWidth={1.5}/>
          {ages.slice(0, nBars).map((age, i) => {
            if (age % 3 !== 0) return null;
            return (
              <text key={age} x={xp(i)} y={H-PB+18} textAnchor="middle"
                fontSize={9} fill="#94a3b8" fontFamily="system-ui">{age}</text>
            );
          })}
        </svg>
      <div style={{display:"flex",gap:16,justifyContent:"center",margin:"8px 0 4px",flexWrap:"wrap",alignItems:"center"}}>
        {[
          {label:"After-Tax Income", col:GREEN, type:"rect"},
          {label:"Tax (incl. credit)", col:PINK, type:"rect"},
          {label:"Pre-tax target",  col:"rgba(26,74,107,", type:"dash"},
          {label:"Tax Rate (on taxable income)", col:CORAL, type:"line"},
          ...(inc ? [{label:"Market range (adjusts yearly)", col:"rgba(245,166,35,", type:"band"}] : []),
        ].map(({label, col, type}) => (
          <span key={label} style={{display:"flex",alignItems:"center",gap:5,fontFamily:"system-ui",fontSize:10,color:"#5a6a72"}}>
            <svg width={18} height={10}>
              {type==="rect" && (
                <rect x={0} y={0} width={18} height={10} rx={2}
                  fill={`${col}0.65)`} stroke={`${col}0.80)`} strokeWidth={0.75}/>
              )}
              {type==="band" && (
                <rect x={0} y={0} width={18} height={10} rx={2}
                  fill={`${col}0.12)`} stroke={`${col}0.35)`} strokeWidth={0.75}/>
              )}
              {type==="dash" && (
                <line x1={0} y1={5} x2={18} y2={5} stroke={`${col}0.85)`}
                  strokeWidth={2} strokeDasharray="6 4"/>
              )}
              {type==="line" && (
                <>
                  <line x1={0} y1={5} x2={18} y2={5} stroke={`${col}0.75)`} strokeWidth={1.5}/>
                  <circle cx={9} cy={5} r={2.5} fill={`${col}0.75)`} stroke="#fff" strokeWidth={0.8}/>
                </>
              )}
            </svg>
            {label}
          </span>
        ))}
      </div>
      {bandNote && (
        <div style={{fontFamily:"system-ui",fontSize:10.5,color:"#5a6a72",
                     textAlign:"center",margin:"2px 8px 4px",lineHeight:1.5}}>
          {bandNote}
        </div>
      )}
    </div>
  );
}

// ── "Benefit Deferral" metric — what waiting past 65 is worth ───────────────
// A `pf-metric-item` in the Save tab's plan-status rail, beside Income
// Surplus/Shortfall. `deferral` is the block of the same name from
// GET /api/portfolio/baseline/<goal>; anything but `ok:true` renders nothing,
// which is the common case (a plan that already starts both benefits at 65 has
// made no deferral to price).
//
// **This is deliberately a metric and not a line on the Lifecycle chart, and
// moving it there would invert its sign.** Deferral is bought with portfolio
// capital across the bridge years, so the deferred plan holds LESS on a balance
// axis at every single age — measured across five scenarios, the take-at-65 arm
// led by $69,400–$123,084 and the deferred arm never led once, while sustaining
// $1,250–$1,818/yr MORE. `app._baseline_gate` suppresses it 5/5. A balance chart
// shows what deferral costs and hides what it buys; dollars-per-year is the axis
// on which the deferred plan actually dominates, at every age, with no crossing.
// See `app._deferral_gain` and `projection.md § Benefit deferral`.
function BenefitDeferralMetric({ deferral }) {
  const d = deferral;
  if (!d || !d.ok) return null;
  const ages = d.cpp_start_age === d.oas_start_age
    ? `${d.cpp_start_age}` : `${d.cpp_start_age}/${d.oas_start_age}`;
  return (
    <div className="pf-metric-item">
      <div className="pf-metric-label">Benefit Deferral</div>
      <div className="pf-metric-val" style={{color:"#2d7a47"}}>
        +{fmtDollar(d.income_gain)}/yr
      </div>
      <div style={{fontSize:10.5,color:"#647071",marginTop:5,
        fontFamily:"'IBM Plex Mono',monospace",lineHeight:1.6}}>
        {/* Both sub-lines name their own unit. They carried "$28k vs $20k/yr"
            and "≈$28k more over a lifetime" until a real dataset put two
            unrelated $28k figures on adjacent lines — the benefit level and the
            lifetime gain are not the same kind of number and must not read as
            the same one. */}
        <span>From age {ages}: {fmtCompact(d.cpp_annual + d.oas_annual)}/yr of
          {" "}benefits, not {fmtCompact(d.at65_cpp_annual + d.at65_oas_annual)}</span>
        {/* The survival-weighted lifetime figure, never the raw horizon total:
            deferral is a longevity bet, and summing to 96 quietly assumes the
            retiree collects every one of those years. */}
        {d.lifetime_weighted > 0 && (
          <><br/><span>≈{fmtCompact(d.lifetime_weighted)} more spending over a lifetime</span></>
        )}
      </div>
    </div>
  );
}

// Shared pill treatment for the card-head buttons (Details / Optimization
// Benefit). One object so a second pill cannot drift from the first.
// inline-flex, not the default inline: a pill can carry a `PremiumTag`, and on
// an inline button the chip's own line-height pushes the label off centre.
const PF_PILL_STY = {
  background:"#faf9f7", border:"1px solid #d4cfc5", borderRadius:12,
  padding:"3px 11px", fontFamily:"'IBM Plex Mono',monospace", fontSize:10,
  color:"#5a6a72", cursor:"pointer", letterSpacing:".04em", lineHeight:1.6,
  display:"inline-flex", alignItems:"center", gap:6,
};

function AfterTaxIncomeCard({ decum, goal, user, onUpgrade }) {
  const [showDetail, setShowDetail] = React.useState(false);
  const [showOpt, setShowOpt]       = React.useState(false);
  // Cosmetics only — `require_premium` on the endpoint is the boundary. A free
  // user can still click; they get the locked panel instead of a chart, which
  // is the affordance that sells it.
  const locked = (user?.tier || "free") !== "premium";
  return (
    <div className="pf-proj-card" style={{marginTop:12}}>
      <div className="pf-proj-head" style={{marginBottom:10}}>
        <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12.5,fontWeight:500,color:"#1a1a1a"}}>
          After-Tax Spendable Income
        </span>
        <span style={{display:"flex",gap:8}}>
          {goal === "retirement" && (
            <button onClick={() => setShowOpt(true)} style={PF_PILL_STY}
              title="What your plan's construction adds, against a bank portfolio">
              Optimization Benefit
              <PremiumTag premium={!locked}/>
            </button>
          )}
          <button onClick={() => setShowDetail(true)} style={PF_PILL_STY}>Details</button>
        </span>
      </div>
      <AfterTaxIncomeChart decum={decum} showDetail={showDetail} setShowDetail={setShowDetail} />
      {showOpt && (
        <OptimizationBenefitModal goal={goal} locked={locked}
          onClose={() => setShowOpt(false)} onUpgrade={onUpgrade} />
      )}
    </div>
  );
}

// ── Optimization Benefit (PREMIUM) ───────────────────────────────────────────
// The one place the whole optimization story is shown as a single picture: a
// stacked area of after-tax spendable income, from a bank mutual-fund portfolio
// at the bottom up to this plan at the top, one band per decision.
//
// **Income, never balances.** Two of these levers — withdrawal order and CPP/OAS
// deferral — are consumption effects that INVERT on a wealth axis (both plans
// die with zero, so the plan that can afford to spend more holds less, and
// deferral is bought with portfolio capital during the bridge years). On this
// axis every band is non-negative at every age. See `app._OPT_LADDER` and
// `methodology.md § 18f`; do not re-plot this against portfolio value.
function OptimizationBenefitModal({ goal, locked, onClose, onUpgrade }) {
  const [data, setData] = React.useState(null);
  const [err, setErr]   = React.useState(null);

  useEffect(() => {
    if (locked) return;                     // don't spend six projections on a locked panel
    let dead = false;
    api(`/api/portfolio/optimization-benefit/${goal}`).then(r => {
      if (dead) return;
      if (r.status === 402) { setErr("premium"); return; }
      if (!r.ok || !r.data || !r.data.ok) {
        setErr((r.data && (r.data.reason || r.data.error)) || "Could not build the comparison.");
        return;
      }
      setData(r.data);
    });
    return () => { dead = true; };
  }, [goal, locked]);

  // Same portal treatment as DecumDetailModal — the app has one modal shell and
  // it is inline-styled, not classed (`ui.md § Modal launch pattern`).
  return ReactDOM.createPortal(
    <div style={{position:"fixed",inset:0,background:"rgba(0,0,0,.50)",zIndex:9999,
                 display:"flex",alignItems:"center",justifyContent:"center",padding:24}}
         onClick={onClose}>
      <div style={{background:"#fff",borderRadius:6,width:"100%",maxWidth:880,
                   maxHeight:"88vh",display:"flex",flexDirection:"column",
                   boxShadow:"0 8px 40px rgba(0,0,0,.22)"}}
           onClick={e => e.stopPropagation()}>
        <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",
                     padding:"16px 20px",borderBottom:"1px solid #d4cfc5",flexShrink:0}}>
          <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:11,fontWeight:500,
                        letterSpacing:".12em",textTransform:"uppercase",color:"#647071"}}>
            Optimization Benefit
          </span>
          <button onClick={onClose} aria-label="Close"
            style={{background:"none",border:"none",cursor:"pointer",fontSize:18,
                    color:"#647071",lineHeight:1,padding:"0 4px"}}>×</button>
        </div>
        <div style={{overflowY:"auto",flexGrow:1,padding:"14px 20px 20px"}}>
          {(locked || err === "premium")
            ? <OptimizationBenefitLocked onUpgrade={onUpgrade} />
            : err ? <div className="pf-error" style={{margin:0}}>{err}</div>
            : !data ? (
              <div style={{display:"flex",alignItems:"center",gap:12,padding:"32px 0"}}>
                <span className="pf-spinner"/>
                <span style={{fontSize:13,color:"#5a6a72"}}>
                  Re-running your plan six ways…
                </span>
              </div>
            ) : <OptimizationBenefitChart data={data} />}
        </div>
      </div>
    </div>, document.body);
}

function OptimizationBenefitLocked({ onUpgrade }) {
  return (
    <div style={{padding:"18px 0 6px",maxWidth:560}}>
      <p style={{fontSize:13.5,color:"#1a1a1a",lineHeight:1.65,marginBottom:12}}>
        <b>See what your plan's construction is actually worth.</b> This re-runs your
        own plan six ways — from a typical Canadian bank mutual-fund portfolio up to
        the plan you have — and shows the spendable income each one supports, year by
        year, with a band for every decision that separates them.
      </p>
      <p style={{fontSize:12.5,color:"#5a6a72",lineHeight:1.65,marginBottom:16}}>
        Same savings, same retirement date, same stock/bond mix throughout. Only the
        fees, where each fund is held, the order accounts are drawn down, and when
        government benefits start.
      </p>
      {onUpgrade && (
        <button className="sp-btn sp-btn-primary" onClick={onUpgrade}>See premium →</button>
      )}
    </div>
  );
}

// One hue, six steps of lightness — the bands are cumulative stages of ONE
// story, not six categories, so a categorical palette would misdescribe them.
// The floor (a bank portfolio) is the neutral slate every "what you did not do"
// reference on this tab uses; the five improvements ramp up the app's blue.
const OPT_BAND_FILL = [
  "rgba(90,106,114,0.22)",  "rgba(50,102,173,0.22)", "rgba(50,102,173,0.36)",
  "rgba(50,102,173,0.52)",  "rgba(50,102,173,0.68)", "rgba(50,102,173,0.86)",
];

function OptimizationBenefitChart({ data }) {
  const { ages, rungs, summary, assumptions } = data;
  const n = Math.min(ages.length, ...rungs.map(r => r.net_spend.length));
  if (n < 2) return null;

  // ── The chart plots the BENEFIT, not the income ──────────────────────────
  // Stacked from the bank portfolio's own income as the zero line, because
  // stacking from $0 spends ~80% of the plot on the floor every plan already
  // has and squeezes the thing the card is named after into a sliver. The
  // baseline's absolute level is stated in the header and the legend, so
  // nothing is hidden — the axis is relabelled, not truncated.
  const floor = rungs[0].net_spend;
  const gain  = rungs.map(r => r.net_spend.map((v, i) => v - floor[i]));

  const W = 800, H = 280, PT = 16, PB = 30, PL = 62, PR = 12;
  const cW = W - PL - PR, cH = H - PT - PB;
  const yMax = Math.max(...gain[gain.length - 1].slice(0, n), 1) * 1.10;
  const xp = i => PL + (i / (n - 1)) * cW;
  const yp = v => PT + cH * (1 - Math.min(v, yMax) / yMax);

  // Band i is the area between cumulative gain i−1 and i; band 1 sits on zero.
  const band = (lo, hi) => [
    ...hi.slice(0, n).map((v, i) => `${i === 0 ? "M" : "L"}${xp(i).toFixed(1)},${yp(v).toFixed(1)}`),
    ...(lo ? [...lo.slice(0, n)].reverse().map((v, i) =>
      `L${xp(n - 1 - i).toFixed(1)},${yp(v).toFixed(1)}`)
      : [`L${xp(n - 1).toFixed(1)},${yp(0).toFixed(1)}`, `L${xp(0).toFixed(1)},${yp(0).toFixed(1)}`]),
    "Z"].join(" ");

  const step = Math.pow(10, Math.floor(Math.log10(yMax / 4)));
  const tick = [1, 2, 2.5, 5, 10].map(m => m * step).find(s => yMax / 4 <= s) || step * 10;
  const yTicks = [];
  for (let v = tick; v < yMax; v += tick) yTicks.push(Math.round(v));

  // A lever the plan did not use contributes nothing; label it as a fact rather
  // than drawing a legend row with no band under it.
  const contrib = rungs.map((r, i) =>
    i === 0 ? 0 : (r.smile_peak || 0) - (rungs[i - 1].smile_peak || 0));

  return (
    <div>
      <p style={{fontSize:12.5,color:"#5a6a72",lineHeight:1.6,margin:"6px 0 14px"}}>
        Your plan supports <b style={{color:"#1a1a1a"}}>{fmtDollar(summary.optimized_peak)}/yr</b> where a
        bank mutual-fund portfolio of the same savings supports {fmtDollar(summary.bank_peak)} —
        {" "}<b style={{color:"#2d7a47"}}>+{fmtDollar(summary.gain_vs_bank)}/yr</b>, or about
        {" "}{fmtCompact(summary.lifetime_vs_bank)} more spending over a lifetime once weighted for
        how long you're likely to live.
      </p>
      <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
        {yTicks.map(v => (
          <g key={v}>
            <line x1={PL} y1={yp(v)} x2={W-PR} y2={yp(v)} stroke="rgba(0,0,0,0.04)" strokeWidth={1}/>
            <text x={PL-5} y={yp(v)+3.5} textAnchor="end" fontSize={9} fill="#94a3b8"
              fontFamily="system-ui">+${(v/1000).toFixed(v < 1000 ? 1 : 0)}k</text>
          </g>
        ))}
        {rungs.slice(1).map((r, k) => (
          <path key={r.id} d={band(k === 0 ? null : gain[k], gain[k + 1])}
            fill={OPT_BAND_FILL[k + 1]} stroke="rgba(255,255,255,0.55)" strokeWidth={0.6}/>
        ))}
        <path d={gain[gain.length-1].slice(0, n).map((v, i) =>
          `${i===0?"M":"L"}${xp(i).toFixed(1)},${yp(v).toFixed(1)}`).join(" ")}
          fill="none" stroke="rgba(50,102,173,0.95)" strokeWidth={2}/>
        {/* The zero line IS the bank portfolio, and is labelled as such — an
            unlabelled baseline is what would make this axis misleading. */}
        <line x1={PL} y1={H-PB} x2={W-PR} y2={H-PB} stroke="#b0a48c" strokeWidth={1.25}/>
        <text x={PL+4} y={H-PB-6} fontSize={9.5} fill="#647071" fontFamily="system-ui">
          a bank mutual-fund portfolio · {fmtDollar(summary.bank_peak)}/yr
        </text>
        {ages.slice(0, n).map((a, i) =>
          (a % 5 === 0) ? (
            <text key={a} x={xp(i)} y={H-PB+14} textAnchor="middle" fontSize={9}
              fill="#94a3b8" fontFamily="system-ui">{a}</text>
          ) : null)}
      </svg>

      <div style={{marginTop:14,display:"grid",gap:6}}>
        {rungs.map((r, i) => {
          // A fee is only worth naming on the rung where it CHANGED. The
          // deferral rung carries the robo fee forward and labelling it there
          // read as though deferring were somehow a fee decision.
          const feeNow  = i === 3 ? assumptions.plan_mer : r.mer;
          const feePrev = i === 0 ? null : (i - 1 === 3 ? assumptions.plan_mer : rungs[i-1].mer);
          const showFee = feeNow != null && feeNow !== feePrev;
          return (
            <div key={r.id} style={{display:"flex",alignItems:"center",gap:9,fontSize:11.5}}>
              <span style={{width:14,height:11,borderRadius:2,flex:"0 0 auto",
                background:i === 0 ? "transparent" : OPT_BAND_FILL[i],
                border:i === 0 ? "1px solid #b0a48c" : "1px solid rgba(0,0,0,0.08)"}}/>
              <span style={{color:i === 0 ? "#647071" : "#1a1a1a",flex:1}}>
                {r.label}
                {showFee && (
                  <span style={{color:"#647071"}}> · {(feeNow*100).toFixed(2)}% fees</span>
                )}
                {i === 0 && <span style={{color:"#647071"}}> — the baseline</span>}
                {/* A zero band is almost never "this is worthless" — it is
                    usually "this plan has not done it yet", and saying which is
                    the difference between a dead row and an action. Asset
                    location is the case with a crisp explanation: holding one
                    all-in-one fund in every account IS the benchmark's own
                    construction, so the lever has nothing to work with. */}
                {r.id === "location" && contrib[i] <= 0
                  && assumptions.taxable_bond_frac != null
                  && Math.abs(assumptions.taxable_bond_frac - assumptions.bench_bond_frac) < 0.005 && (
                  <span style={{color:"#8a5709",display:"block",fontSize:10.5,lineHeight:1.5}}>
                    your non-registered account holds the same
                    {" "}{Math.round(assumptions.bench_bond_frac*100)}% bonds as the comparison —
                    {" "}there is nothing located differently yet
                  </span>
                )}
              </span>
              <span style={{fontFamily:"'IBM Plex Mono',monospace",
                color:i === 0 ? "#647071" : "#1a1a1a", minWidth:82, textAlign:"right"}}>
                {i === 0 ? fmtDollar(r.smile_peak) + "/yr"
                  : contrib[i] > 0 ? "+" + fmtDollar(contrib[i]) + "/yr"
                  : "not yet used"}
              </span>
            </div>
          );
        })}
        <div style={{display:"flex",alignItems:"center",gap:9,fontSize:11.5,
          borderTop:"1px solid #e3ddd1",paddingTop:7,marginTop:1}}>
          <span style={{width:14,flex:"0 0 auto"}}/>
          <span style={{flex:1,color:"#1a1a1a",fontWeight:500}}>Your plan</span>
          <span style={{fontFamily:"'IBM Plex Mono',monospace",color:"#2d7a47",
            minWidth:82,textAlign:"right",fontWeight:500}}>
            {fmtDollar(summary.optimized_peak)}/yr
          </span>
        </div>
      </div>

      <p style={{fontSize:10.5,color:"#647071",lineHeight:1.6,marginTop:14}}>
        Every line above is your own plan — same savings, same retirement date and the
        same stock/bond mix — re-run with one more decision changed. Bank fees are the
        {" "}{(assumptions.bank_mer*100).toFixed(2)}% asset-weighted average across all Canadian
        mutual fund series; a branch client in an advice-embedded series typically pays more.
        {!assumptions.deferral_modelled &&
          " Benefit timing is not compared here because this plan already starts CPP and OAS at 65."}
        {" "}Each band is what that step adds <i>in this order</i> — these decisions interact, so a
        different order would divide the same total differently.
      </p>
    </div>
  );
}

// ── IPS lifecycle projection (client-side MC, no backend call) ───────────────────
function computeIpsMC(totalBal, contrib, horizon, mu, sigma) {
  // mu is a GEOMETRIC (CAGR-style) expected return (allocation exp_return, net of
  // MER) — the log drift is ln(1+mu) with no −σ²/2, matching the backend engine
  // so the unsaved preview's median agrees with the saved projection's p50.
  const drift = Math.log(1 + mu);
  const N = 2000;
  const yearBuckets = Array.from({ length: horizon + 1 }, () => new Array(N));
  for (let n = 0; n < N; n++) {
    let val = totalBal;
    yearBuckets[0][n] = val;
    for (let t = 1; t <= horizon; t++) {
      const u1 = Math.random(), u2 = Math.random();
      const z = Math.sqrt(-2 * Math.log(Math.max(u1, 1e-10))) * Math.cos(2 * Math.PI * u2);
      val = Math.max(0, val + contrib) * Math.exp(drift + sigma * z);
      yearBuckets[t][n] = val;
    }
  }
  const pct = (arr, p) => {
    const s = [...arr].sort((a, b) => a - b);
    return s[Math.floor((p / 100) * (N - 1))];
  };
  return {
    years: Array.from({ length: horizon + 1 }, (_, i) => i),
    p10: yearBuckets.map(y => pct(y, 10)),
    p25: yearBuckets.map(y => pct(y, 25)),
    p50: yearBuckets.map(y => pct(y, 50)),
    p75: yearBuckets.map(y => pct(y, 75)),
    p90: yearBuckets.map(y => pct(y, 90)),
  };
}

function IpsProjectionChart({ form, metrics, goal, projection, mc }) {
  const retireYr = parseInt(form.retireYear) || 0;
  const currentYear = new Date().getFullYear();
  if (!mc) return null;

  const W = 600, H = 180, PT = 14, PB = 28, PL = 6, PR = 6;
  const cW = W - PL - PR, cH = H - PT - PB;
  const nYears = mc.years.length;

  // Retirement-only FI reference line: the engine's FI number when we have it,
  // else the wizard's rough 25× rule for the unsaved preview.
  const fiNumber = goal !== "retirement" ? null
    : (projection && projection.ok && projection.target) ? projection.target
    : (parseFloat(form.targetIncome) > 0 ? parseFloat(form.targetIncome) * 25 : null);

  const maxY = Math.max(...mc.p90) * 1.08;
  const xp = i => PL + (i / (nYears - 1)) * cW;
  const yp = v => PT + cH * (1 - Math.min(v, maxY) / maxY);

  const linePath = arr => arr.map((v, i) => `${i===0?"M":"L"}${xp(i).toFixed(1)},${yp(v).toFixed(1)}`).join(" ");
  const bandPath = (lo, hi) => [
    ...lo.map((v, i) => `${i===0?"M":"L"}${xp(i).toFixed(1)},${yp(v).toFixed(1)}`),
    ...[...hi].reverse().map((v, i) => `L${xp(nYears-1-i).toFixed(1)},${yp(v).toFixed(1)}`), "Z"
  ].join(" ");
  const areaPath = [
    `M${xp(0).toFixed(1)},${(PT+cH).toFixed(1)}`,
    ...mc.p50.map((v, i) => `L${xp(i).toFixed(1)},${yp(v).toFixed(1)}`),
    `L${xp(nYears-1).toFixed(1)},${(PT+cH).toFixed(1)}`, "Z"
  ].join(" ");

  // X-axis labels every 4 years, suppress if too close to terminal year
  const xLabels = [];
  const firstFour = Math.ceil(currentYear / 4) * 4;
  for (let i = 0; i < nYears; i++) {
    const yr = currentYear + mc.years[i];
    if (yr === firstFour || (yr > firstFour && yr % 4 === 0)) {
      if (Math.abs(yr - retireYr) >= 2) xLabels.push({ i, yr });
    } else if (i === nYears - 1) {
      xLabels.push({ i, yr });
    }
  }

  const termVal   = mc.p50[nYears - 1];
  const termLabel = fmtCompact(termVal);

  return (
    <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
      <defs>
        <linearGradient id="ipsGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   stopColor="#f5a623" stopOpacity="0.18"/>
          <stop offset="100%" stopColor="#f5a623" stopOpacity="0.02"/>
        </linearGradient>
      </defs>
      {/* p10–p90 outer band */}
      <path d={bandPath(mc.p10, mc.p90)} fill="#f5a623" fillOpacity="0.04"/>
      {/* p25–p75 inner band */}
      <path d={bandPath(mc.p25, mc.p75)} fill="#f5a623" fillOpacity="0.08"/>
      {/* gradient fill under median */}
      <path d={areaPath} fill="url(#ipsGrad)"/>
      {/* median line */}
      <path d={linePath(mc.p50)} fill="none" stroke="#f5a623" strokeWidth="1.5" strokeLinejoin="round"/>
      {/* Plan target line (retirement only) — same fiNumber the Save tab's
          on-track signal compares against, so the two must read consistently */}
      {fiNumber && fiNumber < maxY && (() => {
        const fy  = yp(fiNumber);
        const lbl = fiNumber >= 1e6 ? `$${(fiNumber/1e6).toFixed(1)}M plan target` : `$${Math.round(fiNumber/1000)}k plan target`;
        return (
          <g>
            <line x1={PL} y1={fy} x2={W-PR} y2={fy}
              stroke="#1a4a6b" strokeWidth="1" strokeDasharray="4 3" opacity="0.65"/>
            <text x={PL+4} y={fy-3.5} fontSize="8.5" fill="#1a4a6b"
              fontFamily="'IBM Plex Mono',monospace" opacity="0.85">{lbl}</text>
          </g>
        );
      })()}
      {/* terminal annotation */}
      <text x={W-PR} y={PT+1} textAnchor="end" fontSize="9.5" fill="#8a5709"
        fontFamily="'IBM Plex Mono',monospace" fontWeight="500">
        {termLabel} by {retireYr}
      </text>
      {/* x-axis labels */}
      {xLabels.map(({i, yr}) => (
        <text key={yr} x={xp(i)} y={H-8}
          textAnchor={i===0?"start":i===nYears-1?"end":"middle"}
          fontSize="9.5" fill="#647071" fontFamily="'IBM Plex Mono',monospace">
          {yr}
        </text>
      ))}
    </svg>
  );
}

// ── Allocation donut (pure SVG — prints cleanly, no chart library) ──────────────
function Donut({ holdings, size = 168, thickness = 30 }) {
  if (!holdings || !holdings.length) return null;
  const total = holdings.reduce((s, h) => s + Number(h.weight), 0) || 1;
  const cx = size / 2, r = (size - thickness) / 2, circ = 2 * Math.PI * r;
  const gap = holdings.length > 1 ? 1.5 : 0;   // hairline between slices
  let offset = 0;
  return (
    <svg className="ips-donut" width={size} height={size} viewBox={`0 0 ${size} ${size}`}
      role="img" aria-label="Allocation by asset class">
      <circle cx={cx} cy={cx} r={r} fill="none" stroke="#eee7da" strokeWidth={thickness} />
      {holdings.map(h => {
        const frac = Number(h.weight) / total;
        const dash = Math.max(frac * circ - gap, 0);
        const seg = (
          <circle key={h.ticker} cx={cx} cy={cx} r={r} fill="none"
            stroke={classColor(h.asset_class)} strokeWidth={thickness}
            strokeDasharray={`${dash} ${circ - dash}`} strokeDashoffset={-offset}
            transform={`rotate(-90 ${cx} ${cx})`} />
        );
        offset += frac * circ;
        return seg;
      })}
    </svg>
  );
}

function logoIcon(tab) {
  const F = "#f5a623";
  const filled = { fill: F, stroke: F, strokeWidth: 1 };
  const empty   = { fill: "none", stroke: F, strokeWidth: 1 };
  const bar1 = filled;
  const bar2 = tab === "portfolio" || tab === "dashboard" ? filled : empty;
  const bar3 = tab === "dashboard" ? filled : empty;
  return (
    <svg width="40" height="40" viewBox="0 0 27 27" fill="none" aria-hidden="true">
      <rect x="3"  y="15" width="4" height="9"  rx="2" {...bar1} />
      <rect x="11" y="9"  width="4" height="15" rx="2" {...bar2} />
      <rect x="19" y="5"  width="4" height="19" rx="2" {...bar3} />
    </svg>
  );
}

// ── Profile page ──────────────────────────────────────────────────────────────
function ProfilePage({ user, onSwitchTab, onLogout, onUserUpdated, onNavigate, onBack, deleteVerified }) {
  const [name, setName]               = useState(user.name || "");
  const [birthYear, setBirthYear]     = useState(user.birth_year ? String(user.birth_year) : "");
  const [password, setPassword]       = useState("");
  const [currentPassword, setCurrentPassword] = useState("");
  const [err, setErr]                 = useState("");
  const [busy, setBusy]               = useState(false);

  const [deletePassword, setDeletePassword] = useState("");
  const [deleteErr, setDeleteErr]           = useState("");
  const [deleteBusy, setDeleteBusy]         = useState(false);
  const [reauthExpired, setReauthExpired]   = useState(false); // server rejected a stale Google re-auth
  const hasPassword = user.has_password;
  const googleVerified = deleteVerified && !reauthExpired;

  const startGoogleReauth = () => {
    try { sessionStorage.setItem("spDeleteIntent", "1"); } catch (e) { /* no-op */ }
    window.location.href = "/api/auth/google/start?mode=reauth";
  };

  const submit = async (e) => {
    e.preventDefault();
    setErr(""); setBusy(true);
    const body = {};
    const trimmedName = name.trim();
    if (trimmedName && trimmedName !== user.name) body.name = trimmedName;
    if (birthYear) body.birth_year = parseInt(birthYear);
    if (password) { body.password = password; body.current_password = currentPassword; }
    if (!Object.keys(body).length) { onBack(); return; }
    const r = await api("/api/auth/me", { method: "PATCH", body });
    setBusy(false);
    if (r.ok && r.data && r.data.user) { onUserUpdated(r.data.user); onBack(); return; }
    setErr((r.data && r.data.error) || "Something went wrong.");
  };

  const deleteAccount = async () => {
    if (hasPassword && !deletePassword) return;
    if (!hasPassword && !googleVerified) return;
    setDeleteErr(""); setDeleteBusy(true);
    const body = hasPassword ? { password: deletePassword } : {};
    const r = await api("/api/auth/me", { method: "DELETE", body });
    if (r.ok) {
      window.location.replace("/landing");
      return;
    }
    setDeleteBusy(false);
    if (r.data && r.data.need === "google_reauth") {
      // The step-up window lapsed — send them back through Google verification.
      setReauthExpired(true);
      setDeleteErr("Your Google verification expired. Please verify again.");
      return;
    }
    setDeleteErr((r.data && r.data.error) || "Could not delete account.");
  };

  return (
    <div className="sp-root">
      <PageNav activeTab={null} onSwitchTab={onSwitchTab} user={user} onLogout={onLogout} onUserUpdated={onUserUpdated} onNavigate={onNavigate} />
      <div className="sp-page">
        <h1 className="sp-h1">Edit profile</h1>
        {/* The badge sits on the ACCOUNT line, not on the h1 — it qualifies who
            this account is, not what the page does. Shown only for a subscriber:
            a "Premium" chip reading locked here would be an upsell on a settings
            page, and this is the one place in the app where the tag is a status
            and not the affordance on a gated control. `flex` rather than a bare
            inline chip so it centres on the 14.5px lead instead of riding its
            baseline. */}
        <p className="sp-lead" style={{marginBottom:24,display:"flex",
             alignItems:"center",gap:10,flexWrap:"wrap"}}>
          <span>Account for {user.email}</span>
          {user.tier === "premium" && <PremiumTag premium={true}/>}
        </p>
        <div style={{maxWidth:440}}>
          {err && <div className="auth-err" style={{marginBottom:14}}>{err}</div>}
          <form onSubmit={submit}>
            <div className="sp-field">
              <label className="sp-label">Name</label>
              <input className="sp-input" type="text" value={name} autoComplete="name"
                onChange={e => setName(e.target.value)} placeholder="e.g. Alex Chen" />
            </div>
            <div className="sp-field">
              <label className="sp-label">Year of birth</label>
              <input className="sp-input" type="number" value={birthYear} min="1901" max={new Date().getFullYear() - 1}
                onChange={e => setBirthYear(e.target.value)} placeholder="e.g. 1985" />
            </div>
            <div className="sp-field">
              <label className="sp-label">New password <span style={{fontWeight:400,color:"#647071"}}>(leave blank to keep current)</span></label>
              <input className="sp-input" type="password" value={password} autoComplete="new-password"
                onChange={e => setPassword(e.target.value)} placeholder="At least 8 characters" />
            </div>
            {password && (
              <div className="sp-field">
                <label className="sp-label">Current password <span style={{fontWeight:400,color:"#647071"}}>(required to confirm change)</span></label>
                <input className="sp-input" type="password" value={currentPassword} autoComplete="current-password"
                  onChange={e => setCurrentPassword(e.target.value)} placeholder="Your current password" />
              </div>
            )}
            <div style={{display:"flex",gap:10,marginTop:8}}>
              <button type="button" className="sp-btn" style={{flex:1,justifyContent:"center"}} onClick={onBack}>Cancel</button>
              <button type="submit" className="sp-btn sp-btn-primary" style={{flex:1,justifyContent:"center"}} disabled={busy}>
                {busy ? "Saving…" : "Save changes"}
              </button>
            </div>
          </form>

          <div style={{marginTop:24,paddingTop:20,borderTop:"1px solid #e8e4de"}}>
            <div style={{fontWeight:600,fontSize:13,marginBottom:8}}>Connected accounts</div>
            {user.google_linked ? (
              <div style={{display:"flex",alignItems:"center",gap:8,fontSize:13,color:"#2d7a47"}}>
                <span aria-hidden="true">✓</span>
                <span>Google account connected{!user.has_password && " — you sign in with Google"}.</span>
              </div>
            ) : (
              <>
                <p style={{fontSize:12.5,color:"#5a6a72",margin:"0 0 10px",lineHeight:1.5}}>
                  Link your Google account to sign in with one click.
                </p>
                <GoogleButton label="Connect Google account" mode="link" />
              </>
            )}
          </div>

          <AiReviewToggleSection user={user} onUserUpdated={onUserUpdated} />

          <div style={{marginTop:24,paddingTop:20,borderTop:"1px solid #e8e4de"}}>
            <div style={{fontWeight:600,fontSize:13,color:"#c0392b",marginBottom:8}}>Delete account</div>
            {deleteErr && <div className="auth-err" style={{marginBottom:10,fontSize:12}}>{deleteErr}</div>}
            {hasPassword ? (
              <>
                <div className="sp-field" style={{marginBottom:12}}>
                  <label className="sp-label" style={{color:"#c0392b"}}>
                    Confirm your password to permanently delete all plans, portfolios, and holdings
                  </label>
                  <input className="sp-input" type="password" value={deletePassword} autoComplete="current-password"
                    onChange={e => setDeletePassword(e.target.value)} placeholder="Your current password" />
                </div>
                <button
                  type="button"
                  onClick={deleteAccount}
                  disabled={!deletePassword || deleteBusy}
                  style={{
                    width:"100%", padding:"8px 0", borderRadius:4, border:"none",
                    background: deletePassword ? "#c0392b" : "#e8e4de",
                    color: deletePassword ? "#fff" : "#aaa",
                    fontWeight:600, fontSize:13, cursor: deletePassword ? "pointer" : "default",
                    transition:"background 0.15s"
                  }}>
                  {deleteBusy ? "Deleting…" : "Delete my account"}
                </button>
              </>
            ) : googleVerified ? (
              <>
                <p style={{fontSize:12.5,color:"#2d7a47",margin:"0 0 12px",lineHeight:1.5}}>
                  ✓ Verified with Google. This permanently deletes all plans, portfolios, and holdings.
                </p>
                <button
                  type="button"
                  onClick={deleteAccount}
                  disabled={deleteBusy}
                  style={{
                    width:"100%", padding:"8px 0", borderRadius:4, border:"none",
                    background:"#c0392b", color:"#fff", fontWeight:600, fontSize:13,
                    cursor: deleteBusy ? "default" : "pointer"
                  }}>
                  {deleteBusy ? "Deleting…" : "Delete my account permanently"}
                </button>
              </>
            ) : (
              <>
                <p style={{fontSize:12.5,color:"#5a6a72",margin:"0 0 12px",lineHeight:1.5}}>
                  For your security, deleting a Google account requires re-verifying with Google first.
                </p>
                <button
                  type="button"
                  onClick={startGoogleReauth}
                  style={{
                    width:"100%", padding:"8px 0", borderRadius:4, border:"1px solid #c0392b",
                    background:"#fff", color:"#c0392b", fontWeight:600, fontSize:13, cursor:"pointer"
                  }}>
                  Verify with Google to delete
                </button>
              </>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// ── Help Center ──────────────────────────────────────────────────────────────
// "Chapter" figures: a help section can pin the real product component beside
// its prose instead of prose alone. Figures are built from the SAME classes and
// components the product renders (GoalsTimeline/.gt-row, PlanAllocBar/.pl-card)
// — never a redrawn illustration — so a figure can't visually drift from the
// real UI. This is a small, hand-built Phase 1 (one reusable layout component +
// two figures); a content-block + figure-registry model, so authors can add an
// illustrated article without touching this file, is future work (see the
// "Help article with live examples" design handoff).
function HelpChapter({ label, children, sources, figNum, figCaption, deepLink, figure }) {
  return (
    <div className="help-chapter">
      <div className="help-prose doc-body">
        <div className="sp-section-label">{label}</div>
        {children}
        {sources && sources.length > 0 && (
          <div className="help-sources">
            <span className="help-sources-tag">Sources</span>
            {sources.map((s, i) => (
              <a key={i} href={s.href} target="_blank" rel="noopener noreferrer">{s.label}</a>
            ))}
          </div>
        )}
      </div>
      <div className="help-figcol">
        <figure className="help-figure">
          {figure}
          <figcaption className="help-cite help-fig-caption">
            <span><strong>Fig. {figNum}</strong> — {figCaption}</span>
            {deepLink && (
              <a href="#" onClick={e => { e.preventDefault(); deepLink.onClick(); }}>{deepLink.label} →</a>
            )}
          </figcaption>
        </figure>
      </div>
    </div>
  );
}

// Sample data below is illustrative only (not read from any real account) — it
// exists purely to hydrate the live components with plausible numbers. Years
// are offsets from the real current year (not fixed calendar years) so the
// figure stays "goals a few/several/many years out" indefinitely rather than
// quietly drifting into the past as real time passes.
const HELP_SAMPLE_NOW_YEAR = new Date().getFullYear();
const HELP_TIMELINE_NODES = [
  { goalId: "emergency",  name: "Emergency",  icon: "ti-shield-bolt", year: HELP_SAMPLE_NOW_YEAR + 2, dot: "#2d7a47" },
  { goalId: "home",       name: "Home",       icon: "ti-home",        year: HELP_SAMPLE_NOW_YEAR + 5, dot: "#c2571a" },
  { goalId: "retirement", name: "Retirement", icon: "ti-beach",       year: HELP_SAMPLE_NOW_YEAR + 8, dot: "#2d7a47" },
];
// Renders the actual GoalsTimeline component (the same one PlanHub's "Goals over
// time" strip renders) inside the same .db-card/.pl-sec-lbl chrome — not a copy.
function HelpGoalTimelineFigure() {
  return (
    <div className="db-card" style={{ padding: "20px 22px 22px" }}>
      <div className="pl-sec-lbl" style={{ marginBottom: 16 }}>
        <span>Goals over time</span><span>{HELP_TIMELINE_NODES.length} goals</span>
      </div>
      <GoalsTimeline nodes={HELP_TIMELINE_NODES} onJump={() => {}} />
    </div>
  );
}

// Renders the real PlanGoalCard empty-state shell — the same "No plan yet —
// click to build one" cards the Plan hub shows before any plan exists — for
// all four GOAL_TYPES, in the same .pl-grid layout. sv is null on every card
// (there's nothing saved yet), so each one renders its setup-prompt branch;
// not a redrawn illustration.
function HelpGoalPickerFigure() {
  return (
    <div className="pl-grid">
      {GOAL_TYPES.map(g => (
        <PlanGoalCard key={g.id} goalId={g.id} sv={null} d={undefined}
          highlighted={false} isDraft={false} onEnter={() => {}} onClear={() => {}} />
      ))}
    </div>
  );
}

// The real seeded cpp_oas_reference rows (ips_db.py `ensure_schema`) — the
// same government-published monthly figures the wizard's dropdowns/sliders
// read, not fabricated. birthYear is an offset from the real current year (a
// sample 60-year-old), so the age-60..70 start-year options never drift into
// the past the way a fixed birth year would.
const HELP_GOV_BIRTH_YEAR = HELP_SAMPLE_NOW_YEAR - 60;
const HELP_GOV_REF = {
  60: { cpp_avg_monthly: 592.22,  cpp_max_monthly: 964.90,  oas_max_monthly: 0 },
  61: { cpp_avg_monthly: 658.85,  cpp_max_monthly: 1073.45, oas_max_monthly: 0 },
  62: { cpp_avg_monthly: 725.47,  cpp_max_monthly: 1182.00, oas_max_monthly: 0 },
  63: { cpp_avg_monthly: 792.10,  cpp_max_monthly: 1290.55, oas_max_monthly: 0 },
  64: { cpp_avg_monthly: 858.72,  cpp_max_monthly: 1399.10, oas_max_monthly: 0 },
  65: { cpp_avg_monthly: 925.35,  cpp_max_monthly: 1507.65, oas_max_monthly: 751.97 },
  66: { cpp_avg_monthly: 1003.08, cpp_max_monthly: 1634.29, oas_max_monthly: 806.11 },
  67: { cpp_avg_monthly: 1080.81, cpp_max_monthly: 1760.94, oas_max_monthly: 860.25 },
  68: { cpp_avg_monthly: 1158.54, cpp_max_monthly: 1887.58, oas_max_monthly: 914.39 },
  69: { cpp_avg_monthly: 1236.27, cpp_max_monthly: 2014.22, oas_max_monthly: 968.53 },
  70: { cpp_avg_monthly: 1314.00, cpp_max_monthly: 2140.86, oas_max_monthly: 1022.68 },
};
// Renders the real GovBenefitInput pair — the same CPP/OAS sliders the
// retirement Accounts step renders in its .gov-grid — pre-populated to age 70
// (the product's own default-and-defer nudge). Local state stands in for the
// wizard's `form`/`onPatch` so the sliders actually drag live, same as the
// real thing; not a redrawn illustration.
function HelpGovBenefitFigure() {
  const [gov, setGov] = useState({
    cppYear: String(HELP_GOV_BIRTH_YEAR + 70),
    cppAnnual: String(Math.round(HELP_GOV_REF[70].cpp_avg_monthly * 12)),
    oasYear: String(HELP_GOV_BIRTH_YEAR + 70),
    oasAnnual: String(Math.round(HELP_GOV_REF[70].oas_max_monthly * 12)),
  });
  return (
    <div className="gov-grid">
      <GovBenefitInput kind="cpp" label="CPP" birthYear={HELP_GOV_BIRTH_YEAR}
        minAge={60} maxAge={70} refMap={HELP_GOV_REF}
        year={gov.cppYear} annual={gov.cppAnnual}
        yearKey="cppYear" annualKey="cppAnnual"
        onPatch={patch => setGov(g => ({ ...g, ...patch }))} />
      <GovBenefitInput kind="oas" label="OAS" birthYear={HELP_GOV_BIRTH_YEAR}
        minAge={65} maxAge={70} refMap={HELP_GOV_REF}
        year={gov.oasYear} annual={gov.oasAnnual}
        yearKey="oasYear" annualKey="oasAnnual"
        onPatch={patch => setGov(g => ({ ...g, ...patch }))} />
    </div>
  );
}

// Sample ~8-year horizon (offset from the real current year, never a fixed
// calendar year) so the figure never drifts into the past. Renders the exact
// .alloc-card markup the Allocation step (case 4) renders, with local state
// standing in for `form`/`setForm` and `getCMA()` — the same offline-fallback
// math the real step itself uses before its live /api/allocation fetch
// resolves — standing in for `useAllocation`, so the figure needs no network
// call. Starts pre-touched and aggressive (85% equity against a 55%
// suggestion) so both the "chosen your own mix" hint and the
// too-aggressive warning are visible immediately; the slider is real and
// draggable, so dragging it down past the suggestion by 20+ points flips to
// the too-conservative warning instead.
const HELP_ALLOC_TARGET_YEAR = HELP_SAMPLE_NOW_YEAR + 8;
function HelpAllocationSliderFigure() {
  const [gstate, setGstate] = useState({ equity: 85, equityTouched: true });
  const horizonYrs = HELP_ALLOC_TARGET_YEAR - HELP_SAMPLE_NOW_YEAR;
  const suggEq = suggestedEquityForHorizon(horizonYrs);
  const tooAggressive = suggEq != null && gstate.equity - suggEq >= 25;
  const tooConservative = suggEq != null && suggEq - gstate.equity >= 20;
  const bondPct = 100 - gstate.equity;
  const metrics = getCMA(gstate.equity);
  return (
    <div className="alloc-card">
      <div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline",marginBottom:14}}>
        <span style={{fontSize:13,fontWeight:500,color:"#1a1a1a"}}>Bonds / Equity</span>
        <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12,color:"#5a6a72"}}>{bondPct}% bonds / {gstate.equity}% equity</span>
      </div>
      <input type="range" className="sp-range" min="0" max="100" step="5"
        value={gstate.equity}
        onChange={e => setGstate({ equity: +e.target.value, equityTouched: true })}
        style={{marginBottom:6}} />
      <div style={{display:"flex",justifyContent:"space-between",fontFamily:"'IBM Plex Mono',monospace",fontSize:10,color:"#647071",marginBottom:suggEq != null ? 10 : 20}}>
        <span>All bonds</span>
        <span>Balanced</span>
        <span>All equity</span>
      </div>
      {suggEq != null && (
        <div className="sp-hint" style={{marginBottom:(tooAggressive || tooConservative) ? 10 : 20}}>
          Suggested for this goal's ~{horizonYrs}-year timeline: <strong>{suggEq}% equity</strong>
          {gstate.equityTouched && gstate.equity !== suggEq ? " — you can choose your own mix based on your risk tolerance and circumstances." : ""}
        </div>
      )}
      {tooAggressive && (
        <div style={{padding:"11px 14px",borderRadius:4,background:"rgba(194,87,26,.08)",border:"1px solid rgba(194,87,26,.35)",fontSize:12.5,color:"#a1450f",lineHeight:1.55,marginBottom:20}}>
          <strong>More aggressive than your timeline suggests.</strong> This goal is about {horizonYrs} years away, and stocks generally need well over a decade to reliably ride out a bad stretch — a downturn shortly before {HELP_ALLOC_TARGET_YEAR} could leave too little time to recover. Money needed sooner is usually better held in bonds and cash. Review your risk tolerance and circumstances to select the right mix for you.
        </div>
      )}
      {tooConservative && (
        <div style={{padding:"11px 14px",borderRadius:4,background:"rgba(194,87,26,.08)",border:"1px solid rgba(194,87,26,.35)",fontSize:12.5,color:"#a1450f",lineHeight:1.55,marginBottom:20}}>
          <strong>More conservative than your timeline suggests.</strong> This goal is about {horizonYrs} years away — usually enough time to ride out a bad stretch of markets and let stocks do their work. Holding this much in bonds and cash risks not keeping pace with what this goal needs by {HELP_ALLOC_TARGET_YEAR}. Review your risk tolerance and circumstances to select the right mix for you.
        </div>
      )}
      <div className="alloc-metrics">
        <div className="alloc-metric">
          <div className="alloc-metric-label">Exp. return</div>
          <div><span className="alloc-metric-val">{metrics.ret}</span><span className="alloc-metric-unit">%/yr</span></div>
        </div>
        <div className="alloc-metric">
          <div className="alloc-metric-label">Worst downturn</div>
          <div><span className="alloc-metric-val" style={{color:"#b34030"}}>−{metrics.dd8 ? metrics.dd8.split("–").pop() || metrics.dd8.split("-").pop() : "—"}</span></div>
        </div>
        <div className="alloc-metric">
          <div className="alloc-metric-label">Profile</div>
          <div className="alloc-metric-label-val">{metrics.label}</div>
        </div>
      </div>
    </div>
  );
}

// Sample allocation shaped like the real /api/portfolio/overview `allocation`
// array (asset_class/current_pct/target_pct + per-account _amt fields) — two
// classes drifted a clean 5 percentage points off plan (one over, one
// under), two sitting exactly on plan, so all three drift states the "vs
// Plan" view can show (on plan / over / under) are visible in one figure.
// The _amt fields (summing to $100k, consistent with each current_pct) feed
// the Mix and By-Account figures — one shared dataset behind all three
// allocation-view figures. Illustrative only, not derived from any real
// account.
const HELP_ALLOC_DRIFT_SAMPLE = [
  { asset_class: "Canadian Equity",   current_pct: 20, target_pct: 15, rrsp_amt: 12000, tfsa_amt: 8000,  taxable_amt: 0     },
  { asset_class: "Developed Markets", current_pct: 35, target_pct: 40, rrsp_amt: 20000, tfsa_amt: 10000, taxable_amt: 5000  },
  { asset_class: "Emerging Markets",  current_pct: 8,  target_pct: 8,  rrsp_amt: 0,     tfsa_amt: 8000,  taxable_amt: 0     },
  { asset_class: "Fixed Income",      current_pct: 37, target_pct: 37, rrsp_amt: 25000, tfsa_amt: 0,     taxable_amt: 12000 },
];
// Renders the real AllocVsPlanView — the same "vs Plan" drift-bar chart the
// Save tab's allocation panel renders — in its real chrome, fed the sample
// above. Not a redrawn illustration.
function HelpAllocDriftFigure() {
  return (
    <div className="db-card" style={{ padding: "20px 22px 22px" }}>
      <div className="pl-sec-lbl" style={{ marginBottom: 12 }}>
        <span>Allocation vs Plan</span><span>2 of 4 classes drifted</span>
      </div>
      <AllocVsPlanView allocation={HELP_ALLOC_DRIFT_SAMPLE} />
    </div>
  );
}

// `year` (not a fixed "N years out" string) so the figure never goes stale —
// GoalsTimeline derives "Now" from the real clock the same way.
const HELP_ALLOC_CARDS = [
  {
    icon: "ti-beach", name: "Retirement", year: HELP_SAMPLE_NOW_YEAR + 8, growth: "~90% growth",
    segs: [
      { label: "Canada",       weight: 0.17, color: classColor("Canadian Equity") },
      { label: "Developed",    weight: 0.37, color: classColor("Developed Markets") },
      { label: "Emerging",     weight: 0.07, color: classColor("Emerging Markets") },
      { label: "Fixed income", weight: 0.39, color: classColor("Fixed Income") },
    ],
  },
  {
    icon: "ti-shield-bolt", name: "Emergency Fund", year: HELP_SAMPLE_NOW_YEAR + 2, growth: "~0% growth",
    segs: [
      { label: "Fixed income", weight: 0.35, color: classColor("Fixed Income") },
      { label: "Cash",         weight: 0.65, color: classColor("Cash") },
    ],
  },
];
// Two real .pl-card / PlanAllocBar instances side by side — the same markup
// PlanGoalCard renders on the Plan hub, just fed sample allocations.
function HelpAllocationCompareFigure({ showAnnotations }) {
  return (
    <>
      <div className="help-fig2-grid">
        {HELP_ALLOC_CARDS.map((c, i) => (
          <div key={c.name} className="goal-card pl-card">
            <div className="pl-card-hd">
              <div className="pl-card-id">
                <i className={"ti " + c.icon} aria-hidden="true" />
                <div><div className="goal-name">{c.name}</div><div className="pl-card-meta">{Math.max(c.year - HELP_SAMPLE_NOW_YEAR, 0)} years out · {c.year}</div></div>
              </div>
            </div>
            <div className="pl-sec-lbl">
              <span>Allocation{showAnnotations && <span className="help-callout-badge">{i + 1}</span>}</span>
              <span>{c.growth}</span>
            </div>
            <PlanAllocBar segs={c.segs} />
          </div>
        ))}
      </div>
      {showAnnotations && (
        <div className="help-callouts">
          <div className="help-callout"><span className="help-callout-badge">1</span><span>Long horizon → equity-heavy for growth.</span></div>
          <div className="help-callout"><span className="help-callout-badge">2</span><span>Short horizon → cash &amp; bonds to preserve capital.</span></div>
        </div>
      )}
    </>
  );
}

// Synthetic {p25,p50,p75} arrays shaped like the real projection_view the
// product renders (see PlanMiniChart) — illustrative only, not derived from
// any real account.
function _wealthTrajectorySample() {
  const n = 24, start = 45000, end = 210000, bandFrac = 0.11;
  const p25 = [], p50 = [], p75 = [];
  for (let i = 0; i < n; i++) {
    const t = i / (n - 1);
    const med = start + (end - start) * Math.pow(t, 1.15);
    const half = bandFrac * end * Math.sqrt(t);
    p50.push(med);
    p25.push(Math.max(start * 0.9, med - half));
    p75.push(med + half);
  }
  return { p25, p50, p75 };
}
const HELP_WEALTH_SAMPLE = _wealthTrajectorySample();
// Renders the real PlanMiniChart (the same one PlanGoalCard's "Projection"
// section renders) inside the same .db-card/.pl-sec-lbl chrome.
function HelpWealthTrajectoryFigure() {
  const last = HELP_WEALTH_SAMPLE.p50[HELP_WEALTH_SAMPLE.p50.length - 1];
  return (
    <div className="db-card" style={{ padding: "20px 22px 22px" }}>
      <div className="pl-sec-lbl" style={{ marginBottom: 10 }}>
        <span>Projected value</span><span>~{fmtCompact(last)} median</span>
      </div>
      <PlanMiniChart mc={HELP_WEALTH_SAMPLE} color="#f5a623" />
    </div>
  );
}

const HELP_SIGNAL_ROWS = [
  { name: "Retirement",    meta: "Median projection meets or beats target", signal: "green" },
  { name: "Home purchase", meta: "Within 80% of target",                    signal: "amber" },
  { name: "Education",     meta: "Below target at the median",              signal: "red" },
];
// Renders the real SignalDot + the shared SIGNAL_LABEL/SIGNAL_TEXT vocabulary
// (the same .pf-signal pill every goal card in the app renders) — doubles as
// a legend for all three colors at once.
function HelpGoalSignalsFigure() {
  return (
    <div className="db-card" style={{ padding: "20px 22px 22px" }}>
      <div className="pl-sec-lbl" style={{ marginBottom: 12 }}><span>Goal signals</span><span /></div>
      <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
        {HELP_SIGNAL_ROWS.map(r => (
          <div key={r.name} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}>
            <div>
              <div style={{ fontSize: 13, fontWeight: 500, color: "#1a1a1a" }}>{r.name}</div>
              <div style={{ fontSize: 11.5, color: "#647071" }}>{r.meta}</div>
            </div>
            <span className={"pf-signal pf-signal-" + r.signal} style={{ color: SIGNAL_TEXT[r.signal] }}>
              <SignalDot signal={r.signal} />{SIGNAL_LABEL[r.signal]}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}

// Synthetic per-account balances shaped like the real decumulation.* series
// AccountBalancesChart renders — illustrative only, not derived from any real
// account. This is a *sequential* bucket meltdown, not a proportional one:
// RRSP is drawn to $0 first (bracket-filling order, "how-withdrawals-are-taxed"),
// while taxable/TFSA sit untouched and keep growing; once RRSP is exhausted,
// taxable is drawn next (growing no further) while TFSA keeps growing; TFSA is
// drawn last, glide-to-$0 by 96 to match the default die-with-zero plan
// (methodology § "what-we-assume"). Do not revert this to all three buckets
// shrinking in lockstep/proportionally — that was a real reported bug, since
// it contradicts the plain-English "RRSP first, TFSA last" copy right next to
// the figure and the real product's actual per-account withdrawal order.
function _retirementDrawdownSample() {
  const startAge = 65, endAge = 96;
  const rrspEndAge = 74, taxableEndAge = 88;   // RRSP gone first, then taxable, TFSA held longest
  const growth = 0.035;                        // real annual growth on an untouched bucket
  const ages = [];
  for (let a = startAge; a <= endAge; a++) ages.push(a);
  const n = ages.length;
  const rrsp0 = 460000, taxable0 = 150000, tfsa0 = 120000;
  const rrsp = [], taxable = [], tfsa = [];
  let taxableAtDrawStart = null, tfsaAtDrawStart = null;
  for (let i = 0; i < n; i++) {
    const age = ages[i];
    rrsp.push(age <= rrspEndAge
      ? Math.round(rrsp0 * (1 - (age - startAge) / (rrspEndAge - startAge)))
      : 0);
    if (age <= rrspEndAge) {
      taxable.push(Math.round(taxable0 * Math.pow(1 + growth, age - startAge)));
    } else if (age <= taxableEndAge) {
      if (taxableAtDrawStart === null) taxableAtDrawStart = taxable[taxable.length - 1];
      taxable.push(Math.round(taxableAtDrawStart * (1 - (age - rrspEndAge) / (taxableEndAge - rrspEndAge))));
    } else {
      taxable.push(0);
    }
    if (age <= taxableEndAge) {
      tfsa.push(Math.round(tfsa0 * Math.pow(1 + growth, age - startAge)));
    } else {
      if (tfsaAtDrawStart === null) tfsaAtDrawStart = tfsa[tfsa.length - 1];
      tfsa.push(Math.round(tfsaAtDrawStart * (1 - (age - taxableEndAge) / (endAge - taxableEndAge))));
    }
  }
  return { ages, rrsp, taxable, tfsa, cpp_start_age: 70, oas_start_age: 70 };
}
const HELP_DRAWDOWN_SAMPLE = _retirementDrawdownSample();
// Renders the real AccountBalancesChart (the same one the Save tab's
// "Retirement Withdrawals" card renders) inside the same .db-card/.pl-sec-lbl
// chrome — not a redrawn illustration.
function HelpRetirementDrawdownFigure() {
  return (
    <div className="db-card" style={{ padding: "20px 22px 22px" }}>
      <div className="pl-sec-lbl" style={{ marginBottom: 16 }}>
        <span>Retirement Withdrawals</span><span>Ages 65–96</span>
      </div>
      <AccountBalancesChart decum={HELP_DRAWDOWN_SAMPLE} />
    </div>
  );
}

// Synthetic re-amortized retirement income shaped like the real
// decumulation.* series AfterTaxIncomeChart renders: gross withdrawal easing
// down through the early years then levelling off (the "smile"), plus the
// income_pct/income_pct_net MC band showing how far a single year's income
// could swing across simulated markets — illustrative only, not derived from
// any real account. draw_taxable/draw_tfsa are left at $0 (this sample only
// models the RRSP+CPP/OAS income split, not account depletion — that's what
// HelpRetirementDrawdownFigure covers), so the Details modal's Taxable/TFSA
// columns read "—" rather than showing fabricated numbers.
function _payChequeVariabilitySample() {
  const startAge = 65, endAge = 96, cppAge = 70;
  const ages = [];
  for (let a = startAge; a <= endAge; a++) ages.push(a);
  const n = ages.length;
  const target = 50000, cppOasAmt = 18000;
  const withdrawal = [], tax_paid = [], eff_tax_rate = [], draw_rrsp = [], cpp_oas = [];
  const p10 = [], p50 = [], p90 = [];
  for (let i = 0; i < n; i++) {
    const t = i / (n - 1);
    const ease = Math.min(t / 0.45, 1);
    const gross = Math.round(58000 - 12000 * ease);         // eases down, then levels off
    const cpp = ages[i] >= cppAge ? cppOasAmt : 0;
    const rate = ages[i] < cppAge ? 16 : 14;
    withdrawal.push(gross);
    cpp_oas.push(cpp);
    draw_rrsp.push(Math.max(0, gross - cpp));
    eff_tax_rate.push(rate);
    tax_paid.push(Math.round(gross * rate / 100));
    // Re-amortized income band: uncertainty widest with the most years of
    // markets still ahead, narrowing as the plan nears its end.
    const spreadUp = gross * (0.5 - 0.32 * t);
    const spreadDn = gross * (0.28 - 0.2 * t);
    p50.push(gross);
    p90.push(Math.round(gross + spreadUp));
    p10.push(Math.round(gross - spreadDn));
  }
  const netOf = arr => arr.map(v => Math.round(v * 0.87));
  return {
    ages, withdrawal, tax_paid, eff_tax_rate, draw_rrsp, cpp_oas,
    draw_taxable: ages.map(() => 0), draw_tfsa: ages.map(() => 0),
    annual_need: target, cpp_start_age: cppAge, oas_start_age: cppAge,
    income_pct: { p10, p50, p90 },
    income_pct_net: { p10: netOf(p10), p50: netOf(p50), p90: netOf(p90) },
  };
}
const HELP_PAYCHEQUE_SAMPLE = _payChequeVariabilitySample();
// Renders the real AfterTaxIncomeCard — the same "After-Tax Spendable Income"
// card (incl. its working Details button/modal) the Save tab's retirement
// decumulation section renders. `pf-proj-card` already supplies its own
// bordered/background chrome, so no extra wrapper is needed here.
function HelpRetirementPaychequeFigure() {
  return <AfterTaxIncomeCard decum={HELP_PAYCHEQUE_SAMPLE} />;
}

// Synthetic 1-year daily indexed-price series for five sample watchlist funds
// (real ETF tickers, chosen so each maps to a visually distinct classColor()
// bucket — EM/CA-equity/developed/preferred/fixed-income) shaped like the
// real /api/dashboard/price-history payload PriceIndexChart renders on the
// Track tab — illustrative only, not fetched from any account. A small
// seeded LCG (not Math.random()) keeps the figure's wiggle deterministic
// across renders instead of reshuffling on every repaint. Dates walk back
// from "today" (module-load time) so the window never drifts into the past
// the way a fixed calendar range would.
function _priceIndexSample() {
  const NDAYS = 260; // ~1 trading year
  const dates = [];
  let d = new Date();
  while (dates.length < NDAYS) {
    const dow = d.getDay();
    if (dow !== 0 && dow !== 6) dates.unshift(d.toISOString().slice(0, 10));
    d = new Date(d.getTime() - 86400000);
  }
  // drift/vol tuned (not the original small values) so the derived realized
  // vol / return spread — shared by HelpAssetMovementFigure — actually
  // separates the five funds into distinct risk/return clusters instead of
  // huddling near the middle of the bubble chart.
  const FUNDS = [
    { ticker: "XSEM", asset_class: "Emerging Markets",  drift: 0.00220,  vol: 0.0264, seed: 1103515245 },
    { ticker: "DRMC", asset_class: "Canadian Equity",   drift: 0.00130,  vol: 0.0175, seed: 1013904223 },
    { ticker: "ESGG", asset_class: "Developed Markets", drift: 0.00110,  vol: 0.0155, seed: 2091001379 },
    { ticker: "CPD",  asset_class: "Preferred Shares",  drift: 0.00050,  vol: 0.0080, seed: 314159265  },
    { ticker: "ESGB", asset_class: "Fixed Income",      drift: -0.00005, vol: 0.0056, seed: 271828183  },
  ];
  return FUNDS.map(f => {
    let rnd = f.seed;
    const next = () => { rnd = (rnd * 1103515245 + 12345) & 0x7fffffff; return rnd / 0x7fffffff; };
    let v = 100;
    const values = dates.map((_, i) => {
      if (i === 0) return 100;
      v *= 1 + f.drift + (next() - 0.5) * 2 * f.vol;
      return Math.round(v * 100) / 100;
    });
    return { ticker: f.ticker, asset_class: f.asset_class, dates, values };
  });
}
const HELP_PRICE_INDEX_SAMPLE = _priceIndexSample();
// Renders the real PriceIndexChart in its real "Price over time" card chrome
// — the exact markup DashboardTab renders above the watchlist table — fed
// the sample series above instead of a live fetch.
function HelpWatchlistPriceFigure() {
  return (
    <div className="db-card">
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14 }}>
        <div className="pf-card-title">Price over time</div>
        <div style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 10, color: "#647071", letterSpacing: ".06em" }}>
          INDEXED TO 100 · 1-YEAR
        </div>
      </div>
      <PriceIndexChart series={HELP_PRICE_INDEX_SAMPLE} />
    </div>
  );
}

// Renders the real AssetMovementChart — the same "Asset Movement" bubble
// chart the Track tab renders next to Watchlist Volatility vs Broad Market —
// fed the same HELP_PRICE_INDEX_SAMPLE series (one shared dataset behind all
// three watchlist figures, exactly like the real Track tab passes one
// `priceHistory` fetch into all three charts) in its real card chrome.
function HelpAssetMovementFigure() {
  return (
    <div className="db-card" style={{ marginBottom: 0 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14 }}>
        <div className="pf-card-title">Asset Movement</div>
        <div style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 10, color: "#647071", letterSpacing: ".06em" }}>
          RETURN VS REALIZED VOL · 1-YR
        </div>
      </div>
      <AssetMovementChart series={HELP_PRICE_INDEX_SAMPLE} />
    </div>
  );
}

// Synthetic 1-year daily VIX proxy — a mean-reverting series around a ~16
// baseline with a handful of multi-day stress episodes spiking into the
// upper 20s/low 30s — shaped like the real /api/dashboard/vix payload
// VolVixChart renders, sharing HELP_PRICE_INDEX_SAMPLE's own date axis so
// the two line up exactly. Illustrative only, not fetched from any feed.
function _vixSample(dates) {
  let rnd = 918273645;
  const next = () => { rnd = (rnd * 1103515245 + 12345) & 0x7fffffff; return rnd / 0x7fffffff; };
  const baseline = 16;
  let v = baseline, spikeDaysLeft = 0, spikeTarget = baseline;
  return dates.map(d => {
    if (spikeDaysLeft <= 0 && next() < 0.02) {          // ~2% daily chance a stress episode starts
      spikeDaysLeft = 4 + Math.floor(next() * 6);        // lasts 4-9 trading days
      spikeTarget = baseline + 8 + next() * 10;           // spikes to roughly 26-34
    }
    const target = spikeDaysLeft > 0 ? spikeTarget : baseline;
    if (spikeDaysLeft > 0) spikeDaysLeft--;
    v += (target - v) * 0.35 + (next() - 0.5) * 1.6;       // fast reversion toward target + daily noise
    v = Math.max(10.5, Math.min(34, v));
    return { d, v: Math.round(v * 10) / 10 };
  });
}
const HELP_VIX_SAMPLE = _vixSample(HELP_PRICE_INDEX_SAMPLE[0].dates);
// Renders the real VolVixChart — the same "Watchlist Volatility vs Broad
// Market" card the Track tab renders — in its real chrome, fed the shared
// price sample plus the synthetic VIX series above.
function HelpVolVixFigure() {
  return (
    <div className="db-card" style={{ marginBottom: 0 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14 }}>
        <div className="pf-card-title">Watchlist Volatility vs Broad Market</div>
        <div style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 10, color: "#647071", letterSpacing: ".06em" }}>
          1-YR
        </div>
      </div>
      <VolVixChart series={HELP_PRICE_INDEX_SAMPLE} vixData={HELP_VIX_SAMPLE} />
    </div>
  );
}

// Purpose-built price sample for the correlation figure — seven funds across
// four classes (two Canadian-equity, two developed-market, two fixed-income,
// one lone preferred-share fund), NOT a reuse of HELP_PRICE_INDEX_SAMPLE:
// that sample's five funds are deliberately ONE per class and independently
// seeded (so AssetMovementChart's bubble chart spreads them into distinct
// clusters — see its own comment above), which would hand this figure a
// matrix of near-zero correlations with no same-class block for "Reading the
// grid" to point at. Returns are built from a shared daily market factor
// (positive for equities, negative for fixed income — a mild
// flight-to-quality lean) plus a per-class factor shared only by same-class
// funds (real tracking correlation between two funds indexing the same
// market) plus fund-specific noise, off the same seeded LCG the other sample
// generators use — deterministic across renders, not Math.random().
function _correlationFundSample() {
  const NDAYS = 260;
  const dates = [];
  let d = new Date();
  while (dates.length < NDAYS) {
    const dow = d.getDay();
    if (dow !== 0 && dow !== 6) dates.unshift(d.toISOString().slice(0, 10));
    d = new Date(d.getTime() - 86400000);
  }
  let rnd = 987654321;
  const next = () => { rnd = (rnd * 1103515245 + 12345) & 0x7fffffff; return rnd / 0x7fffffff; };
  const u = () => (next() - 0.5) * 2; // uniform on [-1,1]

  const FUNDS = [
    { ticker: "VCN", asset_class: "Canadian Equity",   cls: "ca",  marketBeta: 1.00, classBeta: 0.55, idio: 0.55, drift: 0.00075 },
    { ticker: "XIC", asset_class: "Canadian Equity",   cls: "ca",  marketBeta: 1.00, classBeta: 0.55, idio: 0.55, drift: 0.00070 },
    { ticker: "VXC", asset_class: "Developed Markets", cls: "dev", marketBeta: 1.00, classBeta: 0.55, idio: 0.55, drift: 0.00085 },
    { ticker: "XAW", asset_class: "Developed Markets", cls: "dev", marketBeta: 1.00, classBeta: 0.55, idio: 0.55, drift: 0.00080 },
    { ticker: "VAB", asset_class: "Fixed Income",      cls: "fi",  marketBeta: -0.25, classBeta: 0.55, idio: 0.45, drift: 0.00010 },
    { ticker: "ZAG", asset_class: "Fixed Income",      cls: "fi",  marketBeta: -0.25, classBeta: 0.55, idio: 0.45, drift: 0.00008 },
    { ticker: "CPD", asset_class: "Preferred Shares",  cls: "pf",  marketBeta: 0.45, classBeta: 0.00, idio: 0.85, drift: 0.00030 },
  ];
  const classFactor = {};
  ["ca", "dev", "fi", "pf"].forEach(c => { classFactor[c] = dates.map(() => u() * 0.008); });
  const marketFactor = dates.map(() => u() * 0.007);

  return FUNDS.map(f => {
    let v = 100;
    const values = dates.map((_, i) => {
      if (i === 0) return 100;
      const shock = f.drift
        + f.marketBeta * marketFactor[i]
        + f.classBeta * classFactor[f.cls][i]
        + f.idio * (u() * 0.006);
      v *= 1 + shock;
      return Math.round(v * 100) / 100;
    });
    return { ticker: f.ticker, asset_class: f.asset_class, dates, values };
  });
}

// Illustrative per-fund dollar weights so the "corr to portfolio" bar has
// something to show — sized like a real diversified watchlist, not an even
// split.
const HELP_CORR_WEIGHTS = { VCN: 22000, XIC: 9000, VXC: 26000, XAW: 11000, VAB: 15000, ZAG: 7000, CPD: 6000 };

// Real Pearson correlation over _correlationFundSample()'s own daily returns
// — the exact algorithm correlation.py's pearson_matrix() uses (n-1 stdev,
// cov/(si*sj), rounded to 3dp) — rather than a hand-authored matrix, so this
// figure's numbers are an honest correlation of the price series actually
// drawn, not invented ones. Shape mirrors what GET /api/dashboard/correlations
// returns: {tickers, classes, weights, vols, matrix, dateRange, nObs}.
function _correlationSample(series) {
  const tickers = series.map(s => s.ticker).sort();
  const byTicker = {};
  series.forEach(s => { byTicker[s.ticker] = s; });

  const returns = {};
  tickers.forEach(t => {
    const v = byTicker[t].values;
    const r = [];
    for (let i = 1; i < v.length; i++) r.push(v[i] / v[i - 1] - 1);
    returns[t] = r;
  });

  const n = tickers.length;
  const means = {}, stds = {};
  tickers.forEach(t => {
    const r = returns[t];
    const m = r.reduce((a, b) => a + b, 0) / r.length;
    means[t] = m;
    stds[t] = Math.sqrt(r.reduce((a, x) => a + (x - m) ** 2, 0) / (r.length - 1));
  });

  const matrix = tickers.map(() => tickers.map(() => 0));
  for (let i = 0; i < n; i++) {
    matrix[i][i] = 1;
    for (let j = i + 1; j < n; j++) {
      const ti = tickers[i], tj = tickers[j];
      const ri = returns[ti], rj = returns[tj];
      const cov = ri.reduce((a, x, k) => a + (x - means[ti]) * (rj[k] - means[tj]), 0) / (ri.length - 1);
      const corr = stds[ti] > 0 && stds[tj] > 0 ? Math.round((cov / (stds[ti] * stds[tj])) * 1000) / 1000 : 0;
      matrix[i][j] = corr;
      matrix[j][i] = corr;
    }
  }

  const dates = byTicker[tickers[0]].dates;
  return {
    tickers,
    classes: tickers.map(t => byTicker[t].asset_class),
    weights: tickers.map(t => HELP_CORR_WEIGHTS[t] || 0),
    vols: tickers.map(t => stds[t]),
    matrix,
    dateRange: `${dates[0]} to ${dates[dates.length - 1]}`,
    nObs: returns[tickers[0]].length,
  };
}
const HELP_CORR_SAMPLE = _correlationSample(_correlationFundSample());
// Renders the real CorrelationHeatmap — the same "Observed Correlations" card
// the Track tab renders above the Watchlist table. CorrelationHeatmap owns
// its full db-card chrome itself (title, date-range/obs stamp, class key,
// colour-scale legend), so unlike the other watchlist figures this wrapper
// adds none of its own.
function HelpCorrelationFigure() {
  return <CorrelationHeatmap data={HELP_CORR_SAMPLE} />;
}

// Synthetic accumulation fan (multiples of today's total, shaped like the
// real projection.accumulation real_pct series) paired with the drawdown
// sample as its decumulation — one shared retirement dataset behind both the
// drawdown figure and the lifecycle figure, exactly like the watchlist
// figures share one price sample. The terminal median multiple (~2.8× on
// $260k ≈ $730k) matches what HELP_DRAWDOWN_SAMPLE's buckets open with, so
// the chart's two phases meet without a visible jump.
function _lifecycleAccumSample() {
  const H = 15, ends = [1.9, 2.3, 2.8, 3.4, 4.0]; // p10..p90 terminal multiples
  const years = [], real_pct = [[], [], [], [], []];
  for (let t = 0; t <= H; t++) {
    years.push(t);
    const f = t / H;
    const med = Math.pow(2.8, f);
    ends.forEach((e, pi) => {
      real_pct[pi].push(med * Math.pow(e / 2.8, Math.pow(f, 1.2)));
    });
  }
  return { years, real_pct };
}
const HELP_LIFECYCLE_SAMPLE = {
  accumulation: _lifecycleAccumSample(),
  decumulation: { ...HELP_DRAWDOWN_SAMPLE, retire_age: 65, decum_vol: 0.06 },
};
// The baseline overlay for the same sample — shaped like the `benchmark` block
// of a real /api/portfolio/baseline/<goal> response so the figure teaches the
// line a reader will actually meet. Synthetic like everything else here, but it
// carries the two properties the real one always has and a hand-drawn
// illustration would get wrong: the wedge WIDENS through accumulation as the
// fee gap compounds, then CLOSES to nothing by the terminal age, because both
// plans exhaust capital at 96 and the advantage was spent rather than kept.
// That closing is why the footnote states the income pair.
const HELP_BASELINE_SAMPLE = (() => {
  const total = 260000, MER_GAP = 0.0083;       // 1.05% benchmark − a 0.22% plan
  const acc = HELP_LIFECYCLE_SAMPLE.accumulation;
  const p50 = acc.real_pct[2].map(m => m * total);
  const base = p50.map((v, t) => Math.round(v * Math.pow(1 - MER_GAP, t)));
  const d = HELP_LIFECYCLE_SAMPLE.decumulation;
  const n = d.ages.length;
  const planBal = d.ages.map((_, i) => (d.rrsp[i] || 0) + (d.taxable[i] || 0) + (d.tfsa[i] || 0));
  const gap0 = 1 - base[base.length - 1] / p50[p50.length - 1];
  return {
    visible: true,
    accumulation: { years: acc.years, p50: base },
    decumulation: {
      ages: d.ages, retire_age: 65, smile_peak: 46200,
      balance: planBal.map((v, i) => Math.round(v * (1 - gap0 * (1 - i / (n - 1))))),
    },
    gap_at_retirement: Math.round(p50[p50.length - 1] - base[base.length - 1]),
    plan_smile_peak: 52800, real_dollars: true,
    assumptions: { benchmark_mer: 0.0105, plan_mer: 0.0022, bench_bond_frac: 0.40,
                   decum_strategy: "proportional", plan_decum_strategy: "meltdown" },
  };
})();
// Renders the real LifecycleChart — the same "Portfolio Lifecycle Projection"
// card the Save tab's Projections section renders for a retirement goal — in
// its real pf-proj-card chrome.
function HelpLifecycleFigure() {
  return (
    <div className="pf-proj-card">
      <div className="pf-proj-head" style={{ marginBottom: 10 }}>
        <span>
          <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 12.5, fontWeight: 500, color: "#1a1a1a" }}>
            Portfolio Lifecycle Projection
          </span>
          <span style={{ fontSize: 11.5, color: "#647071", marginLeft: 8 }}>saving $18k / year until retirement</span>
        </span>
      </div>
      <LifecycleChart proj={HELP_LIFECYCLE_SAMPLE} total={260000} currentYear={HELP_SAMPLE_NOW_YEAR}
        baseline={HELP_BASELINE_SAMPLE} />
    </div>
  );
}

// Four calendar years of history + gray forward bars at the annual-savings
// rate — shaped like the real selectedGoal.history {dates, values, contribs,
// seed} payload pr_db.load_ips_annual_series returns. Years walk back from
// the real current year so the figure never dates itself; year 2's negative
// market gain is deliberate — the red-bar state is part of what the chart
// teaches. Consistency: each year's close = prior close (or seed) + contrib
// + gain, so the derived gain bars are exactly what the chart computes.
const HELP_CONTRIB_SAMPLE = (() => {
  const y = HELP_SAMPLE_NOW_YEAR;
  return {
    seed: 20000,
    dates:    [`${y - 3}-12-31`, `${y - 2}-12-31`, `${y - 1}-12-31`, `${y}-06-30`],
    values:   [27800, 33100, 44000, 50100],
    contribs: [6000, 6500, 7000, 3500],
  };
})();
// Renders the real ContribHistoryChart — the same "Contributions over time"
// chart the Save tab's savings module renders — in its real chrome.
function HelpContribHistoryFigure() {
  return (
    <div className="pf-proj-card">
      <div className="pf-proj-head" style={{ marginBottom: 10 }}>
        <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 12.5, fontWeight: 500, color: "#1a1a1a" }}>
          Contributions over time
        </span>
      </div>
      <ContribHistoryChart history={HELP_CONTRIB_SAMPLE} annualSavings={7200} />
    </div>
  );
}

// Renders the real AllocMixView (the Save tab allocation panel's "Mix" donut
// view) fed the same HELP_ALLOC_DRIFT_SAMPLE the drift figure uses — one
// shared allocation dataset behind all three allocation-view figures.
function HelpAllocMixFigure() {
  return (
    <div className="db-card" style={{ padding: "20px 22px 22px" }}>
      <div className="pl-sec-lbl" style={{ marginBottom: 12 }}>
        <span>Current Mix</span><span>4 asset classes</span>
      </div>
      <AllocMixView allocation={HELP_ALLOC_DRIFT_SAMPLE} />
    </div>
  );
}

// Renders the real AllocByAccountView (the "By account" stacked bars) fed the
// same shared allocation sample; goalTotal matches the sample's $100k of
// per-account amounts so the "% of portfolio" labels are exact.
function HelpAllocAccountFigure() {
  return (
    <div className="db-card" style={{ padding: "20px 22px 22px" }}>
      <div className="pl-sec-lbl" style={{ marginBottom: 12 }}>
        <span>By Account</span><span>RRSP · TFSA · Taxable</span>
      </div>
      <AllocByAccountView allocation={HELP_ALLOC_DRIFT_SAMPLE} goalTotal={100000} />
    </div>
  );
}

// The real ROLE_TICKERS swap (allocation.py): with the climate screen on,
// each role's fund swaps to its screened counterpart — same role, same
// asset-class mix, same published class-level return expectations
// (methodology.md § 11a rule 1: ESG funds take the class figures unchanged).
// Tickers mirror allocation.py's ROLE_TICKERS — keep in sync if a role's
// fund ever changes.
const HELP_CLIMATE_ROLES = [
  { cls: "Canadian Equity",   plain: "VCN", screened: "DRMC" },
  { cls: "Developed Markets", plain: "VXC", screened: "ESGG" },
  { cls: "Emerging Markets",  plain: "VEE", screened: "XSEM" },
  { cls: "Fixed Income",      plain: "VAB", screened: "ESGB" },
];
function HelpClimateSwapFigure() {
  const cards = [
    { title: "Screen off", tag: "Plain index funds",  key: "plain" },
    { title: "Screen on",  tag: "Climate-screened",   key: "screened" },
  ];
  return (
    <div className="help-fig2-grid">
      {cards.map(c => (
        <div key={c.key} className="goal-card pl-card">
          <div className="pl-sec-lbl"><span>{c.title}</span><span>{c.tag}</span></div>
          <div style={{ display: "flex", flexDirection: "column", gap: 9, marginTop: 6 }}>
            {HELP_CLIMATE_ROLES.map(r => (
              <div key={r.cls} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 12.5 }}>
                <span className="db-tbl-cls">
                  <span className="db-cls-dot" style={{ background: classColor(r.cls) }} />
                  {AC_LABELS[r.cls] || r.cls}
                </span>
                <span style={{ fontFamily: "'IBM Plex Mono',monospace", color: "#1a1a1a" }}>{r[c.key]}</span>
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

// Two sample alert rules rendered through the real AlertRuleRow — the same
// toggle/sentence/tooltip rows the Alerts page renders — with local state so
// the toggles actually flip. showActions is off: there is no real DB rule
// behind these to edit or delete.
const HELP_ALERT_SAMPLE_RULES = [
  { id: 1, name: "Buy-the-dip watch", condition_type: "composite_oversold", scope: "watchlist", scope_value: null,   cooldown_days: 5, enabled: true },
  { id: 2, name: "VEQT running hot",  condition_type: "rsi_above",          scope: "ticker",    scope_value: "VEQT", cooldown_days: 7, enabled: false },
];
function HelpAlertRulesFigure() {
  const [rules, setRules] = useState(HELP_ALERT_SAMPLE_RULES);
  const [tipOpenId, setTipOpenId] = useState(null);
  return (
    <div className="db-card" style={{ padding: "8px 22px 10px" }}>
      <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", padding: "12px 0 2px" }}>
        <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 15, fontWeight: 500, fontVariant: "small-caps", color: "#8a5709" }}>Your alerts</span>
        <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 11, color: "#647071" }}>
          {rules.filter(r => r.enabled).length} active
        </span>
      </div>
      {rules.map(rule => (
        <AlertRuleRow key={rule.id} rule={rule} editing={false} showActions={false}
          tipOpen={tipOpenId === rule.id}
          onTipEnter={() => setTipOpenId(rule.id)}
          onTipLeave={() => setTipOpenId(null)}
          onTipToggle={() => setTipOpenId(id => id === rule.id ? null : rule.id)}
          onToggle={() => setRules(prev => prev.map(x => x.id === rule.id ? { ...x, enabled: !x.enabled } : x))}
          onEdit={() => {}} onDelete={() => {}} />
      ))}
    </div>
  );
}

// Small trigger card shared by the "open the real modal" Help figures — same
// pf-proj-card/gear-pill markup SavingsModuleCard renders, with an added
// "Walk through" tag. Unlike every other figure in the Help Center, these two
// launch a real, full-screen modal over the whole page — worth flagging
// before the click, not only once it's already open.
function HelpModalTriggerCard({ title, onOpen }) {
  return (
    <div className="pf-proj-card" style={{ maxWidth: 340 }}>
      <div style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 10, letterSpacing: ".14em",
                    textTransform: "uppercase", color: "#a1450f", marginBottom: 8 }}>
        Walk through
      </div>
      <div className="pf-proj-head" style={{ marginBottom: 6 }}>
        <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 12.5, fontWeight: 500, color: "#1a1a1a" }}>
          {title}
        </span>
        <button onClick={onOpen} title={`Manage ${title.toLowerCase()}`}
          style={{ background: "#faf9f7", border: "1px solid #d4cfc5", borderRadius: 12,
                   padding: "4px 10px", fontFamily: "'IBM Plex Mono',monospace", fontSize: 14,
                   color: "#5a6a72", cursor: "pointer", letterSpacing: ".04em", lineHeight: 1,
                   display: "inline-flex", alignItems: "center" }}>
          <i className="ti ti-settings" aria-hidden="true" />
        </button>
      </div>
      <div style={{ fontSize: 11.5, color: "#647071" }}>Click the gear icon to try it — nothing here is saved.</div>
    </div>
  );
}

// Renders the real gear-icon trigger (the same pill SavingsModuleCard's
// "Contributions over time" card header shows) which, on click, opens the
// real PortfolioEditModalShell + ContributionsPanelBody — the exact modal
// the Save tab opens, minus the live /api/portfolio/contributions fetch:
// local state stands in for the network round-trip, the same way the CPP/OAS
// and allocation-slider figures use local state in place of `form`/`setForm`.
// Starts with one sample RRSP contribution already recorded (matching the
// screenshot this figure was requested from) so the YTD summary and list
// aren't empty; adding, correcting, validating, and deleting are all genuinely
// live against that local state. The Holdings tab is present (clicking it
// really switches) but shows a pointer to the "Recording holdings" article
// instead of a second live editor — out of scope for what this figure is
// illustrating.
function HelpContributionsModalFigure() {
  const today = new Date().toISOString().slice(0, 10);
  const [open, setOpen] = useState(false);
  const [tab, setTab] = useState("contributions");
  const [rows, setRows] = useState([
    { id: 1, contrib_date: today, amount: 500, bucket: "rrsp", source: "manual", notes: "Employer contribution", validated: false },
  ]);
  const [editing, setEditing] = useState(null);
  const [fDate, setFDate] = useState(today);
  const [fAmount, setFAmount] = useState("");
  const [fBucket, setFBucket] = useState("rrsp");
  const [fNotes, setFNotes] = useState("");
  const [fErr, setFErr] = useState(null);

  const data = { contributions: rows, ytd_total: rows.reduce((s, r) => s + r.amount, 0) };

  const handleAdd = () => {
    setFErr(null);
    const amt = parseFloat(fAmount);
    if (!fDate) { setFErr("Date is required"); return; }
    if (!amt || isNaN(amt)) { setFErr("Enter a valid amount"); return; }
    setRows(rs => [{ id: Date.now(), contrib_date: fDate, amount: amt, bucket: fBucket || null, source: "manual", notes: fNotes || null, validated: false }, ...rs]);
    setFAmount(""); setFNotes("");
  };
  const handleCorrect = () => {
    if (!editing) return;
    const amt = parseFloat(editing.amount);
    if (!amt || isNaN(amt)) return;
    setRows(rs => rs.map(r => r.id === editing.id ? { ...r, amount: amt, source: "manual" } : r));
    setEditing(null);
  };
  const handleValidate = (c) => setRows(rs => rs.map(r => r.id === c.id ? { ...r, validated: !r.validated } : r));
  const handleDismissDelete = (c) => setRows(rs => rs.filter(r => r.id !== c.id));

  return (
    <>
      <HelpModalTriggerCard title="Contributions over time" onOpen={() => setOpen(true)} />

      {open && (
        <PortfolioEditModalShell goalLabel="Retirement" tab={tab} setTab={setTab} onClose={() => setOpen(false)} demo>
          <ContributionsPanelBody
            active={tab === "contributions"} data={data} err={null} busy={false}
            editing={editing} setEditing={setEditing}
            fDate={fDate} setFDate={setFDate}
            fAmount={fAmount} setFAmount={setFAmount}
            fBucket={fBucket} setFBucket={setFBucket}
            fNotes={fNotes} setFNotes={setFNotes}
            fErr={fErr} accounts={["tfsa", "rrsp", "taxable"]}
            handleCorrect={handleCorrect} handleValidate={handleValidate}
            handleDismissDelete={handleDismissDelete} handleAdd={handleAdd}
          />
          <div style={{ display: tab === "holdings" ? "flex" : "none", flexDirection: "column",
                        flex: 1, minHeight: 0, alignItems: "center", justifyContent: "center",
                        padding: 24, textAlign: "center" }}>
            <div style={{ color: "#647071", fontSize: 12.5, lineHeight: 1.6, maxWidth: 300 }}>
              The Holdings tab works the same way — see the <strong>Recording holdings</strong> article for a walkthrough.
            </div>
          </div>
        </PortfolioEditModalShell>
      )}
    </>
  );
}

// Sample TSX-style ETF registry — the same shape data.tickers takes in the
// real GET /api/portfolio/holdings-edit response — so the ticker search has
// something to fall back to client-side (registryTickers filter path) with no
// live /api/tickers/search call, and the "one all-in-one fund" path has a
// candidate to suggest (VGRO, carrying the same equity_weight_pct real rows
// now carry). Illustrative only.
const HELP_HOLDINGS_TICKERS = [
  { ticker: "VCN",  asset_class: "Canadian Equity",   price: 45.20, notes: "Vanguard FTSE Canada All Cap Index ETF" },
  { ticker: "XAW",  asset_class: "Developed Markets", price: 38.10, notes: "iShares Core MSCI All Country World ex Canada Index ETF" },
  { ticker: "ZAG",  asset_class: "Fixed Income",      price: 12.85, notes: "BMO Aggregate Bond Index ETF" },
  { ticker: "VGRO", asset_class: "Multi-Asset",       price: 35.10, notes: "Vanguard Growth ETF Portfolio", equity_weight_pct: 80, is_esg: false },
];
// Non-cash holdings only — CASH is always the auto-balanced remainder now
// (see HoldingsEditPanel), never part of `rows`.
const HELP_HOLDINGS_SAMPLE_ROWS = [
  { ticker: "VCN", asset_class: "Canadian Equity", custom: false, price: 45.20,
    rrsp_units: 140, tfsa_units: 0, taxable_units: 0, resp_units: null, srrsp_units: null,
    rrsp_amount: null, tfsa_amount: null, taxable_amount: null, resp_amount: null, srrsp_amount: null },
  { ticker: "XAW", asset_class: "Developed Markets", custom: false, price: 38.10,
    rrsp_units: 0, tfsa_units: 260, taxable_units: 0, resp_units: null, srrsp_units: null,
    rrsp_amount: null, tfsa_amount: null, taxable_amount: null, resp_amount: null, srrsp_amount: null },
];
// Bucket totals — the declared account balance each bucket's Cash remainder is
// computed against. Chosen so both RRSP and TFSA show a plausible positive
// cash remainder once VCN/XAW's value is subtracted.
const HELP_HOLDINGS_TOTALS = { rrsp_units: 7500, tfsa_units: 10000, taxable_units: 0 };
// Sample plan target weights (a 70%-equity plan via the app's real 29/60/11
// Canada/developed/emerging proxy) so the allocation-vs-plan bar has
// something real to compare the sample holdings against.
const HELP_HOLDINGS_TARGET_BY_CLASS = {
  "Fixed Income": 30, "Canadian Equity": 20.3, "Developed Markets": 42, "Emerging Markets": 7.7,
};

// Renders the real gear-icon trigger (the same pill SavingsModuleCard's
// "Holdings" card header shows) which, on click, opens the real
// PortfolioEditModalShell + HoldingsEditPanelBody — the exact modal the Save
// tab opens, minus the live GET/POST /api/portfolio/holdings-edit calls:
// local state stands in for both the initial fetch and the save, the same
// pattern HelpContributionsModalFigure uses for ContributionsPanelBody.
// Path switching, editing unit/dollar amounts and bucket totals, arming/
// confirming a Remove, and adding a holding (the ticker box falls back to the
// small sample registry above, exactly like the real component's own client-
// side fallback when the live catalog search is empty) are all genuinely live
// against that local state; Discard/Save only ever touch the local copy —
// "nothing is saved" in the modal title means exactly that.
function HelpHoldingsModalFigure() {
  const [open, setOpen] = useState(false);
  const [tab, setTab] = useState("holdings");
  const [rows, setRows] = useState(HELP_HOLDINGS_SAMPLE_ROWS);
  const [savedRows, setSavedRows] = useState(HELP_HOLDINGS_SAMPLE_ROWS);
  const [totals, setTotals] = useState(HELP_HOLDINGS_TOTALS);
  const [savedTotals, setSavedTotals] = useState(HELP_HOLDINGS_TOTALS);
  const [path, setPath] = useState("individual");
  const [savedPath, setSavedPath] = useState("individual");
  const [dirty, setDirty] = useState(false);
  const [err, setErr] = useState(null);
  const [flash, setFlash] = useState(false);
  const [delArm, setDelArm] = useState(null);
  const [tkSearch, setTkSearch] = useState("");
  const [showDrop, setShowDrop] = useState(false);
  const [customDraft, setCustomDraft] = useState(null);
  const [catalogResults, setCatalogResults] = useState([]);

  const data = {
    accounts: ["rrsp", "tfsa", "taxable"], tickers: HELP_HOLDINGS_TICKERS,
    equity_pct: 70, climate_screen: false, target_by_class: HELP_HOLDINGS_TARGET_BY_CLASS,
  };
  const tickerMap = React.useMemo(() => {
    const m = {};
    HELP_HOLDINGS_TICKERS.forEach(t => { m[t.ticker] = t; });
    return m;
  }, []);

  const handleSave = () => {
    setSavedRows(JSON.parse(JSON.stringify(rows)));
    setSavedTotals({ ...totals });
    setSavedPath(path);
    setDirty(false);
    setDelArm(null);
    setFlash(true);
    setTimeout(() => setFlash(false), 2200);
  };
  const handleDiscard = () => {
    setRows(JSON.parse(JSON.stringify(savedRows)));
    setTotals({ ...savedTotals });
    setPath(savedPath);
    setDirty(false);
    setErr(null);
    setDelArm(null);
  };

  return (
    <>
      <HelpModalTriggerCard title="Holdings" onOpen={() => setOpen(true)} />

      {open && (
        <PortfolioEditModalShell goalLabel="Retirement" tab={tab} setTab={setTab} onClose={() => setOpen(false)} demo wide>
          <div style={{ display: tab === "contributions" ? "flex" : "none", flexDirection: "column",
                        flex: 1, minHeight: 0, alignItems: "center", justifyContent: "center",
                        padding: 24, textAlign: "center" }}>
            <div style={{ color: "#647071", fontSize: 12.5, lineHeight: 1.6, maxWidth: 300 }}>
              The Contributions tab works the same way — see the <strong>Tracking contributions</strong> article for a walkthrough.
            </div>
          </div>
          <HoldingsEditPanelBody
            active={tab === "holdings"} data={data} err={err} setErr={setErr} busy={false} flash={flash}
            rows={rows} setRows={setRows} totals={totals} setTotals={setTotals}
            path={path} setPath={setPath} tickerMap={tickerMap}
            dirty={dirty} setDirty={setDirty}
            delArm={delArm} setDelArm={setDelArm}
            tkSearch={tkSearch} setTkSearch={setTkSearch} showDrop={showDrop} setShowDrop={setShowDrop}
            customDraft={customDraft} setCustomDraft={setCustomDraft}
            catalogResults={catalogResults} setCatalogResults={setCatalogResults} searchCatalog={() => {}}
            save={handleSave} discard={handleDiscard}
          />
        </PortfolioEditModalShell>
      )}
    </>
  );
}

// A stand-in frontier for the Help figure, shaped exactly like the optimizer's
// real payload: `cloud` as bare [vol, cagr] pairs, `front` and `picks` as
// objects. Built from a fixed LCG rather than Math.random so the figure is
// pixel-identical on every render — a figure that reshuffles cannot be reviewed,
// and its caption would drift from what the reader sees.
const HELP_FRONTIER_SAMPLE = (() => {
  let s = 20260803;
  const rnd = () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296;
  const edgeAt = v => 5.0 + 6.5 * Math.pow((v - 4.5) / 6.3, 0.78);

  const cloud = [];
  for (let i = 0; i < 420; i++) {
    // Narrower vol band than the frontier spans, dropping away from the boundary
    // — random mixes cluster mid-risk and almost never touch the efficient edge,
    // which is the whole reason the boundary is worth drawing.
    const v = 5.6 + rnd() * 5.2;
    cloud.push([+v.toFixed(2),
                +(edgeAt(v) - 0.5 - (rnd() + rnd() + rnd()) * 1.15).toFixed(2)]);
  }
  const front = [];
  for (let i = 0; i <= 19; i++) {
    const v = 4.5 + (i / 19) * 6.3;
    front.push({ vol: +v.toFixed(2), cagr: +edgeAt(v).toFixed(2),
                 max_dd: -(7.5 + v * 2.05) });
  }
  const at = i => front[i];
  const mode = shift => ({
    cloud: cloud.map(c => [c[0], +(c[1] + shift).toFixed(2)]),
    front: front.map(p => ({ ...p, cagr: +(p.cagr + shift).toFixed(2) })),
    picks: { min_vol:    { ...at(0),  cagr: +(at(0).cagr + shift).toFixed(2) },
             max_sharpe: { ...at(9),  cagr: +(at(9).cagr + shift).toFixed(2) },
             max_return: { ...at(19), cagr: +(at(19).cagr + shift).toFixed(2) } },
  });
  return {
    ok: true,
    forward: mode(0),
    blend:   mode(-0.9),
    current: {
      current: { forward: { vol: 8.6, cagr: 8.9, max_dd: -21 },
                 blend:   { vol: 8.6, cagr: 8.2, max_dd: -22 }, covered: 1 },
      plan:    { forward: { vol: 7.4, cagr: 9.3, max_dd: -18 },
                 blend:   { vol: 7.4, cagr: 8.5, max_dd: -19 }, covered: 1 },
    },
  };
})();

// Renders the REAL FrontierChart — the same component the Save tab's Optimizer
// draws — on the sample above, with its markers live so the figure demonstrates
// the one interaction the article describes: every marker is selectable.
function HelpFrontierFigure() {
  const [pick, setPick] = useState("max_sharpe");
  return (
    <div className="pf-proj-card">
      <div className="op-head" style={{ marginBottom: 10 }}>
        <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 12.5, fontWeight: 500, color: "#1a1a1a" }}>
          Efficient frontier
        </span>
      </div>
      {/* `OPT_MODE`, not a local toggle. A figure showing a control the product
          does not have is worse than no figure — the reader goes looking for it. */}
      <FrontierChart data={HELP_FRONTIER_SAMPLE} mode={OPT_MODE} pick={pick} onPick={setPick} />
    </div>
  );
}

// Figure registry — content data (help-content.js) can only hold a `kind`
// string, never a live component. Maps that string to the real wrapper
// component. See ui.md § Help Center for the "add a new figure" recipe.
const HELP_FIGURES = {
  "goal-timeline": HelpGoalTimelineFigure,
  "goal-picker": HelpGoalPickerFigure,
  "gov-benefit-sliders": HelpGovBenefitFigure,
  "allocation-slider": HelpAllocationSliderFigure,
  "allocation-drift": HelpAllocDriftFigure,
  "allocation-compare": HelpAllocationCompareFigure,
  "wealth-trajectory": HelpWealthTrajectoryFigure,
  "goal-signals": HelpGoalSignalsFigure,
  "retirement-drawdown": HelpRetirementDrawdownFigure,
  "retirement-paycheque": HelpRetirementPaychequeFigure,
  "watchlist-price-index": HelpWatchlistPriceFigure,
  "watchlist-asset-movement": HelpAssetMovementFigure,
  "watchlist-vol-vix": HelpVolVixFigure,
  "watchlist-correlation": HelpCorrelationFigure,
  "contributions-modal": HelpContributionsModalFigure,
  "holdings-modal": HelpHoldingsModalFigure,
  "lifecycle-projection": HelpLifecycleFigure,
  "contrib-history": HelpContribHistoryFigure,
  "allocation-mix": HelpAllocMixFigure,
  "allocation-account": HelpAllocAccountFigure,
  "climate-fund-swap": HelpClimateSwapFigure,
  "alert-rules": HelpAlertRulesFigure,
  "efficient-frontier": HelpFrontierFigure,
};

const HELP_TAB_LABELS = { plan: "Plan", portfolio: "Save", dashboard: "Track", methodology: "Methodology" };

// One category's articles as a card grid — title + one-line dek. The same
// index→article pattern is used by all four categories.
function HelpCategoryIndex({ articles, onOpen }) {
  return (
    <div className="help-index-grid">
      {articles.map(a => (
        <button key={a.id} type="button" className="help-index-card" onClick={() => onOpen(a.id)}>
          <div className="help-index-card-title">{a.title}</div>
          <div className="help-index-card-dek">{a.dek}</div>
        </button>
      ))}
    </div>
  );
}

// Resolves a figure-chapter block's declarative deepLink.action into a real
// onClick — content data (help-content.js) can't hold a function. New action
// types get added here, not by putting functions in the data file.
function resolveHelpAction(action, onSwitchTab, onNavigate) {
  if (!action) return null;
  if (action.type === "switchTab") return () => onSwitchTab(action.tab, action.opts);
  // {type:"navigate", page, opts?} — App-level pages (profile/alerts) that
  // aren't tabs; resolved through the same onNavigate PageNav/UserMenu use.
  if (action.type === "navigate" && onNavigate) return () => onNavigate(action.page, action.opts);
  return null;
}

// Bottom-of-article redirect banner ("Ready to set up your goals?" style) —
// an optional per-article `cta` pointing the reader at the app section the
// article just explained. Reuses resolveHelpAction so `action` stays a
// declarative descriptor, same as a figure-chapter's `deepLink`. Renders
// nothing when the article carries no `cta` — most articles have one, but
// it's opt-in, not automatic, since not every article has one specific place
// to send the reader (see help-content.js's per-article comments).
function HelpCtaBanner({ cta, onSwitchTab, onNavigate }) {
  if (!cta) return null;
  return (
    <div className="db-card help-cta-banner">
      <div>
        <div className="help-cta-heading">{cta.heading}</div>
        <div className="help-cta-body">{cta.body}</div>
      </div>
      <button className="sp-btn sp-btn-primary" onClick={resolveHelpAction(cta.action, onSwitchTab, onNavigate)}>{cta.buttonLabel}</button>
    </div>
  );
}

// One article: breadcrumb + title, then its blocks in order — "prose" blocks
// full-width, "figure-chapter" blocks via the existing HelpChapter (real
// component figure resolved from HELP_FIGURES by kind) — then any trailing
// article-level sources, then an optional bottom `cta` redirect banner. Page
// width (1180 vs 820) is decided by the caller.
function HelpArticlePage({ categoryLabel, article, onSwitchTab, onNavigate, onBack }) {
  return (
    <div>
      <div className="help-breadcrumb">
        <a href="#" onClick={e => { e.preventDefault(); onBack(); }}>Help Center</a>
        <span className="help-breadcrumb-sep">/</span>
        <a href="#" onClick={e => { e.preventDefault(); onBack(); }}>{categoryLabel}</a>
        <span className="help-breadcrumb-sep">/</span>
        <span>{article.title}</span>
      </div>
      <h1 className="sp-h1">{article.title}</h1>
      {article.blocks.map((b, i) => {
        if (b.type === "figure-chapter") {
          const FigureComp = HELP_FIGURES[b.figureKind];
          if (!FigureComp) {
            console.warn("[HelpArticlePage] unknown figureKind:", b.figureKind, "in article", article.id);
            return null;
          }
          const deepLink = b.deepLink
            ? { label: b.deepLink.label, onClick: resolveHelpAction(b.deepLink.action, onSwitchTab, onNavigate) }
            : null;
          return (
            <HelpChapter key={i} label={b.label} figNum={b.figNum} figCaption={b.figCaption}
              figure={<FigureComp {...(b.sampleData || {})} />} deepLink={deepLink} sources={b.sources}>
              <div dangerouslySetInnerHTML={{ __html: b.html }} />
            </HelpChapter>
          );
        }
        return <div key={i} className="doc-body" style={{maxWidth:760, marginBottom:20}} dangerouslySetInnerHTML={{ __html: b.html }} />;
      })}
      {article.sources && article.sources.length > 0 && (
        <div className="help-sources" style={{maxWidth:760}}>
          <span className="help-sources-tag">Sources</span>
          {article.sources.map((s, i) => (
            <a key={i} href={s.href} target="_blank" rel="noopener noreferrer">{s.label}</a>
          ))}
        </div>
      )}
      <HelpCtaBanner cta={article.cta} onSwitchTab={onSwitchTab} onNavigate={onNavigate} />
    </div>
  );
}

// Sidebar rail listing every category's articles as a collapsible accordion —
// lets a reader jump straight to any article, in any category, without
// retracing through a category index each time (mirrors asset.html's
// watch-rail). Since the sub-nav bar was removed this is the Help Center's only
// navigation — it carries both article-level jumps and "where am I" (active
// group deep green, open article gets a green left rule). The category index's
// .help-cat-pills row filters that one grid; it is not a second navigator.
// The current category starts expanded; opening another one is additive
// (doesn't collapse the current one) so a reader can browse across categories
// at once, closer to the expandable multi-section sidebar of a typical docs
// site. `expanded` intentionally does not reset when `activeTab` changes to a
// category already toggled open/closed by the reader — it only ever adds the
// newly-active category, never removes one the reader chose to close.
function HelpRail({ activeTab, activeArticleId, onNavigate }) {
  const [expanded, setExpanded] = useState(() => new Set([activeTab]));
  useEffect(() => {
    setExpanded(prev => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab)));
  }, [activeTab]);
  const toggle = (id) => setExpanded(prev => {
    const next = new Set(prev);
    if (next.has(id)) next.delete(id); else next.add(id);
    return next;
  });
  return (
    <nav className="help-rail" aria-label="Help Center contents">
      {Object.entries(HELP_TAB_LABELS).map(([id, label]) => {
        const articles = (window.HELP_ARTICLES && window.HELP_ARTICLES[id]) || [];
        const isOpen = expanded.has(id);
        return (
          <div key={id} className="help-rail-group">
            <button type="button" className={"help-rail-head" + (id === activeTab ? " current" : "")}
              onClick={() => toggle(id)} aria-expanded={isOpen}>
              <span>{label}</span>
              <i className={"ti ti-chevron-down help-rail-chev" + (isOpen ? " open" : "")} aria-hidden="true" />
            </button>
            {isOpen && (
              <div className="help-rail-list">
                {articles.map(a => (
                  <button key={a.id} type="button"
                    className={"help-rail-item" + (id === activeTab && a.id === activeArticleId ? " active" : "")}
                    onClick={() => onNavigate(id, a.id)}>
                    {a.title}
                  </button>
                ))}
              </div>
            )}
          </div>
        );
      })}
    </nav>
  );
}

// Thin router between a category's article index and one open article. Content
// comes from window.HELP_ARTICLES (help-content.js — a plain data file, not
// JSX, loaded before this script). `defaultTab`/`defaultArticleId` seed the
// initial view (from the URL hash, via App — see parseHelpHash); `onViewChange`
// reports every navigation upward so App can keep the hash in sync. HelpPage
// itself never touches window.location — that's App's job.
function HelpPage({ user, onSwitchTab, onLogout, onUserUpdated, onNavigate, defaultTab = "plan", defaultArticleId = null, onViewChange }) {
  const [tab, setTab] = useState(defaultTab);
  const [articleId, setArticleId] = useState(defaultArticleId);

  // A caller can re-navigate Help while it's already mounted (a hashchange
  // from the browser back button, another deep-link chip) — resync from props.
  useEffect(() => { setTab(defaultTab); setArticleId(defaultArticleId); }, [defaultTab, defaultArticleId]);

  const report = (nextTab, nextArticleId) => { if (onViewChange) onViewChange(nextTab, nextArticleId); };
  // goTo is the general navigator — it's what the rail uses to jump straight
  // into another category's article. openCategory/openArticle/goBack are the
  // narrower cases the subnav pills, index cards, and breadcrumb already used.
  const goTo = (nextTab, nextArticleId) => { setTab(nextTab); setArticleId(nextArticleId); report(nextTab, nextArticleId); };
  const openCategory = (id) => goTo(id, null);
  const openArticle = (id) => goTo(tab, id);
  const goBack = () => goTo(tab, null);

  const articles = (window.HELP_ARTICLES && window.HELP_ARTICLES[tab]) || [];
  const article = articleId ? articles.find(a => a.id === articleId) : null;
  const wide = article ? article.blocks.some(b => b.type === "figure-chapter") : true;

  return (
    <div className="sp-root">
      <PageNav activeTab={null} onSwitchTab={onSwitchTab} user={user} onLogout={onLogout} onUserUpdated={onUserUpdated} onNavigate={onNavigate} />
      {/* No sub-nav bar here by design. The Help Center used to carry a sticky
          `.sp-subnav` breadcrumb of the four category names — but those names
          are the sidebar's own group headings, so it answered "where am I" a
          second time and competed with the sidebar for the top of the screen
          while representing nothing sequential. The rail below is the only
          navigation; the one job the bar did add (filtering the card grid to a
          category) moved to .help-cat-pills, directly above the grid it
          filters. Do not reintroduce a bar here — see nav.css § .wz-*. */}
      {/* 1200 matches --sp-nav-max (nav.css) — the page must not run wider than
          the header bar above it. help-main's own maxWidth splits what's left
          after the rail+divider+gap (925 = 1200-246-1-28) for a wide
          article/index, or keeps prose at its original narrower 820 for
          plain-text readability. */}
      <div className="sp-page" style={{maxWidth: 1200}}>
        <div className="help-shell">
          <HelpRail activeTab={tab} activeArticleId={articleId} onNavigate={goTo} />
          <div className="help-main" style={{maxWidth: wide ? 925 : 820}}>
            {article ? (
              <HelpArticlePage categoryLabel={HELP_TAB_LABELS[tab]} article={article} onSwitchTab={onSwitchTab} onNavigate={onNavigate} onBack={goBack} />
            ) : (
              <>
                <h1 className="sp-h1">Help Center</h1>
                <p className="sp-lead" style={{marginBottom:18, maxWidth:760}}>Answers for using each part of SavingsPhase, plus the research behind how we calculate your numbers.</p>
                {/* Pills, not tabs — style.md's green-underline tab rule is
                    about switching between views; this switches which subset of
                    one grid is shown, so it sits with the grid it filters. */}
                <div className="help-cat-pills" role="tablist" aria-label="Help categories">
                  {Object.entries(HELP_TAB_LABELS).map(([id, label]) => (
                    <button key={id} type="button" role="tab" aria-selected={tab === id}
                      className={"help-cat-pill" + (tab === id ? " on" : "")}
                      onClick={() => openCategory(id)}>
                      {label}
                    </button>
                  ))}
                </div>
                <HelpCategoryIndex articles={articles} onOpen={openArticle} />
              </>
            )}
          </div>
        </div>
      </div>
      <SiteFooter />
    </div>
  );
}

// ── Alerts page (per-user alert rules) ───────────────────────────────────────
const _ALERT_SCENARIOS = {
  oversold:   { title: "an oversold fund",                   short: "looks oversold",                      side: "fall",
    meaning: "It has dropped enough that, historically, the heaviest selling pressure tends to ease — often a calmer place to add." },
  drop:       { title: "a sharp drop",                       short: "drops sharply from its recent high",  side: "fall",
    meaning: "Two things line up at once: it is oversold and sitting well below its recent peak — a bigger dislocation worth a look." },
  trend:      { title: "a break below the long-term trend",  short: "slips below its long-term trend",     side: "fall",
    meaning: "Its price has crossed below the average of roughly the last 200 trading days — the long trend has cooled." },
  falling:    { title: "a fast decline",                     short: "starts falling fast",                 side: "fall",
    meaning: "A steep short-term slide, measured over the last five trading days." },
  overbought: { title: "an overbought fund",                 short: "looks overbought",                    side: "rise",
    meaning: "It has risen enough that buying pressure tends to ease — a chance to trim back toward your plan." },
  surge:      { title: "a sharp run-up",                     short: "jumps sharply above its recent range",side: "rise",
    meaning: "Overbought and well above its recent range — a move big enough it may now be overweight in your plan." },
  abovetrend: { title: "a climb above the long-term trend",  short: "climbs above its long-term trend",   side: "rise",
    meaning: "Its price has crossed above the average of roughly the last 200 trading days." },
  rising:     { title: "a fast climb",                       short: "starts rising fast",                  side: "rise",
    meaning: "A steep short-term rise, measured over the last five trading days." },
};

const _ALERT_THRESHOLDS = {
  oversold:   { small: "RSI < 40",                            medium: "RSI < 30",                            large: "RSI < 22" },
  drop:       { small: "RSI < 35 · down 7% / 20d",      medium: "RSI < 30 · down 10% / 20d",     large: "RSI < 28 · down 12% / 20d" },
  trend:      { small: "below 200-day avg · RSI < 50",  medium: "below 200-day avg · RSI < 45",  large: "below 200-day avg · RSI < 40" },
  falling:    { small: "down 3% / 5d",                        medium: "down 5% / 5d",                        large: "down 8% / 5d" },
  overbought: { small: "RSI > 60",                            medium: "RSI > 70",                            large: "RSI > 78" },
  surge:      { small: "RSI > 62 · up 7% / 20d",        medium: "RSI > 70 · up 10% / 20d",       large: "RSI > 72 · up 12% / 20d" },
  abovetrend: { small: "above 200-day avg · RSI > 50",  medium: "above 200-day avg · RSI > 55",  large: "above 200-day avg · RSI > 60" },
  rising:     { small: "up 3% / 5d",                          medium: "up 5% / 5d",                          large: "up 8% / 5d" },
};

const _ALERT_SENS_META = [
  { key: "small",  label: "Small",  fill: "0%",               left: "9px",              cooldown: 3,
    title: "Small moves — catch dislocations early.",
    caption: "More alerts, on shallower moves. Reminders pause 3 days between emails." },
  { key: "medium", label: "Medium", fill: "calc(50% - 9px)",  left: "calc(50%)",        cooldown: 5,
    title: "Medium moves — a balanced default.",
    caption: "Recommended. A meaningful dislocation before we email. Reminders pause 5 days." },
  { key: "large",  label: "Large",  fill: "calc(100% - 9px)", left: "calc(100% - 9px)", cooldown: 7,
    title: "Large moves only — just the big dislocations.",
    caption: "Fewer, deeper alerts. Reminders pause 7 days between emails." },
];

const _ALERT_COND_TO_SCENARIO = {
  rsi_below:            "oversold",
  composite_oversold:   "drop",
  sma_break:            "trend",
  roc_below:            "falling",
  rsi_above:            "overbought",
  composite_overbought: "surge",
  sma_break_above:      "abovetrend",
  roc_above:            "rising",
};

const _ALERT_SCENARIO_PARAMS = {
  oversold:   { small: { condition_type:"rsi_below", param_rsi:40 }, medium: { condition_type:"rsi_below", param_rsi:30 }, large: { condition_type:"rsi_below", param_rsi:22 } },
  drop:       { small: { condition_type:"composite_oversold", param_rsi:35, param_drawdown_pct:7,  param_drawdown_days:20 },
                medium: { condition_type:"composite_oversold", param_rsi:30, param_drawdown_pct:10, param_drawdown_days:20 },
                large:  { condition_type:"composite_oversold", param_rsi:28, param_drawdown_pct:12, param_drawdown_days:20 } },
  trend:      { small: { condition_type:"sma_break", param_rsi:50 }, medium: { condition_type:"sma_break", param_rsi:45 }, large: { condition_type:"sma_break", param_rsi:40 } },
  falling:    { small: { condition_type:"roc_below", param_roc:-3 }, medium: { condition_type:"roc_below", param_roc:-5 }, large: { condition_type:"roc_below", param_roc:-8 } },
  overbought: { small: { condition_type:"rsi_above", param_rsi:60 }, medium: { condition_type:"rsi_above", param_rsi:70 }, large: { condition_type:"rsi_above", param_rsi:78 } },
  surge:      { small: { condition_type:"composite_overbought", param_rsi:62, param_drawdown_pct:7,  param_drawdown_days:20 },
                medium: { condition_type:"composite_overbought", param_rsi:70, param_drawdown_pct:10, param_drawdown_days:20 },
                large:  { condition_type:"composite_overbought", param_rsi:72, param_drawdown_pct:12, param_drawdown_days:20 } },
  abovetrend: { small: { condition_type:"sma_break_above", param_rsi:50 }, medium: { condition_type:"sma_break_above", param_rsi:55 }, large: { condition_type:"sma_break_above", param_rsi:60 } },
  rising:     { small: { condition_type:"roc_above", param_roc:3 },  medium: { condition_type:"roc_above", param_roc:5 },  large: { condition_type:"roc_above", param_roc:8 } },
};

function _alertScenarioFromRule(rule) {
  return _ALERT_COND_TO_SCENARIO[rule.condition_type] || null;
}

function _alertSensFromRule(rule) {
  const cd = parseInt(rule.cooldown_days) || 5;
  if (cd <= 3) return "small";
  if (cd <= 5) return "medium";
  return "large";
}

// Module-level (formerly an inner component of AlertsPage) so AlertRuleRow —
// and through it the Help Center's alert-rules figure — can use it too.
// AlertsPage's builder aliases it back as `InfoTooltip`.
function AlertInfoTooltip({ text, open, onEnter, onLeave, onToggle, greenTint }) {
  const btnStyle = {
    width: greenTint ? 17 : 16, height: greenTint ? 17 : 16, borderRadius:"50%",
    border: greenTint ? "1px solid #e0c9a0" : "1px solid #cdd8cf",
    background: greenTint ? "#fdf6ea" : "#faf9f7",
    color: greenTint ? "#8a5709" : "#647071",
    fontFamily:"'IBM Plex Mono',monospace", fontSize: greenTint ? 10 : "9.5px",
    lineHeight:1, cursor:"help", padding:0,
    display:"inline-flex", alignItems:"center", justifyContent:"center",
  };
  return (
    <span style={{ position:"relative", display:"inline-flex", verticalAlign:"middle", marginLeft:6 }}>
      <button onMouseEnter={onEnter} onMouseLeave={onLeave} onClick={onToggle}
        aria-label="Technical detail" style={btnStyle}>i</button>
      {open && (
        <span style={{
          position:"absolute", bottom:"calc(100% + 8px)", left:"50%", transform:"translateX(-50%)",
          zIndex:40, whiteSpace:"nowrap",
          background:"#1a2b22", color:"#eaf3ec",
          fontFamily:"'IBM Plex Mono',monospace", fontSize:11, padding:"7px 11px",
          borderRadius:7, boxShadow:"0 8px 20px -8px rgba(0,0,0,.5)",
        }}>
          {text}
          <span style={{ position:"absolute", top:"100%", left:"50%", transform:"translateX(-50%)", border:"5px solid transparent", borderTopColor:"#1a2b22" }} />
        </span>
      )}
    </span>
  );
}

// One saved-alert row — toggle + plain-English sentence + threshold tooltip
// (+ Edit/× when interactive). Extracted from AlertsPage's rules.map so the
// Help Center's alert-rules figure can render the exact same rows fed sample
// rules (the same behavior-preserving split as ContributionsPanelBody /
// HoldingsEditPanelBody — see save-tab.md § Combined portfolio edit modal).
// `showActions:false` (the figure) hides Edit/× — a sample row has no DB rule
// behind it — while the toggle stays live via `onToggle`.
function AlertRuleRow({ rule, editing, tipOpen, onTipEnter, onTipLeave, onTipToggle, onToggle, onEdit, onDelete, showActions = true }) {
  const sc      = _alertScenarioFromRule(rule);
  const sens    = _alertSensFromRule(rule);
  const scMeta  = sc ? _ALERT_SCENARIOS[sc] : null;
  const scShort = scMeta ? scMeta.short : (rule.condition_type || "");
  const targetLc = rule.scope === "ticker" && rule.scope_value
    ? (rule.scope_value.replace(/\.TO$/i,""))
    : "a fund on your watchlist";
  const cadence = `${rule.cooldown_days || 7} days`;
  const tipText = sc ? `${sens.charAt(0).toUpperCase() + sens.slice(1)} sensitivity · ${_ALERT_THRESHOLDS[sc]?.[sens] || ""}` : "";
  const on = rule.enabled;
  return (
    <div style={{ display:"flex", alignItems:"center", gap:15, padding:"15px 2px", borderBottom:"1px solid #e6e0d4" }}>
      {/* toggle */}
      <button
        onClick={onToggle}
        title={on ? "Active — click to pause" : "Paused — click to activate"}
        style={{ flex:"none", width:40, height:23, borderRadius:99, border:"none", cursor:"pointer", position:"relative", padding:0, background: on ? "#f5a623" : "#cfc8ba", boxShadow: on ? "inset 0 0 0 1px #8a5709" : "none" }}
      >
        <span style={{ position:"absolute", top:3, [on?"right":"left"]:3, width:17, height:17, borderRadius:"50%", background:"#fff", boxShadow:"0 1px 2px rgba(0,0,0,.25)" }} />
      </button>
      {/* sentence */}
      <div style={{ flex:1, fontSize:14, lineHeight:1.55, color:"#5a6a72" }}>
        <span style={{ fontWeight:600, color:"#1a1a1a" }}>{rule.name}</span>
        {" "}&mdash; email me when{" "}
        <span style={{ color:"#8a5709", fontWeight:500 }}>{targetLc}</span>{" "}
        <span style={{ color:"#8a5709", fontWeight:500 }}>{scShort}</span>
        {", then pause "}
        <span style={{ color:"#8a5709", fontWeight:500 }}>{cadence}</span>.
        {tipText && (
          <AlertInfoTooltip
            text={tipText}
            open={tipOpen}
            onEnter={onTipEnter}
            onLeave={onTipLeave}
            onToggle={onTipToggle}
            greenTint={false}
          />
        )}
      </div>
      {showActions && (
        <>
          {/* edit */}
          <button
            onClick={onEdit}
            style={{ fontFamily:"inherit", fontSize:12, fontWeight:500, color: editing ? "#1a4a6b" : "#5a6a72", background:"none", border: editing ? "1px solid #1a4a6b" : "1px solid #d4cfc5", borderRadius:6, padding:"6px 12px", cursor:"pointer" }}
          >
            Edit
          </button>
          {/* delete */}
          <button
            onClick={onDelete}
            title="Delete this alert"
            style={{ flex:"none", width:30, height:30, display:"inline-flex", alignItems:"center", justifyContent:"center", fontSize:16, lineHeight:1, color:"#647071", background:"none", border:"1px solid transparent", borderRadius:6, cursor:"pointer" }}
          >&times;</button>
        </>
      )}
    </div>
  );
}

function AlertsPage({ user, onSwitchTab, onLogout, onNavigate }) {
  const [rules,       setRules]      = useState([]);
  const [loading,     setLoading]    = useState(true);
  const [err,         setErr]        = useState("");
  const [saving,      setSaving]     = useState(false);
  const [createErr,   setCreateErr]  = useState("");
  const [tipOpenId,   setTipOpenId]  = useState(null);
  const [bTipOpen,    setBTipOpen]   = useState(false);

  // builder state
  const [bScenario,   setBScenario]  = useState("drop");
  const [bSens,       setBSens]      = useState("medium");
  const [bTarget,     setBTarget]    = useState("watchlist");
  const [bTicker,     setBTicker]    = useState("");
  const [bShowSearch, setBShowSearch]= useState(false);
  const [bOpenMenu,   setBOpenMenu]  = useState(null); // "target" | "scenario" | null
  const [bName,       setBName]      = useState("");
  const [editingRule, setEditingRule] = useState(null); // rule object being edited, or null
  const [tickerUniverse, setTickerUniverse] = useState([]);

  useEffect(() => { fetchRules(); }, []);
  useEffect(() => {
    api("/compare/tickers").then(r => { if (r.ok && r.data && r.data.tickers) setTickerUniverse(r.data.tickers); });
  }, []);

  useEffect(() => {
    function closeMenus(e) {
      if (!e.target.closest("[data-alert-menu]")) setBOpenMenu(null);
    }
    document.addEventListener("mousedown", closeMenus);
    return () => document.removeEventListener("mousedown", closeMenus);
  }, []);

  async function fetchRules() {
    setLoading(true); setErr("");
    try {
      const r = await fetch("/api/my/alerts", { credentials: "same-origin" });
      if (!r.ok) throw new Error(`Server error ${r.status}`);
      const d = await r.json();
      if (!d.ok) throw new Error(d.error || "failed");
      setRules(d.rows);
    } catch (e) { setErr(e.message); }
    finally { setLoading(false); }
  }

  async function toggleEnabled(rule) {
    try {
      const r = await fetch(`/api/my/alerts/${rule.id}`, {
        method: "PATCH", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ enabled: !rule.enabled }),
      });
      const d = await r.json();
      if (!d.ok) throw new Error(d.error || "failed");
      setRules(prev => prev.map(x => x.id === rule.id ? { ...x, enabled: !x.enabled } : x));
    } catch (e) { setErr(e.message); }
  }

  async function deleteRule(rule) {
    if (!confirm(`Delete alert "${rule.name}"?`)) return;
    try {
      const r = await fetch(`/api/my/alerts/${rule.id}`, { method: "DELETE", credentials: "same-origin" });
      const d = await r.json();
      if (!d.ok) throw new Error(d.error || "failed");
      setRules(prev => prev.filter(x => x.id !== rule.id));
    } catch (e) { setErr(e.message); }
  }

  function resetBuilder() {
    setBName(""); setBTicker(""); setBTarget("watchlist"); setBScenario("drop"); setBSens("medium");
    setEditingRule(null); setCreateErr("");
  }

  async function saveAlert() {
    setCreateErr("");
    if (!bName.trim()) { setCreateErr("Give this alert a name before saving."); return; }
    if (bTarget === "one" && !bTicker.trim()) { setCreateErr("Enter a fund ticker for this alert."); return; }
    const sensMeta = _ALERT_SENS_META.find(s => s.key === bSens);
    const params   = _ALERT_SCENARIO_PARAMS[bScenario][bSens];
    const body = {
      name:          bName.trim(),
      ...params,
      scope:         bTarget === "one" ? "ticker" : "watchlist",
      scope_value:   bTarget === "one" ? bTicker.trim().toUpperCase() : null,
      cooldown_days: sensMeta.cooldown,
    };
    setSaving(true);
    try {
      let r;
      if (editingRule) {
        r = await fetch(`/api/my/alerts/${editingRule.id}`, {
          method: "PATCH", credentials: "same-origin",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(body),
        });
      } else {
        r = await fetch("/api/my/alerts", {
          method: "POST", credentials: "same-origin",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ ...body, enabled: true }),
        });
      }
      const d = await r.json();
      if (!d.ok) throw new Error(d.error || "failed");
      resetBuilder();
      fetchRules();
    } catch (e) { setCreateErr(e.message); }
    finally { setSaving(false); }
  }

  const scenMeta    = _ALERT_SCENARIOS[bScenario];
  const sensMeta    = _ALERT_SENS_META.find(s => s.key === bSens);
  const techText    = _ALERT_THRESHOLDS[bScenario][bSens];
  const watchTarget = bTarget === "one"
    ? (bTicker.trim() ? "in " + bTicker.trim().toUpperCase().replace(/\.TO$/i, "") : "in your chosen fund")
    : "on any fund in your watchlist";

  const pillStyle = (open) => ({
    display:"inline-flex", alignItems:"center", gap:6,
    fontFamily:"'Inter Tight',system-ui,sans-serif", fontSize:18, fontWeight:600, color:"#1a4d2e",
    background:"#eef3ef", borderRadius:8, padding:"2px 11px", cursor:"pointer",
    border: open ? "1px solid #1a4a6b" : "1px solid #cdd8cf",
    boxShadow: open ? "0 0 0 1px #1a4a6b" : "none",
    outline:"none",
  });
  const menuBox = {
    position:"absolute", top:"calc(100% + 6px)", left:0, zIndex:30,
    background:"#fff", border:"1px solid #d4cfc5", borderRadius:10,
    boxShadow:"0 18px 40px -20px rgba(40,30,15,.35)", padding:6, minWidth:240,
    display:"flex", flexDirection:"column", gap:2,
  };
  const menuRow = (selected) => ({
    display:"flex", alignItems:"center", justifyContent:"space-between", gap:12,
    padding:"9px 11px", borderRadius:6, cursor:"pointer",
    fontFamily:"inherit", fontSize:14, fontWeight:400, color:"#1a1a1a",
    width:"100%", textAlign:"left", border:"none",
    background: selected ? "#f3f8f4" : "transparent",
  });
  const menuHeader = {
    padding:"9px 11px 4px", fontFamily:"'IBM Plex Mono',monospace", fontSize:"9.5px",
    letterSpacing:".12em", textTransform:"uppercase", color:"#647071",
  };

  // Inline info tooltip — the shared module-level component (see AlertRuleRow)
  const InfoTooltip = AlertInfoTooltip;

  return (
    <div className="sp-root">
      <PageNav activeTab={null} onSwitchTab={onSwitchTab} user={user} onLogout={onLogout} onUserUpdated={null} onNavigate={onNavigate} />
      <div className="sp-page" style={{maxWidth:960}}>

          {/* header block */}
          <div style={{ marginBottom:28 }}>
            <h1 style={{ fontSize:27, fontWeight:500, letterSpacing:"-.02em", color:"#1a1a1a", margin:0 }}>
              Know when it&#8217;s worth a look.
            </h1>
            <p style={{ fontSize:15, color:"#5a6a72", maxWidth:660, marginTop:10, lineHeight:1.6 }}>
              SavingsPhase watches the funds you track overnight and emails you when a price moves far
              enough to be worth a second look &#8212; a chance to rebalance or top up at a better price,
              not a reason to trade on the day&#8217;s noise.
            </p>
            <div style={{ marginTop:13, fontFamily:"'IBM Plex Mono',monospace", fontSize:12, color:"#647071" }}>
              Checked nightly &middot; emailed to {user.email}
            </div>
          </div>

          {err && (
            <div style={{ background:"#fff5f5", border:"1px solid #fca5a5", borderRadius:4, padding:"8px 12px", fontSize:13, color:"#c0392b", marginBottom:16 }}>{err}</div>
          )}

          {/* existing alerts */}
          <div style={{ display:"flex", alignItems:"baseline", justifyContent:"space-between", marginBottom:8 }}>
            <span style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:15, fontWeight:500, fontVariant:"small-caps", color:"#8a5709" }}>Your alerts</span>
            <span style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:11, color:"#647071" }}>
              {rules.filter(r => r.enabled).length} active
            </span>
          </div>

          <div style={{ marginBottom:32 }}>
            {loading ? (
              <div style={{ padding:"22px 2px", fontSize:14, color:"#647071" }}>Loading&#8230;</div>
            ) : rules.length === 0 ? (
              <div style={{ padding:"22px 2px", fontSize:14, color:"#647071" }}>No alerts yet &#8212; build one below.</div>
            ) : rules.map(rule => (
              <AlertRuleRow key={rule.id} rule={rule}
                editing={editingRule?.id === rule.id}
                tipOpen={tipOpenId === rule.id}
                onTipEnter={() => setTipOpenId(rule.id)}
                onTipLeave={() => setTipOpenId(null)}
                onTipToggle={() => setTipOpenId(id => id === rule.id ? null : rule.id)}
                onToggle={() => toggleEnabled(rule)}
                onDelete={() => deleteRule(rule)}
                onEdit={() => {
                  const sc   = _alertScenarioFromRule(rule) || "drop";
                  const sens = _alertSensFromRule(rule)     || "medium";
                  setBScenario(sc);
                  setBSens(sens);
                  setBTarget(rule.scope === "ticker" ? "one" : "watchlist");
                  setBTicker(rule.scope_value ? rule.scope_value.replace(/\.TO$/i, "") : "");
                  setBName(rule.name || "");
                  setEditingRule(rule);
                  setCreateErr("");
                  setTimeout(() => document.getElementById("alert-builder")?.scrollIntoView({ behavior:"smooth" }), 50);
                }}
              />
            ))}
          </div>

          {/* new / edit alert builder */}
          <div style={{ marginBottom:14 }}>
            <span style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:15, fontWeight:500, fontVariant:"small-caps", color:"#8a5709" }}>{editingRule ? "Edit alert" : "New alert"}</span>
          </div>

          <div id="alert-builder" style={{ background:"#fff", border:"1px solid #d4cfc5", borderRadius:12, padding:"26px 28px" }}>
            {/* sentence builder */}
            <div style={{ fontSize:19, lineHeight:1.95, color:"#5a6a72", fontWeight:400 }}>
              Email me when{" "}
              {/* target pill */}
              <span style={{ position:"relative", display:"inline-block" }} data-alert-menu>
                <button
                  onClick={() => setBOpenMenu(m => m === "target" ? null : "target")}
                  style={pillStyle(bOpenMenu === "target")}
                >
                  {bTarget === "one" ? "one specific fund" : "a fund on my watchlist"}
                  <span style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:11, color:"#1a4a6b" }}>&#9662;</span>
                </button>
                {bOpenMenu === "target" && (
                  <div style={menuBox}>
                    {[
                      { v:"watchlist", label:"a fund on my watchlist" },
                      { v:"one",       label:"one specific fund" },
                    ].map(o => (
                      <button key={o.v} style={menuRow(bTarget === o.v)}
                        onClick={() => { setBTarget(o.v); setBOpenMenu(null); if (o.v !== "one") setBTicker(""); }}>
                        {o.label}
                        <span style={{ color:"#1a4a6b", fontFamily:"'IBM Plex Mono',monospace", fontSize:12, visibility: bTarget === o.v ? "visible" : "hidden" }}>&#10003;</span>
                      </button>
                    ))}
                  </div>
                )}
              </span>
              {" "}
              {/* scenario pill */}
              <span style={{ position:"relative", display:"inline-block" }} data-alert-menu>
                <button
                  onClick={() => setBOpenMenu(m => m === "scenario" ? null : "scenario")}
                  style={pillStyle(bOpenMenu === "scenario")}
                >
                  {scenMeta.short}
                  <span style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:11, color:"#1a4a6b" }}>&#9662;</span>
                </button>
                {bOpenMenu === "scenario" && (
                  <div style={{ ...menuBox, minWidth:288 }}>
                    <div style={menuHeader}>When a fund falls</div>
                    {["oversold","drop","trend","falling"].map(k => (
                      <button key={k} style={menuRow(bScenario === k)}
                        onClick={() => { setBScenario(k); setBOpenMenu(null); }}>
                        {_ALERT_SCENARIOS[k].short}
                        <span style={{ color:"#1a4a6b", fontFamily:"'IBM Plex Mono',monospace", fontSize:12, visibility: bScenario === k ? "visible" : "hidden" }}>&#10003;</span>
                      </button>
                    ))}
                    <div style={{ ...menuHeader, marginTop:4, borderTop:"1px solid #eee5d6" }}>When a fund runs up</div>
                    {["overbought","surge","abovetrend","rising"].map(k => (
                      <button key={k} style={menuRow(bScenario === k)}
                        onClick={() => { setBScenario(k); setBOpenMenu(null); }}>
                        {_ALERT_SCENARIOS[k].short}
                        <span style={{ color:"#1a4a6b", fontFamily:"'IBM Plex Mono',monospace", fontSize:12, visibility: bScenario === k ? "visible" : "hidden" }}>&#10003;</span>
                      </button>
                    ))}
                  </div>
                )}
              </span>.
            </div>

            {/* fund picker (specific fund only) */}
            {bTarget === "one" && (
              <div style={{ display:"flex", alignItems:"center", gap:12, marginTop:16 }}>
                <span style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:10, letterSpacing:".08em", textTransform:"uppercase", color:"#647071", flexShrink:0 }}>
                  Which fund?
                </span>
                <button
                  onClick={() => setBShowSearch(true)}
                  style={{
                    display:"inline-flex", alignItems:"center", gap:8,
                    height:40, padding:"0 13px",
                    fontFamily:"'IBM Plex Mono',monospace", fontSize:14, letterSpacing:".04em",
                    color: bTicker ? "#1a4d2e" : "#a0aab4",
                    background:"#f3f8f4", border:"1px solid #bcd3c4",
                    borderRadius:8, cursor:"pointer", minWidth:160,
                  }}
                >
                  {bTicker ? bTicker.replace(/\.TO$/i, "") : "Search funds…"}
                </button>
                {bTicker && (
                  <button onClick={() => setBTicker("")} aria-label="Clear selected fund"
                    style={{ background:"none", border:"none", cursor:"pointer", color:"#647071", fontSize:18, lineHeight:1, padding:"0 2px" }}>
                    ×
                  </button>
                )}
              </div>
            )}

            {/* sensitivity slider */}
            <div style={{ display:"flex", alignItems:"flex-start", gap:40, marginTop:24, padding:"20px 22px", background:"#faf9f7", border:"1px solid #e6e0d4", borderRadius:10, flexWrap:"wrap" }}>
              <div style={{ flex:"none" }}>
                <div style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:10, letterSpacing:".1em", textTransform:"uppercase", color:"#647071", marginBottom:18 }}>Sensitivity</div>
                <div style={{ width:300 }}>
                  {/* track */}
                  <div style={{ position:"relative", height:22 }}>
                    <div style={{ position:"absolute", top:9, left:9, right:9, height:4, borderRadius:2, background:"#e0d9cb" }} />
                    <div style={{ position:"absolute", top:9, left:9, height:4, borderRadius:2, background:"#f5a623", width: sensMeta.fill }} />
                    {_ALERT_SENS_META.map((st, i) => {
                      const on = st.key === bSens;
                      return (
                        <button key={st.key} onClick={() => setBSens(st.key)}
                          aria-label={st.label}
                          style={{
                            position:"absolute", top:0, left:st.left, transform:"translateX(-50%)",
                            width:18, height:18, borderRadius:"50%", cursor:"pointer", padding:0,
                            border: on ? "2px solid #8a5709" : "2px solid #c2cdc4",
                            background: on ? "#f5a623" : "#fff",
                            boxShadow: on ? "0 0 0 4px rgba(245,166,35,.24)" : "none",
                          }} />
                      );
                    })}
                  </div>
                  {/* labels */}
                  <div style={{ display:"flex", justifyContent:"space-between", marginTop:10 }}>
                    {_ALERT_SENS_META.map((st, i) => {
                      const on = st.key === bSens;
                      return (
                        <button key={st.key} onClick={() => setBSens(st.key)}
                          style={{
                            fontFamily:"inherit", fontSize:"12.5px", cursor:"pointer",
                            background:"none", border:"none", padding:0,
                            color: on ? "#1a4d2e" : "#647071", fontWeight: on ? 600 : 500,
                            textAlign: i === 0 ? "left" : i === 2 ? "right" : "center",
                          }}>{st.label}</button>
                      );
                    })}
                  </div>
                </div>
              </div>
              <div style={{ flex:1, minWidth:250, paddingTop:4 }}>
                <div style={{ fontSize:"14.5px", color:"#1a1a1a", fontWeight:600, lineHeight:1.5 }}>{sensMeta.title}</div>
                <div style={{ fontSize:"13.5px", color:"#5a6a72", lineHeight:1.55, marginTop:5 }}>{sensMeta.caption}</div>
              </div>
            </div>

            {/* "this watches for" summary */}
            <div style={{ marginTop:18, padding:"0 2px", fontSize:14, color:"#4a5a52", lineHeight:1.6 }}>
              This watches for <strong style={{ color:"#1a1a1a", fontWeight:600 }}>{scenMeta.title}</strong>{" "}
              {watchTarget}. {scenMeta.meaning}
              <InfoTooltip
                text={`${techText} · checked nightly`}
                open={bTipOpen}
                onEnter={() => setBTipOpen(true)}
                onLeave={() => setBTipOpen(false)}
                onToggle={() => setBTipOpen(v => !v)}
                greenTint={true}
              />
            </div>

            {/* name + save/create */}
            <div style={{ display:"flex", gap:16, alignItems:"flex-end", marginTop:20 }}>
              <label style={{ flex:1 }}>
                <span style={{ display:"block", fontFamily:"'IBM Plex Mono',monospace", fontSize:10, letterSpacing:".08em", textTransform:"uppercase", color:"#647071", marginBottom:7 }}>
                  Name this alert
                </span>
                <input
                  value={bName}
                  onChange={e => setBName(e.target.value)}
                  placeholder="e.g. Buy-the-dip watch"
                  style={{
                    width:"100%", height:42, padding:"0 14px",
                    fontFamily:"inherit", fontSize:14, color:"#1a1a1a",
                    background:"#faf9f7", border:"1px solid #cdd8cf", borderRadius:8, outline:"none",
                  }}
                  onFocus={e => { e.target.style.borderColor="#1a4a6b"; e.target.style.background="#fff"; }}
                  onBlur={e =>  { e.target.style.borderColor="#cdd8cf"; e.target.style.background="#faf9f7"; }}
                />
              </label>
              {editingRule && (
                <button
                  onClick={resetBuilder}
                  style={{
                    fontFamily:"inherit", fontSize:14, fontWeight:500, color:"#5a6a72",
                    background:"none", border:"1px solid #d4cfc5", borderRadius:8,
                    padding:"11px 22px", height:42, cursor:"pointer", whiteSpace:"nowrap",
                  }}
                >
                  Cancel
                </button>
              )}
              <button
                onClick={saveAlert}
                disabled={saving}
                style={{
                  fontFamily:"inherit", fontSize:14, fontWeight:500, color:"#fff",
                  background: saving ? "#5b7f99" : "#1a4a6b", border:"none", borderRadius:8,
                  padding:"11px 22px", height:42, cursor: saving ? "default" : "pointer",
                  whiteSpace:"nowrap",
                }}
              >
                {saving ? (editingRule ? "Saving…" : "Creating…") : (editingRule ? "Save changes" : "Create alert")}
              </button>
            </div>
            {createErr && (
              <div style={{ marginTop:10, fontSize:13, color:"#b23b3b" }}>{createErr}</div>
            )}
          </div>

      </div>
      {bShowSearch && (
        <WatchlistSearchModal
          universe={tickerUniverse}
          watchedSet={new Set()}
          onClose={() => setBShowSearch(false)}
          onPick={ticker => setBTicker(ticker)}
        />
      )}
    </div>
  );
}

// ── User menu chip (shared by IPSBuilder and PageNav) ─────────────────────────
function UserMenu({ user, onLogout, onUserUpdated, onNavigate }) {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    if (!open) return;
    function closeMenu(e) {
      if (!e.target.closest("[data-user-menu]")) setOpen(false);
    }
    function closeOnEscape(e) { if (e.key === "Escape") setOpen(false); }
    document.addEventListener("mousedown", closeMenu);
    document.addEventListener("keydown", closeOnEscape);
    return () => {
      document.removeEventListener("mousedown", closeMenu);
      document.removeEventListener("keydown", closeOnEscape);
    };
  }, [open]);

  return (
    <div className={"sp-user-menu" + (open ? " open" : "")} data-user-menu>
      <button className="sp-user-trigger" onClick={() => setOpen(v => !v)}
              aria-haspopup="menu" aria-expanded={open}>
        <span className="sp-user-avatar">{user.name.charAt(0).toUpperCase()}</span>
        <span className="sp-user-name">{user.name}</span>
      </button>
      <div className="sp-user-dropdown" role="menu">
        <button className="sp-user-dropdown-item" role="menuitem" onClick={() => { setOpen(false); onNavigate("profile"); }}>Edit profile</button>
        <button className="sp-user-dropdown-item" role="menuitem" onClick={() => { setOpen(false); onNavigate("alerts"); }}>Alerts</button>
        <button className="sp-user-dropdown-item danger" role="menuitem" onClick={() => { setOpen(false); onLogout(); }}>Log out</button>
      </div>
    </div>
  );
}

// ── Mobile nav drawer (≤768px) ────────────────────────────────────────────────
// Shared by PageNav and IPSBuilder's own nav — at ≤768px nav.css hides
// .sp-section-tabs / .sp-help-btn / .sp-user-menu and shows only this burger
// button, which opens a full-screen overlay drawer covering the same
// destinations. Portal-mounted like every other modal in this app (the
// .sp-tab-active CSS transform on the tab wrapper breaks position:fixed
// descendants otherwise). `onPlanClick` is passed in rather than derived from
// onSwitchTab because the two nav sites mean different things by "go to Plan":
// PageNav wants onSwitchTab("plan"), while the wizard's own nav wants goToHub()
// (matching each site's existing logo/Plan-tab handler).
function MobileNavDrawer({ activeTab, onPlanClick, onSwitchTab, onHelp, onNavigate, onLogout, user }) {
  const [open, setOpen] = useState(false);
  const btnRef = useRef(null);

  const close = () => { setOpen(false); if (btnRef.current) btnRef.current.focus(); };
  const go = (fn) => { fn(); setOpen(false); };

  useEffect(() => {
    if (!open) return;
    function closeOnEscape(e) { if (e.key === "Escape") close(); }
    document.addEventListener("keydown", closeOnEscape);
    return () => document.removeEventListener("keydown", closeOnEscape);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  const sections = [
    { id: "plan", label: "Plan", icon: "ti-map-2", onClick: () => go(onPlanClick) },
    { id: "portfolio", label: "Save", icon: "ti-pig-money", onClick: () => go(() => onSwitchTab("portfolio")) },
    { id: "dashboard", label: "Track", icon: "ti-chart-line", onClick: () => go(() => onSwitchTab("dashboard")) },
  ];

  return (
    <>
      <button ref={btnRef} className="sp-nav-menu-btn" onClick={() => setOpen(true)}
        aria-label="Open menu" aria-haspopup="dialog" aria-expanded={open}>
        <i className="ti ti-menu-2" aria-hidden="true" />
      </button>
      {open && ReactDOM.createPortal(
        <div className="sp-nav-drawer" role="dialog" aria-modal="true" aria-label="Site menu">
          <div className="sp-nav-drawer-head">
            <a className="sp-logo" href="#" onClick={e => { e.preventDefault(); go(onPlanClick); }}>
              {logoIcon(activeTab)}<span className="sp-logo-text">SavingsPhase</span>
            </a>
            <button className="sp-nav-drawer-close" onClick={close} aria-label="Close menu">
              <i className="ti ti-x" aria-hidden="true" />
            </button>
          </div>
          <div className="sp-nav-drawer-scroll">
            <div className="sp-nav-drawer-lbl">Sections</div>
            {sections.map(s => (
              <button key={s.id} className={"sp-nav-drawer-item" + (activeTab === s.id ? " active" : "")} onClick={s.onClick}>
                <i className={"ti " + s.icon} aria-hidden="true" />{s.label}
                {activeTab === s.id
                  ? <span className="sp-nav-drawer-tag">Current</span>
                  : <i className="ti ti-chevron-right sp-nav-drawer-arrow" aria-hidden="true" />}
              </button>
            ))}
            <button className="sp-nav-drawer-item" onClick={() => go(onHelp)}>
              <i className="ti ti-help-circle" aria-hidden="true" />Help
              <i className="ti ti-chevron-right sp-nav-drawer-arrow" aria-hidden="true" />
            </button>

            <div className="sp-nav-drawer-divider" />

            <div className="sp-nav-drawer-lbl">Account</div>
            <div className="sp-nav-drawer-user">
              <div className="sp-nav-drawer-user-card">
                <span className="sp-nav-drawer-avatar">{user.name.charAt(0).toUpperCase()}</span>
                <div>
                  <div className="sp-nav-drawer-name">{user.name}</div>
                  <div className="sp-nav-drawer-email">{user.email}</div>
                </div>
              </div>
              <div className="sp-nav-drawer-links">
                <button className="sp-nav-drawer-link" onClick={() => go(() => onNavigate("profile"))}>
                  <i className="ti ti-user" aria-hidden="true" />Edit profile
                </button>
                <button className="sp-nav-drawer-link" onClick={() => go(() => onNavigate("alerts"))}>
                  <i className="ti ti-bell" aria-hidden="true" />Alerts
                </button>
                <button className="sp-nav-drawer-link danger" onClick={() => go(onLogout)}>
                  <i className="ti ti-logout" aria-hidden="true" />Log out
                </button>
              </div>
            </div>
          </div>
        </div>,
        document.body
      )}
    </>
  );
}

// ── Email verification banner ─────────────────────────────────────────────────
function VerificationBanner({ onDismiss }) {
  const [sending, setSending] = useState(false);
  const [sent, setSent]       = useState(false);

  const resend = async () => {
    setSending(true);
    await api("/api/auth/resend-verification", { method: "POST" });
    setSending(false);
    setSent(true);
  };

  return (
    <div style={{background:"#fffbea",borderBottom:"1px solid #e8d96a",padding:"9px 20px",display:"flex",alignItems:"center",gap:12,fontSize:13,fontFamily:"'Inter Tight',system-ui,sans-serif"}}>
      <svg width="15" height="15" fill="none" stroke="#c2571a" strokeWidth="2" viewBox="0 0 24 24" style={{flexShrink:0}}>
        <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
      </svg>
      <span style={{flex:1,color:"#5a3e00"}}>
        {sent
          ? "Verification email sent — check your inbox."
          : <>Please verify your email address. Check your inbox or{" "}
              <button onClick={resend} disabled={sending} style={{background:"none",border:"none",color:"#1a4a6b",cursor:"pointer",padding:0,fontSize:13,fontWeight:500,textDecoration:"underline",fontFamily:"inherit"}}>
                {sending ? "sending…" : "resend the email"}
              </button>.
            </>}
      </span>
      <button onClick={onDismiss} title="Dismiss" aria-label="Dismiss" style={{background:"none",border:"none",cursor:"pointer",color:"#888",fontSize:18,lineHeight:1,padding:"0 4px",flexShrink:0}}>×</button>
    </div>
  );
}

// ── Unsaved draft plan banner ───────────────────────────────────────────────────
// Shown on the Save/Track tabs (never on Plan, where the draft is already
// visible) when the wizard has an in-progress goal that's never been saved —
// "Accept Plan" is the only thing that reaches the backend, so it's easy to
// wander off mid-wizard thinking progress is already saved.
function DraftPlanBanner({ onResume, onDismiss }) {
  return (
    <div style={{background:"#fffbea",borderBottom:"1px solid #e8d96a",padding:"9px 20px",display:"flex",alignItems:"center",gap:12,fontSize:13,fontFamily:"'Inter Tight',system-ui,sans-serif"}}>
      <svg width="15" height="15" fill="none" stroke="#c2571a" strokeWidth="2" viewBox="0 0 24 24" style={{flexShrink:0}}>
        <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
      </svg>
      <span style={{flex:1,color:"#5a3e00"}}>
        You have a plan in progress that hasn't been saved yet.{" "}
        <button onClick={onResume} style={{background:"none",border:"none",color:"#1a4a6b",cursor:"pointer",padding:0,fontSize:13,fontWeight:500,textDecoration:"underline",fontFamily:"inherit"}}>
          Finish and save it
        </button>.
      </span>
      <button onClick={onDismiss} title="Dismiss" aria-label="Dismiss" style={{background:"none",border:"none",cursor:"pointer",color:"#888",fontSize:18,lineHeight:1,padding:"0 4px",flexShrink:0}}>×</button>
    </div>
  );
}

// ── Stale plan review banner ────────────────────────────────────────────────────
// Shown on the Save/Track tabs (same placement rule as DraftPlanBanner) when a
// saved plan hasn't been re-saved — i.e. reviewed and re-signed — for over a
// year. The Governance step's own review schedule (monthly/quarterly/
// semi-annual) relies on the user coming back; nothing else re-surfaces a plan
// they simply stop opening. This is the coarse backstop: a flat 1 year
// regardless of the chosen schedule (tightening it to the plan's own cadence
// was considered and deferred — the banner is a nudge, not a task manager).
// Dismissal is per (goal, updated_at) in localStorage: it survives sessions,
// and re-arms only if the plan is re-saved and later goes stale *again* (a
// re-save resets the clock, so a given updated_at can only go stale once).
const STALE_PLAN_MS = 365 * 24 * 3600 * 1000;
const _staleDismissKey = (slug) => `sp_stale_dismiss_${slug || "anon"}`;
function loadStaleDismissals(slug) {
  try { return JSON.parse(localStorage.getItem(_staleDismissKey(slug))) || {}; }
  catch (e) { return {}; }
}
function saveStaleDismissal(slug, goalId, updatedAt) {
  try {
    const cur = loadStaleDismissals(slug);
    cur[goalId] = updatedAt;
    localStorage.setItem(_staleDismissKey(slug), JSON.stringify(cur));
  } catch (e) { /* no-op */ }
}
function StalePlanBanner({ plan, onReview, onDismiss }) {
  return (
    <div style={{background:"#fffbea",borderBottom:"1px solid #e8d96a",padding:"9px 20px",display:"flex",alignItems:"center",gap:12,fontSize:13,fontFamily:"'Inter Tight',system-ui,sans-serif"}}>
      <svg width="15" height="15" fill="none" stroke="#c2571a" strokeWidth="2" viewBox="0 0 24 24" style={{flexShrink:0}}>
        <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
      </svg>
      <span style={{flex:1,color:"#5a3e00"}}>
        Your {plan.label} plan hasn&#8217;t been reviewed in over a year.{" "}
        <button onClick={onReview} style={{background:"none",border:"none",color:"#1a4a6b",cursor:"pointer",padding:0,fontSize:13,fontWeight:500,textDecoration:"underline",fontFamily:"inherit"}}>
          Review it
        </button>{" "}to keep its savings, timeline, and mix current.
      </span>
      <button onClick={onDismiss} title="Dismiss" aria-label="Dismiss" style={{background:"none",border:"none",cursor:"pointer",color:"#888",fontSize:18,lineHeight:1,padding:"0 4px",flexShrink:0}}>×</button>
    </div>
  );
}

// ── Unsaved-plan-changes banner ──────────────────────────────────────────────────
// Shown on the Save/Track tabs (same placement rule as DraftPlanBanner /
// StalePlanBanner) when a *previously saved* goal's parked wizard draft
// differs from what's actually persisted — any wizard-step field, not just
// allocation (equity sync, estate target, contributions, CPP/OAS, lifestyle,
// ...): all of them can change the plan document/projection without the
// change having reached the server yet. Most commonly hit right after
// changing something in the wizard and then leaving without clicking Accept
// Plan again. The wizard's own document preview (case 6) re-derives its
// content from live `form` state regardless of save state, so it can look
// fully up to date while the Save/Track tabs — which only ever read what's
// actually persisted — still reflect the old plan. Without this banner (and
// DocStatusChip's matching fix), nothing tells the user those screens have
// quietly disagreed. See plan-tab.md § "Plan changes can't silently go
// unsaved". Dismissal is session-only (not localStorage) — this clears
// itself the moment the goal is re-saved, unlike StalePlanBanner's
// once-a-year cadence, so there's no need to remember a dismissal across
// visits.
// `reason` picks the message; the banner itself is one component on purpose
// (see App's `planBanner`) — a plan can be both form-dirty and review-due, and
// two stacked warnings about the same plan is noise.
//   "dirty"  — the parked wizard draft differs from what's persisted
//   "review" — an account change from the Save tab left the signature stale
//              (needs_review_at > updated_at; see plan.md § "The edit applies
//              immediately; the signature goes stale")
function FormDirtyBanner({ goal, reason, onReview, onDismiss }) {
  return (
    <div style={{background:"#fffbea",borderBottom:"1px solid #e8d96a",padding:"9px 20px",display:"flex",alignItems:"center",gap:12,fontSize:13,fontFamily:"'Inter Tight',system-ui,sans-serif"}}>
      <svg width="15" height="15" fill="none" stroke="#c2571a" strokeWidth="2" viewBox="0 0 24 24" style={{flexShrink:0}}>
        <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
      </svg>
      <span style={{flex:1,color:"#5a3e00"}}>
        {reason === "review"
          ? <>The accounts your {goal.label} plan covers changed — the plan document still describes the old set.{" "}</>
          : <>Your {goal.label} plan changed but hasn&#8217;t been saved — the numbers shown here still reflect your last save.{" "}</>}
        <button onClick={onReview} style={{background:"none",border:"none",color:"#1a4a6b",cursor:"pointer",padding:0,fontSize:13,fontWeight:500,textDecoration:"underline",fontFamily:"inherit"}}>
          {reason === "review" ? "Review and re-sign it" : "Review and save it"}
        </button>.
      </span>
      <button onClick={onDismiss} title="Dismiss" aria-label="Dismiss" style={{background:"none",border:"none",cursor:"pointer",color:"#888",fontSize:18,lineHeight:1,padding:"0 4px",flexShrink:0}}>×</button>
    </div>
  );
}

// ── Google OAuth UI helpers ─────────────────────────────────────────────────────
// The OAuth callback redirects back with ?google_error= / ?google_status=; these
// map those codes to friendly copy. Kept module-level (see other *_MSGS constants).
const GOOGLE_MSGS = {
  google_unconfigured:   "Google sign-in isn't available right now — please use your email and password.",
  google_denied:         "Google sign-in was cancelled.",
  google_state:          "Google sign-in expired or was interrupted. Please try again.",
  google_exchange:       "We couldn't complete Google sign-in. Please try again.",
  google_email:          "Google didn't share a verified email, so we can't sign you in that way.",
  google_needs_password: "You already have an account with this email. Sign in with your password, then connect Google under Edit profile.",
  google_link_auth:      "Please sign in first, then connect your Google account.",
  google_link_taken:     "That Google account is already connected to a different SavingsPhase account.",
  google_reauth_mismatch:"That Google account doesn't match the one you're signed in as.",
};
const GOOGLE_STATUS_MSGS = {
  linked:    "Google account connected. You can now sign in with Google.",
  signed_in: "Signed in with Google.",
};
// Error codes that happen while already signed in (link / re-auth), so they are
// surfaced by the App banner. Sign-in-mode errors happen logged out and are shown
// on the Auth screen instead.
const GOOGLE_INAPP_ERRORS = new Set([
  "google_link_taken", "google_link_auth", "google_reauth_mismatch", "google_unconfigured",
]);

function readGoogleParam(key) {
  try { return new URLSearchParams(window.location.search).get(key) || ""; }
  catch (e) { return ""; }
}
// Strip the google_* params so a refresh doesn't re-show the message. `signup` is
// stripped by the same call: it only seeds Auth's initial mode, so leaving it in the
// URL would snap the form back to "register" on every refresh after the user has
// toggled to sign-in.
function clearGoogleParams() {
  try {
    const url = new URL(window.location.href);
    url.searchParams.delete("google_error");
    url.searchParams.delete("google_status");
    url.searchParams.delete("signup");
    window.history.replaceState({}, "", url.pathname + url.search + url.hash);
  } catch (e) { /* no-op */ }
}

// "Continue with Google" — a plain same-origin link; the server owns the redirect
// dance, so no external script and no CSP change are needed (see security.md).
function GoogleButton({ label = "Continue with Google", mode }) {
  const href = "/api/auth/google/start" + (mode === "link" ? "?mode=link" : "");
  return (
    <a href={href} className="google-btn">
      <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
        <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/>
        <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.02-3.7H.96v2.34A9 9 0 0 0 9 18z"/>
        <path fill="#FBBC05" d="M3.98 10.72a5.4 5.4 0 0 1-.28-1.72c0-.6.1-1.18.28-1.72V4.94H.96a9 9 0 0 0 0 8.12l3.02-2.34z"/>
        <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.46 3.44 1.35l2.58-2.58C13.46.9 11.43 0 9 0A9 9 0 0 0 .96 4.94l3.02 2.34C4.68 5.16 6.66 3.58 9 3.58z"/>
      </svg>
      <span>{label}</span>
    </a>
  );
}

// ── Auth screen ─────────────────────────────────────────────────────────────────
function Auth({ onAuth }) {
  // `?signup=1` opens straight on "Create your account". Sign-up CTAs on the
  // public pages (asset.html's star prompt) land here, and dropping someone who
  // just clicked "Create a free account" onto a "Welcome back" login form reads
  // as the wrong page. Same read-then-clear convention as the google_* params
  // below, so a refresh doesn't force the mode back after they toggle to login.
  const [mode, setMode]           = useState(
    () => (readGoogleParam("signup") ? "register" : "login"));   // login | register
  const [name, setName]           = useState("");
  const [email, setEmail]         = useState("");
  const [password, setPassword]   = useState("");
  const [birthYear, setBirthYear] = useState("");
  const [aiConsent, setAiConsent] = useState(true);   // default on for new accounts
  const [err, setErr]             = useState("");
  const [busy, setBusy]           = useState(false);
  const [registeredEmail, setRegisteredEmail] = useState("");

  useEffect(() => {
    const code = readGoogleParam("google_error");
    if (code) setErr(GOOGLE_MSGS[code] || "Google sign-in didn't complete. Please try again.");
    // Clear on `signup` too, not just on an error — the initial mode above has
    // already been read, and a param left in the URL would override the user's
    // own toggle on any refresh.
    if (code || readGoogleParam("signup")) clearGoogleParams();
  }, []);

  const submit = async (e) => {
    e.preventDefault();
    setErr(""); setBusy(true);
    const path = mode === "register" ? "/api/auth/register" : "/api/auth/login";
    const body = mode === "register"
      ? { name, email, password, aiConsent, ...(birthYear ? { birthYear: parseInt(birthYear) } : {}) }
      : { email, password };
    const r = await api(path, { method: "POST", body });
    setBusy(false);
    if (mode === "login") {
      if (r.ok && r.data && r.data.user) { onAuth(r.data.user); return; }
      setErr((r.data && r.data.error) || "Something went wrong. Please try again.");
      return;
    }
    // register never returns a session (see security.md § Auth) — success just
    // means the email was sent, not that this account is new.
    if (r.ok && r.data && r.data.ok) { setRegisteredEmail(email); return; }
    setErr((r.data && r.data.error) || "Something went wrong. Please try again.");
  };

  const backToSignIn = () => {
    setRegisteredEmail("");
    setMode("login");
    setPassword("");
    setErr("");
  };

  if (registeredEmail) {
    return (
      <div className="auth-page">
        <nav className="sp-nav">
          <div className="sp-nav-inner">
            <a className="sp-logo" href="/">
              {logoIcon("plan")}<span className="sp-logo-text">SavingsPhase</span>
            </a>
          </div>
        </nav>
        <div className="auth-wrap">
          <div className="auth-card">
            <div className="auth-h">Check your email</div>
            <div className="auth-sub">
              If <strong>{registeredEmail}</strong> doesn't already have a SavingsPhase account,
              we've sent a link to verify it — click it to finish setting up. If it does already
              have an account, we've sent a reminder with a sign-in link instead.
            </div>
            <button className="sp-btn sp-btn-primary" type="button" onClick={backToSignIn}
              style={{ width: "100%", justifyContent: "center", height: 38 }}>
              Sign in now
            </button>
          </div>
        </div>
        <SiteFooter />
      </div>
    );
  }

  return (
    <div className="auth-page">
      <nav className="sp-nav">
        <div className="sp-nav-inner">
          <a className="sp-logo" href="/">
            {logoIcon("plan")}<span className="sp-logo-text">SavingsPhase</span>
          </a>
        </div>
      </nav>
      <div className="auth-wrap">
        <form className="auth-card" onSubmit={submit}>
          <div className="auth-h">{mode === "register" ? "Create your account" : "Welcome back"}</div>
          <div className="auth-sub">
            {mode === "register"
              ? <>An account lets you save, refine and track your progress over time. <a href="/legal#privacy" style={{color:"#1a4a6b",textDecoration:"none"}} target="_blank" rel="noopener noreferrer">Your data is always kept private</a>.</>
              : "Sign in to view and update your investment plan."}
          </div>
          {err && <div className="auth-err">{err}</div>}
          {mode === "register" && (
            <>
              <div className="sp-field">
                <label className="sp-label">Name</label>
                <input className="sp-input" type="text" value={name} autoComplete="name"
                  onChange={e => setName(e.target.value)} placeholder="e.g. Alex Chen" />
              </div>
              <div className="sp-field">
                <label className="sp-label">Year of birth <span style={{fontWeight:400,color:"#647071"}}>(for planning purposes)</span></label>
                <input className="sp-input" type="number" value={birthYear} min="1901" max={new Date().getFullYear() - 1}
                  onChange={e => setBirthYear(e.target.value)} placeholder="e.g. 1985" />
              </div>
            </>
          )}
          <div className="sp-field">
            <label className="sp-label">Email</label>
            <input className="sp-input" type="email" value={email} autoComplete="email"
              onChange={e => setEmail(e.target.value)} placeholder="you@example.com" />
          </div>
          <div className="sp-field">
            <label className="sp-label">Password</label>
            <input className="sp-input" type="password" value={password}
              autoComplete={mode === "register" ? "new-password" : "current-password"}
              onChange={e => setPassword(e.target.value)}
              placeholder={mode === "register" ? "At least 8 characters" : "Your password"} />
          </div>
          {mode === "register" && (
            <div className="sp-field">
              <AiConsentToggleRow checked={aiConsent} onChange={setAiConsent}
                premium={false} subtitle={_AI_CONSENT_BLURB} />
            </div>
          )}
          <button className="sp-btn sp-btn-primary" type="submit" disabled={busy} style={{ width: "100%", justifyContent: "center", height: 38 }}>
            {busy ? "Please wait…" : (mode === "register" ? "Create account" : "Sign in")}
          </button>
          <div className="auth-divider">or</div>
          <GoogleButton label={mode === "register" ? "Sign up with Google" : "Continue with Google"} />
          <div className="auth-switch">
            {mode === "register" ? "Already have an account? " : "New to SavingsPhase? "}
            <button type="button" onClick={() => { setErr(""); setMode(mode === "register" ? "login" : "register"); }}>
              {mode === "register" ? "Sign in" : "Create one"}
            </button>
          </div>
          {mode === "login" && (
            <div style={{textAlign:"center",marginTop:6,fontSize:12,color:"#647071"}}>
              <a href="/reset-password" style={{color:"#5a6a72",textDecoration:"none"}}>Forgot password?</a>
            </div>
          )}
        </form>
      </div>
      <SiteFooter />
    </div>
  );
}

// ── Editable Plan document — in-place edit layer ──────────────────────────────────
// A block is "stale" once the wizard-computed default it was edited from no longer
// matches what the wizard currently computes — the fresh default renders instead,
// with a "Restore your edit" affordance to reinstate the stored value.
//
// Every comparison against a stored `baseline` uses stableStringify, NEVER
// JSON.stringify. `baseline` has round-tripped through the `ips_documents.doc_edits`
// JSONB column, and JSONB does not preserve object key insertion order — it
// reorders them (verified: {id,name,cls,wt,fee} comes back as {id,wt,cls,fee,name}).
// A plain JSON.stringify comparison against a freshly-computed default is therefore
// unconditionally unequal for any object-valued block, which made the holdings block
// report itself stale on every single load: the user's saved edit was replaced by the
// engine default and demoted to a "Restore your edit" prompt, forever. Today only the
// holdings block holds objects, so this is a no-op for the text and list blocks — the
// point is that it stays correct when the next object-valued block is added.
function docBlockStale(blockId, docEdits, ctx) {
  const rec = docEdits.blocks[blockId];
  if (!rec) return false;
  return stableStringify(DOC_BLOCK_DEFAULTS[blockId](ctx)) !== stableStringify(rec.baseline);
}
function docBlockActive(blockId, docEdits, ctx) {
  return !!docEdits.blocks[blockId] && !docBlockStale(blockId, docEdits, ctx);
}
// The holdings block's default isn't a DOC_BLOCK_DEFAULTS text function — it's derived
// live from the allocation engine's output, so it gets its own staleness helpers.
function holdingsDefaultFromMetrics(metrics) {
  return (metrics.holdings || []).map(h => ({
    id: h.ticker, name: h.ticker, cls: h.asset_class, wt: +(Number(h.weight) * 100).toFixed(1),
    fee: h.mer != null ? h.mer.toFixed(2) + "%" : "—",
  }));
}
// stableStringify, not JSON.stringify — see the docBlockStale header. This is the
// block that made the bug visible: holdings rows are OBJECTS, so a key-order-sensitive
// comparison never matched a baseline that had been through JSONB.
function holdingsBlockStale(docEdits, metrics) {
  const rec = docEdits.blocks.holdings;
  if (!rec) return false;
  return stableStringify(holdingsDefaultFromMetrics(metrics)) !== stableStringify(rec.baseline);
}
function holdingsBlockActive(docEdits, metrics) {
  return !!docEdits.blocks.holdings && !holdingsBlockStale(docEdits, metrics);
}
function docPencilStyle() {
  return { position:"absolute", top:-3, right:9, width:26, height:26, display:"inline-flex",
    alignItems:"center", justifyContent:"center", border:"1px solid #ddd6c8", background:"#fff",
    borderRadius:7, cursor:"pointer", color:"#1a4a6b", fontSize:12,
    boxShadow:"0 3px 10px rgba(40,30,15,.12)" };
}
function docReplayStyle() {
  return { position:"absolute", top:-3, right:9, width:26, height:26, display:"inline-flex",
    alignItems:"center", justifyContent:"center", border:"1px solid #ddd6c8", background:"#fff",
    borderRadius:7, cursor:"pointer", color:"#647071", fontSize:13,
    boxShadow:"0 3px 10px rgba(40,30,15,.12)" };
}

// Hover-reveal wrapper that jumps back to the wizard step that's the actual
// source of a locked (non-editable) block — for values that verbatim-mirror a
// specific wizard field, where a text pencil would let the document drift
// from the real input (e.g. the CPP/OAS paragraph, the estate-target
// sentence). Own local hover state, independent of editCtl/hoveredBlockId —
// this has nothing to do with docEdits, it's pure setStep() navigation, same
// as a sub-nav click (never calls persist()). `compact` renders a small
// inline sibling icon (for a short header-row element like the climate
// badge) instead of the pencil's absolute-positioned paragraph badge.
function ReplayLink({ step, goToStep, title, compact, children }) {
  const [hovered, setHovered] = React.useState(false);
  if (compact) {
    return (
      <span style={{position:"relative",display:"inline-flex",alignItems:"center",gap:6}}
        onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}>
        {children}
        {hovered && (
          <button onClick={() => goToStep(step)} title={title}
            style={{display:"inline-flex",alignItems:"center",justifyContent:"center",
              width:20,height:20,border:"1px solid #ddd6c8",background:"#fff",borderRadius:6,
              cursor:"pointer",color:"#647071",fontSize:11}}>
            <i className="ti ti-rotate" aria-hidden="true" />
          </button>
        )}
      </span>
    );
  }
  return (
    <div onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
      style={{position:"relative", padding:"3px 44px 3px 14px", marginLeft:-14, marginRight:-44}}>
      {children}
      {hovered && (
        <button onClick={() => goToStep(step)} title={title} style={docReplayStyle()}>
          <i className="ti ti-rotate" aria-hidden="true" />
        </button>
      )}
    </div>
  );
}

function EditableText({ blockId, editCtl }) {
  const { enabled, docEdits, ctx, hoveredBlockId, setHoveredBlockId, editingBlockId, setEditingBlockId,
    buffer, setBuffer, onCommit, onRestore } = editCtl;
  const isList = DOC_LIST_BLOCKS.has(blockId);
  const stale = docBlockStale(blockId, docEdits, ctx);
  const active = docBlockActive(blockId, docEdits, ctx);
  const defaultValue = DOC_BLOCK_DEFAULTS[blockId](ctx);
  const displayValue = active ? docEdits.blocks[blockId].value : defaultValue;
  const editing = editingBlockId === blockId;
  const anyEditing = !!editingBlockId;

  if (isList && Array.isArray(defaultValue) && !defaultValue.length && !active) return null;
  if (!isList && !defaultValue && !active) return null;

  const startEdit = () => {
    const seed = isList ? (Array.isArray(displayValue) ? displayValue.join("\n") : "") : displayValue;
    setBuffer(seed);
    setEditingBlockId(blockId);
    setHoveredBlockId(null);
  };
  const commit = () => {
    const value = isList ? buffer.split("\n").map(x => x.trim()).filter(Boolean) : buffer;
    const unchanged = isList
      ? JSON.stringify(value) === JSON.stringify(displayValue || [])
      : value === displayValue;
    if (unchanged) { setEditingBlockId(null); return; }
    onCommit(blockId, value);
  };

  return (
    <div
      onMouseEnter={() => { if (enabled && !anyEditing) setHoveredBlockId(blockId); }}
      onMouseLeave={() => setHoveredBlockId(h => h === blockId ? null : h)}
      style={{ position:"relative", padding:"3px 44px 3px 14px", marginLeft:-14, marginRight:-44,
        borderLeft: `2px solid ${active ? "#1a4a6b" : "transparent"}`, transition:"border-color .15s" }}
    >
      {editing ? (
        <>
          <textarea rows={isList ? 6 : 4} value={buffer} onChange={e => setBuffer(e.target.value)}
            onBlur={commit} autoFocus className="doc-edit-ta" />
          <div style={{display:"flex",alignItems:"center",gap:12,marginTop:8}}>
            <button className="doc-edit-done" onClick={commit}>Done</button>
            <span className="doc-edit-hint">{isList ? "ONE ITEM PER LINE · " : ""}SAVES AUTOMATICALLY</span>
          </div>
        </>
      ) : (
        <>
          {isList ? (
            <div style={{display:"flex",flexDirection:"column",gap:10}}>
              {(displayValue || []).map((it, i) => (
                <div key={i} style={{display:"flex",gap:11,fontSize:14,lineHeight:1.6,color:"#2a2a2a"}}>
                  <span style={{color:"#8a5709",flex:"none"}}>•</span><span>{it}</span>
                </div>
              ))}
            </div>
          ) : (
            <p style={{fontSize:14.5,lineHeight:1.7,color:"#2a2a2a",margin:0}}>{displayValue}</p>
          )}
          {active && <span className="doc-edited-tag">edited</span>}
          {enabled && hoveredBlockId === blockId && !anyEditing && (
            <button onClick={startEdit} title="Edit" style={docPencilStyle()}>✎</button>
          )}
          {enabled && stale && (
            <button className="doc-restore-btn" onClick={() => onRestore(blockId)}>Restore your edit →</button>
          )}
        </>
      )}
    </div>
  );
}

function AddNoteZone({ section, editCtl }) {
  const { enabled, editingBlockId, hoveredAddSection, setHoveredAddSection, onAddNote } = editCtl;
  if (!enabled) return null;
  return (
    <div className="doc-add-note-zone" onMouseEnter={() => { if (!editingBlockId) setHoveredAddSection(section); }}
      onMouseLeave={() => setHoveredAddSection(s => s === section ? null : s)}
      style={{height:30,display:"flex",alignItems:"center",marginTop:6}}>
      {hoveredAddSection === section && !editingBlockId && (
        <button className="doc-add-note-btn" onClick={() => onAddNote(section)}>
          <span className="doc-add-note-plus">+</span>Add a note
        </button>
      )}
    </div>
  );
}

function NoteBlock({ note, editCtl }) {
  const { editingBlockId, setEditingBlockId, buffer, setBuffer, onCommitNote, onRemoveNote } = editCtl;
  const blockKey = "note:" + note.id;
  const editing = editingBlockId === blockKey;
  return (
    <div style={{position:"relative",borderLeft:"3px solid #1a4a6b",background:"#f7f9fb",
      borderRadius:"0 9px 9px 0",padding:"12px 14px"}}>
      <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:7}}>
        <span className="doc-edit-hint" style={{letterSpacing:".1em",textTransform:"uppercase",color:"#8a5709"}}>Added note</span>
        <button onClick={() => onRemoveNote(note.id)} title="Remove note" aria-label="Remove note"
          style={{background:"none",border:"none",color:"#9aa0a8",cursor:"pointer",fontSize:15,lineHeight:1}}>×</button>
      </div>
      {editing ? (
        <>
          <textarea rows={3} value={buffer} onChange={e => setBuffer(e.target.value)}
            onBlur={() => onCommitNote(note.id, buffer)} autoFocus
            placeholder="Add your own note or reminder…" className="doc-edit-ta" />
          <div style={{display:"flex",alignItems:"center",gap:12,marginTop:8}}>
            <button className="doc-edit-done" onClick={() => onCommitNote(note.id, buffer)}>Done</button>
            <span className="doc-edit-hint">SAVES AUTOMATICALLY</span>
          </div>
        </>
      ) : (
        <>
          <p style={{fontSize:13.5,lineHeight:1.6,color:"#2a2a2a",margin:0}}>{note.value}</p>
          <button onClick={() => { setBuffer(note.value || ""); setEditingBlockId(blockKey); }} title="Edit note"
            style={{position:"absolute",top:9,right:34,width:24,height:24,display:"inline-flex",
              alignItems:"center",justifyContent:"center",border:"1px solid #cfe0d4",background:"#fff",
              borderRadius:6,cursor:"pointer",color:"#1a4a6b",fontSize:11}}>✎</button>
        </>
      )}
    </div>
  );
}

function EditableHoldings({ editCtl, metrics }) {
  const { enabled, docEdits, hoveredBlockId, setHoveredBlockId, editingHoldings, setEditingHoldings,
    holdBuffer, setHoldBuffer, onCommitHoldings, onRestoreHoldings } = editCtl;
  const liveDefault = holdingsDefaultFromMetrics(metrics);
  const rec = docEdits.blocks.holdings;
  // Via the shared helpers, not a second inline copy of the same comparison. The
  // duplicate was how one bug produced two unrelated-looking symptoms: this copy
  // discarded the user's saved holdings edit on every load, while the copy in
  // holdingsBlockActive silently dropped it from the "N edits since you signed"
  // count. Keep exactly one implementation.
  const stale = holdingsBlockStale(docEdits, metrics);
  const active = holdingsBlockActive(docEdits, metrics);
  const rows = active ? rec.value : liveDefault;
  const anyEditing = !!editCtl.editingBlockId || editingHoldings;

  const startEdit = () => { setHoldBuffer(rows.map(r => ({ ...r, wt: String(r.wt) }))); setEditingHoldings(true); setHoveredBlockId(null); };
  const setRow = (idx, field, val) => setHoldBuffer(b => b.map((r, i) => i === idx ? { ...r, [field]: val } : r));
  const addRow = () => setHoldBuffer(b => [...b, { id: "h" + Date.now(), name: "New fund", cls: "Canadian Equity", wt: "0", fee: "0.20%" }]);
  const removeRow = (idx) => setHoldBuffer(b => b.filter((_, i) => i !== idx));
  const commit = () => {
    const value = holdBuffer.map(r => ({ ...r, wt: parseFloat(r.wt) || 0 }));
    // stableStringify: `rows` may be rec.value straight out of JSONB (keys reordered)
    // while `value` is rebuilt from holdBuffer, so a key-order-sensitive compare would
    // read "changed" on a dialog the user opened and closed without touching anything.
    if (stableStringify(value) === stableStringify(rows)) { setEditingHoldings(false); return; }
    onCommitHoldings(value);
  };

  const src = editingHoldings ? holdBuffer : rows;
  const total = src.reduce((a, r) => a + (parseFloat(r.wt) || 0), 0);
  const balanced = Math.abs(total - 100) < 0.05;

  return (
    <div
      onMouseEnter={() => { if (enabled && !anyEditing) setHoveredBlockId("holdings"); }}
      onMouseLeave={() => setHoveredBlockId(h => h === "holdings" ? null : h)}
      style={{ position:"relative", padding:"3px 44px 3px 14px", marginLeft:-14, marginRight:-44,
        borderLeft: `2px solid ${active ? "#1a4a6b" : "transparent"}`, transition:"border-color .15s" }}
    >
      {(editingHoldings || active) && (
        <div style={{display:"flex",height:12,borderRadius:4,overflow:"hidden",gap:1.5,marginBottom:16}}>
          {src.map((r, i) => {
            const w = parseFloat(r.wt) || 0;
            const p = total > 0 ? (w / total) * 100 : 0;
            return <div key={i} style={{flex:`0 0 ${p.toFixed(2)}%`, background: classColor(r.cls)}} />;
          })}
        </div>
      )}

      {!editingHoldings && !active ? (
        <div className="ips-comp">
          <Donut holdings={metrics.holdings} />
          <table className="doc-table ips-comp-table">
            <thead><tr><th>Sample holding</th><th>Asset class</th><th>Sample weight</th><th>Annual fee</th></tr></thead>
            <tbody>
              {metrics.holdings.map(h=>(
                <tr key={h.ticker}>
                  <td><strong>{h.ticker}</strong>{h.esg && <span className="hold-esg">ESG</span>}</td>
                  <td><span className="cls-dot" style={{background:classColor(h.asset_class)}}></span>{h.asset_class}</td>
                  <td>{wpct(h.weight)}</td>
                  <td>{h.mer!=null? h.mer.toFixed(2)+"%":"—"}</td>
                </tr>
              ))}
            </tbody>
          </table>
          {enabled && hoveredBlockId === "holdings" && (
            <button onClick={startEdit} title="Edit holdings" style={docPencilStyle()}>✎</button>
          )}
          {enabled && rec && (
            <button className="doc-restore-btn" onClick={onRestoreHoldings}>Restore your edit →</button>
          )}
        </div>
      ) : editingHoldings ? (
        <>
          <div className="doc-hold-head">
            <span style={{flex:1}}>Sample holding</span><span style={{width:150}}>Asset class</span>
            <span style={{width:74,textAlign:"right"}}>Weight</span><span style={{width:62,textAlign:"right"}}>Fee</span><span style={{width:34}}/>
          </div>
          {holdBuffer.map((r, idx) => (
            <div key={idx} className="doc-hold-edit-row">
              <input value={r.name} onChange={e => setRow(idx, "name", e.target.value)} className="doc-hold-input" style={{flex:1,minWidth:0}} />
              <select value={r.cls} onChange={e => setRow(idx, "cls", e.target.value)} className="doc-hold-input" style={{width:150}}>
                {DOC_HOLDING_CLASSES.map(c => <option key={c} value={c}>{c}</option>)}
              </select>
              <input value={r.wt} onChange={e => setRow(idx, "wt", e.target.value)} inputMode="decimal" className="doc-hold-input doc-hold-mono" style={{width:74,textAlign:"right"}} />
              <input value={r.fee} onChange={e => setRow(idx, "fee", e.target.value)} className="doc-hold-input doc-hold-mono" style={{width:62,textAlign:"right"}} />
              <button onClick={() => removeRow(idx)} title="Remove" aria-label="Remove holding" className="doc-hold-remove">×</button>
            </div>
          ))}
          <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginTop:13}}>
            <button className="doc-hold-add" onClick={addRow}><span style={{fontSize:15}}>+</span> Add fund</button>
            <span className="doc-hold-total" style={{color: balanced ? "#2d7a47" : "#a1450f"}}>Total {(Math.round(total*10)/10)}%</span>
          </div>
          <div style={{display:"flex",alignItems:"center",gap:12,marginTop:14}}>
            <button className="doc-edit-done" onClick={commit}>Done</button>
            <span className="doc-edit-hint">SAVES AUTOMATICALLY</span>
          </div>
        </>
      ) : (
        <>
          <div className="doc-hold-head">
            <span style={{flex:1}}>Sample holding</span><span style={{width:150}}>Asset class</span>
            <span style={{width:74,textAlign:"right"}}>Weight</span><span style={{width:62,textAlign:"right"}}>Fee</span>
          </div>
          {rows.map((r, i) => (
            <div key={i} className="doc-hold-row">
              <span style={{flex:1,fontSize:13.5,color:"#1a1a1a"}}>{r.name}</span>
              <span style={{width:150,display:"flex",alignItems:"center",gap:8,fontSize:12.5,color:"#5a6a72"}}>
                <span style={{width:9,height:9,borderRadius:2,background:classColor(r.cls)}}/>{r.cls}
              </span>
              <span style={{width:74,textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:13}}>{r.wt}%</span>
              <span style={{width:62,textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:13,color:"#647071"}}>{r.fee}</span>
            </div>
          ))}
          <span className="doc-edited-tag">edited</span>
          {enabled && hoveredBlockId === "holdings" && !anyEditing && (
            <button onClick={startEdit} title="Edit holdings" style={docPencilStyle()}>✎</button>
          )}
        </>
      )}
    </div>
  );
}

function DocStatusChip({ saved, editCount, formDirty, reviewDue, onResign }) {
  if (!saved) {
    return (
      <div className="doc-status-chip doc-status-chip-unsaved">
        <span className="doc-status-dot" style={{background: "#b34030"}} />
        <span className="doc-status-text">Not saved</span>
        <span className="doc-status-div" />
        <span className="doc-status-sub">Accept Plan below to <button className="doc-status-link" onClick={onResign}>save it</button></span>
      </div>
    );
  }
  // formDirty (any wizard-step field changed locally since the last save —
  // see plan-tab.md § "Plan changes can't silently go unsaved") takes priority
  // over the docEdits-based edit count: it means the doc/projection on screen
  // right now no longer matches what's actually persisted (and what the Save
  // tab compares against), which is a stronger claim than "you've reworded a
  // paragraph."
  // reviewDue slots in between: weaker than "the numbers on screen aren't what's
  // saved", stronger than "you reworded a paragraph". The account change itself
  // is already live — only the signature is stale (see planNeedsReview).
  const draft = editCount > 0 || formDirty || reviewDue;
  return (
    <div className="doc-status-chip">
      <span className="doc-status-dot" style={{background: draft ? "#c2571a" : "#2d7a47"}} />
      <span className="doc-status-text">{draft ? "Working draft" : "Signed & current"}</span>
      {draft && (
        <>
          <span className="doc-status-div" />
          <span className="doc-status-sub">
            {formDirty ? "This plan has unsaved changes"
              : reviewDue ? "Accounts changed — review and re-sign"
              : `${editCount} active edit${editCount > 1 ? "s" : ""}`}
          </span>
          <button className="doc-resign-btn" onClick={onResign}>Re-sign →</button>
        </>
      )}
    </div>
  );
}

// ── AI plan / savings commentary (plan-tab.md § AI insights band, save-tab.md ──
// § AI insights — the opening read, ui.md § AI commentary bands)
// Model output is untrusted text. It is rendered as plain React children,
// never via dangerouslySetInnerHTML — the only markup this ever recognises is
// a **bold** span (the prompt asks the model to wrap specific pre-formatted
// dollar figures / goal names in it), parsed here into <strong>, nothing else.
function BoldText({ text }) {
  const parts = String(text || "").split("**");
  return parts.map((p, i) => (i % 2 === 1 ? <strong key={i}>{p}</strong> : <React.Fragment key={i}>{p}</React.Fragment>));
}

// The consent toggle itself — security.md § Data and privacy's opt-in, offered
// from exactly two places (ui.md § AI commentary bands): the Profile page (below) and the sign-up form (`Auth`).
// Deliberately NOT offered inline in either commentary band — a free/lapsed
// account never even reaches this control there (PlanReviewBand /
// SavingsReviewBand are premium-gated in full), so surfacing it as a settings
// toggle rather than an in-band CTA keeps the two concerns — "may I use your
// figures this way" vs. "can you afford the feature" — visibly separate.
// Purely presentational; both call sites own their own state/API call.
function AiConsentToggleRow({ checked, disabled, onChange, premium, subtitle }) {
  return (
    <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
      <div>
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 3 }}>
          <span style={{ fontSize: 13, fontWeight: 500, color: "#1a1a1a" }}>Enable AI plan reviews</span>
          <PremiumTag premium={premium} />
        </div>
        <div style={{ fontSize: 12, color: "#5a6a72", lineHeight: 1.5, maxWidth: 420 }}>{subtitle}</div>
      </div>
      <label className="toggle">
        <input type="checkbox" checked={checked} disabled={disabled}
          onChange={e => onChange(e.target.checked)} />
        <span className="toggle-track"></span>
      </label>
    </div>
  );
}

// The blurb text both call sites show — one source, so the "what's sent"
// sentence security.md § Data and privacy requires can't drift between the two places a
// user is asked for it. Full detail (Cohere by name, what's excluded, how to
// turn it off) lives at /legal#ai-reviews, not repeated inline here.
const _AI_CONSENT_BLURB = (
  <>Select to have a Canadian-hosted AI system review and provide feedback on your savings plan
    and progress. Your name or other identifying information is never shared.{" "}
    <a href="/legal#ai-reviews" target="_blank" rel="noopener noreferrer" style={{ color: "#1a4a6b" }}>
      Details
    </a>
  </>
);

// Profile-page section — the toggle's home for an existing account. Owns the
// API round trip; PATCH-shaped like the rest of ProfilePage's own form.
function AiReviewToggleSection({ user, onUserUpdated }) {
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const checked = !!user.llm_consent;

  const onChange = async (next) => {
    setErr(""); setBusy(true);
    const r = await api("/api/llm/consent", { method: "POST", body: { enabled: next } });
    setBusy(false);
    if (r.ok && r.data && r.data.user) { onUserUpdated(r.data.user); return; }
    setErr("Couldn't save that — check your connection.");
  };

  return (
    <div style={{marginTop:24,paddingTop:20,borderTop:"1px solid #e8e4de"}}>
      <AiConsentToggleRow checked={checked} disabled={busy} onChange={onChange}
        premium={user.tier === "premium"} subtitle={_AI_CONSENT_BLURB} />
      {err && <div className="auth-err" style={{marginTop:10,fontSize:12}}>{err}</div>}
    </div>
  );
}

// Container styling shared by both commentary bands (ui.md § AI commentary
// bands). Not a CSS class: the design handoff calls out these exact
// literals as the one new visual element this feature introduces, and every
// other value it uses is an existing app class or hex literal.
const _AI_BAND_STYLE = { border: "1px solid #d4cfc5", borderRadius: 4, background: "#faf9f7",
                          padding: "16px 18px" };

function AiBandHeader({ label, dateStr, expanded, onToggle }) {
  return (
    <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
      <div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
        <i className="ti ti-sparkles" style={{ fontSize: 17, color: "#8a5709", flexShrink: 0 }} aria-hidden="true" />
        <div className="sp-section-label" style={{ margin: 0 }}>{label}</div>
      </div>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        {dateStr && <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 12, color: "#6b7778" }}>{dateStr}</span>}
        <button className="sp-btn" style={{ height: 28, padding: "0 12px", fontSize: 12 }} onClick={onToggle}>
          {expanded ? "Hide" : "Show"}
        </button>
      </div>
    </div>
  );
}

// Shared polling: after a 202, re-fetch on an interval until `tick` reports
// done (and applies its own setState) or a small try budget is spent (a
// slow/failed Cohere call just means the band stays absent — plan.md's
// "Loading is not a designed state"). Deliberately NOT a fixed "any review at
// all" check — both bands can already have an OLD (stale) review sitting in
// state when the poll starts, which would satisfy that on the very first
// tick and stop polling before the NEW one lands. PlanReviewBand and
// SavingsReviewBand both wait for a SPECIFIC basis hash instead (their
// `pollForHash`) — see each component's block comment.
function _pollUntil(tick, pollRef) {
  let tries = 0;
  if (pollRef.current) clearInterval(pollRef.current);
  pollRef.current = setInterval(async () => {
    tries += 1;
    const done = await tick();
    if (done || tries > 20) clearInterval(pollRef.current);
  }, 3000);
}

// Screen 1 — Plan tab "AI insights" (plan-tab.md § AI insights band — copy revised
// 2026-08-12 from the handoff's "Read before you sign", which read as an
// alarm rather than a calm second read). One review per goal-plan; mounted
// inside the wizard's final step, just above the signature divider. Renders
// nothing for an unsigned (never-Accepted) plan — a review can only exist
// for a goal that has been saved.
//
// Two triggering paths, mutually exclusive on `previewActive` (the same flag
// that decides whether the plan document's charts read live-preview numbers
// or the persisted ones — see `previewActive` in IPSBuilder):
//   - Not dirty: the persisted plan IS what's on screen, so the server's own
//     persisted-doc staleness check is authoritative (GET, then POST with no
//     body if stale).
//   - Dirty (wizard form no longer matches what's saved, or nothing's saved
//     for a fresher preview to compare against): the LIVE wizard form is
//     what's on screen, so the persisted plan's hash is irrelevant. POSTs the
//     form itself and never trusts a persisted-doc-based `isStale`.
// Either way, ANY known-stale review is hidden rather than shown with a
// warning — trigger moved up + hide-on-stale, 2026-08-13. Bad info read as a
// green light is worse than a blank space; the band simply isn't there until
// a fresh read lands.
function PlanReviewBand({ goal, user, saved, form, previewActive }) {
  const [state, setState] = useState({ loading: true, review: null, nextAvailable: null, llmAvailable: true });
  const [expanded, setExpanded] = useState(true);
  const triedRef = React.useRef(false);
  const pollRef = React.useRef(null);
  // Both review endpoints are @require_premium server-side (app.py) — a free
  // account can never read or generate one, so the whole band (including the
  // consent opt-in) stays hidden rather than offering something that 402s.
  // Covers a lapsed-premium user too: plan.md's Premium tier philosophy is
  // "read-only or hidden, not deleted" for a lapsed grant.
  const isPremium = user.tier === "premium";

  const fetchReview = React.useCallback(async () => {
    const r = await api(`/api/ips/${goal}/review`);
    if (r.ok && r.data) { setState({ loading: false, ...r.data }); return r.data; }
    setState(s => ({ ...s, loading: false }));
    return null;
  }, [goal]);

  // Waits for a specific regeneration (identified by the hash the trigger
  // POST returned) to land, via plain GET — never re-POSTs mid-poll, or every
  // 3s tick would recompute the budget check and could spawn another thread
  // before the first one finishes.
  const pollForHash = React.useCallback((expectHash) => {
    _pollUntil(async () => {
      const r = await api(`/api/ips/${goal}/review`);
      if (r.ok && r.data && r.data.review && r.data.review.basisVersion === expectHash) {
        setState({ loading: false, ...r.data, review: { ...r.data.review, isStale: false } });
        return true;
      }
      return false;
    }, pollRef);
  }, [goal]);

  // Persisted-plan path — nothing on screen differs from what's saved.
  useEffect(() => {
    if (!isPremium || previewActive) return;
    triedRef.current = false;
    setExpanded(true);
    setState(s => (s.review ? { ...s, review: null, loading: true } : s));
    fetchReview();
    return () => { if (pollRef.current) clearInterval(pollRef.current); };
  }, [goal, fetchReview, isPremium, previewActive]);

  useEffect(() => {
    if (!isPremium || previewActive || !saved || state.loading || !state.llmAvailable || !user.llm_consent || triedRef.current) return;
    // Revision-triggered, not cooldown-gated (contrast SavingsReviewBand
    // below): fires on every re-sign so the read stays current, not on a
    // fixed schedule. `nextAvailable` here means something different than on
    // the Save tab too — it's null while under the review budget
    // (app_settings.llm_review_max_per_window, admin-editable, default 5 per
    // 30 days) and only set once that budget is exhausted, so this same
    // "fire unless still blocked" check covers both
    // "no review yet" and "the plan changed and we still have budget" without
    // needing separate branches. Decided 2026-08-12 — see plan-tab.md § AI
    // insights band / API.md for the full cadence-split rationale.
    const stale = state.review && state.review.isStale;
    const dueForRefresh = !state.review ||
      (stale && (!state.nextAvailable || new Date(state.nextAvailable) <= new Date()));
    if (!dueForRefresh) return;
    triedRef.current = true;
    api(`/api/ips/${goal}/review`, { method: "POST" }).then(r => {
      if (r.status === 202 && r.data) pollForHash(r.data.expectHash);
      else if (r.ok && r.data) setState(s => ({ ...s, ...r.data, loading: false }));
    });
  }, [saved, state, user.llm_consent, goal, pollForHash, isPremium, previewActive]);

  // Live-draft path — the wizard form no longer matches what's saved (or
  // there's nothing saved for a fresher preview to compare against). The
  // "read before you sign" commentary has to be checked against what's
  // actually on screen, not the last-accepted figures, or an edit that fixes
  // a shortfall still shows "you have a shortfall" with nothing flagging it
  // as wrong until the user re-signs. Debounced to match the plan document's
  // own live-preview fetch (`previewProj` in IPSBuilder).
  useEffect(() => {
    if (!isPremium || !saved || !previewActive || !user.llm_consent) return;
    setExpanded(true);
    // Hide any leftover persisted-basis content immediately — it no longer
    // describes what's on screen, and must not linger for the debounce.
    setState(s => (s.review ? { ...s, review: null, loading: true } : s));
    let cancelled = false;
    const t = setTimeout(async () => {
      const r = await api(`/api/ips/${goal}/review`,
        { method: "POST", body: { form: { ...form, goal } } });
      if (cancelled || !r.ok || !r.data) return;
      if (r.data.pending) pollForHash(r.data.expectHash);
      else setState(s => ({ ...s, ...r.data, loading: false }));
    }, 400);
    return () => { cancelled = true; clearTimeout(t); if (pollRef.current) clearInterval(pollRef.current); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [goal, form, previewActive, isPremium, saved, user.llm_consent, pollForHash]);

  // Consent lives on the Profile page and the sign-up form ONLY (ui.md § AI
  // commentary bands) — no inline opt-in here. Without it (or without a
  // fresh review) the band simply doesn't render — a stale one is withheld
  // just as if none existed, never shown with a warning (see block comment
  // above).
  if (!isPremium || !saved || !state.llmAvailable || !state.review || state.review.isStale) return null;

  const { body, summary, generatedAt } = state.review;
  const dateStr = new Date(generatedAt).toLocaleDateString("en-CA");

  return (
    <div style={{ ..._AI_BAND_STYLE, marginBottom: 22 }}>
      <AiBandHeader label="AI insights" dateStr={dateStr} expanded={expanded}
        onToggle={() => setExpanded(e => !e)} />
      {expanded ? (
        <div style={{ marginTop: 14, paddingTop: 14, borderTop: "1px solid #e3ddd1", display: "flex", flexDirection: "column", gap: 12 }}>
          {body.map((p, i) => (
            <p key={i} style={{ margin: 0, fontSize: 14, lineHeight: 1.7, color: "#1a4a6b" }}><BoldText text={p} /></p>
          ))}
        </div>
      ) : (
        <div style={{ fontSize: 14, lineHeight: 1.6, color: "#5a6a72", marginTop: 10 }}>{summary}</div>
      )}
    </div>
  );
}

// Screen 2 — Save tab opening read (save-tab.md § AI insights — the opening read). One aggregate
// review across every signed goal-plan; mounted between the page heading and
// the goal-card ribbon. Renders nothing with no signed plan at all (README
// "Empty / precondition state" — never an AI panel with nothing to measure).
function SavingsReviewBand({ user, hasSignedPlan, refreshKey }) {
  const [state, setState] = useState({ loading: true, review: null, nextAvailable: null, llmAvailable: true });
  // README: "Save opening read defaults to lead-paragraph-only in production."
  const [expanded, setExpanded] = useState(false);
  const triedRef = React.useRef(false);
  const pollRef = React.useRef(null);
  // Same premium gate as PlanReviewBand — see its comment. Both /api/save/review
  // routes are @require_premium server-side, so a free (or lapsed) account
  // never sees the band, including the consent opt-in.
  const isPremium = user.tier === "premium";

  const fetchReview = React.useCallback(async () => {
    const r = await api("/api/save/review");
    if (r.ok && r.data) { setState({ loading: false, ...r.data }); return r.data; }
    setState(s => ({ ...s, loading: false }));
    return null;
  }, []);

  // Waits for a specific generation (identified by the hash the trigger POST
  // returned) to land, via plain GET — never re-POSTs mid-poll. See the
  // `_pollUntil` comment above for why "any review" isn't good enough here:
  // the stale review already in state would otherwise end the poll on tick 1.
  const pollForHash = React.useCallback((expectHash) => {
    _pollUntil(async () => {
      const r = await api("/api/save/review");
      if (r.ok && r.data && r.data.review && r.data.review.basisVersion === expectHash) {
        setState({ loading: false, ...r.data, review: { ...r.data.review, isStale: false } });
        return true;
      }
      return false;
    }, pollRef);
  }, []);

  useEffect(() => {
    if (!isPremium) return;
    triedRef.current = false;
    fetchReview();
    return () => { if (pollRef.current) clearInterval(pollRef.current); };
  }, [refreshKey, fetchReview, isPremium]);

  useEffect(() => {
    if (!isPremium || !hasSignedPlan || state.loading || !state.llmAvailable || !user.llm_consent || triedRef.current) return;
    const stale = state.review && state.review.isStale;
    const dueForRefresh = !state.review ||
      (stale && (!state.nextAvailable || new Date(state.nextAvailable) <= new Date()));
    if (!dueForRefresh) return;
    triedRef.current = true;
    api("/api/save/review", { method: "POST" }).then(r => {
      if (r.status === 202 && r.data) pollForHash(r.data.expectHash);
      else if (r.ok && r.data) setState(s => ({ ...s, ...r.data, loading: false }));
    });
  }, [hasSignedPlan, state, user.llm_consent, pollForHash, isPremium]);

  // Consent lives on the Profile page and the sign-up form ONLY — see the
  // matching comment in PlanReviewBand. A stale review is withheld exactly
  // like PlanReviewBand's — see that component's block comment for why
  // ("hide instead of warn", 2026-08-13): the old clay "Goals changed since"
  // badge is gone along with it, replaced by the band simply not being here
  // until a fresh read lands (which now happens within
  // app_settings.llm_savings_stability_days of the figures settling, not up
  // to a month later — save-tab.md § AI insights band).
  if (!isPremium || !hasSignedPlan || !state.llmAvailable || !state.review || state.review.isStale) return null;

  const { body, generatedAt } = state.review;
  const [lead, ...rest] = body;
  const dateStr = new Date(generatedAt).toLocaleDateString("en-CA");

  return (
    <div style={{ maxWidth: 760, margin: "14px 0 30px", minWidth: 0 }}>
      <div style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 12, letterSpacing: ".1em",
                    textTransform: "uppercase", color: "#6b7778", marginBottom: 10 }}>
        Review of your plans
      </div>
      <p style={{ fontSize: 16, lineHeight: 1.75, color: "#1a1a1a", margin: 0 }}><BoldText text={lead} /></p>
      {expanded && rest.length > 0 && (
        <div style={{ fontSize: 14.5, lineHeight: 1.72, color: "#1a4a6b", display: "flex",
                      flexDirection: "column", gap: 14, marginTop: 14 }}>
          {rest.map((p, i) => <p key={i} style={{ margin: 0 }}><BoldText text={p} /></p>)}
        </div>
      )}
      <div style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap", marginTop: 12 }}>
        <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 12, color: "#6b7778",
                       display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
          <i className="ti ti-sparkles" style={{ fontSize: 14, color: "#8a5709", flexShrink: 0 }} aria-hidden="true" />
          AI-generated · {dateStr}
        </span>
        {rest.length > 0 && (
          <button className="sp-btn" style={{ height: 28, padding: "0 12px", fontSize: 12 }}
            onClick={() => setExpanded(e => !e)}>{expanded ? "Less" : "Read the rest"}</button>
        )}
      </div>
    </div>
  );
}

// ── CPP / OAS benefit input (year dropdown + amount slider) ───────────────────
// Retirement wizard only. Reads the DB-backed reference table (monthly figures
// by start age). The dropdown's derived age is what reaches the backend as the
// start age; the slider floor is a literal $0 (partial/no Canadian contribution
// history), the ceiling is the legislated max for the selected year, and the
// green med marker is the national-average reference for that year.
function GovBenefitInput({ kind, label, birthYear, minAge, maxAge, refMap,
                           year, annual, onPatch, yearKey, annualKey }) {
  const medMonthlyOf = (age) => {
    const row = refMap[age];
    if (!row) return 0;
    return kind === "cpp" ? Number(row.cpp_avg_monthly || 0) : Number(row.oas_max_monthly || 0);
  };
  const maxMonthlyOf = (age) => {
    const row = refMap[age];
    if (!row) return 0;
    return kind === "cpp" ? Number(row.cpp_max_monthly || 0) : Number(row.oas_max_monthly || 0);
  };

  const years = [];
  for (let a = minAge; a <= maxAge; a++) years.push({ age: a, year: birthYear + a });

  // Age is never blank — CPP and OAS both always have a start age (default 65).
  // The amount can still be dragged to $0 when a benefit doesn't apply.
  const effectiveYear = year || String(birthYear + 65);
  const selectedAge = parseInt(effectiveYear) - birthYear;
  const medAnnual = Math.round(medMonthlyOf(selectedAge) * 12);
  const maxAnnual = Math.round(maxMonthlyOf(selectedAge) * 12);
  const amt = (annual === "" || annual == null) ? medAnnual : Number(annual);
  const medPct = maxAnnual > 0 ? Math.max(0, Math.min(100, (medAnnual / maxAnnual) * 100)) : 0;

  const pickYear = (y) => {
    const age = parseInt(y) - birthYear;
    // Changing the year snaps the amount back to that year's med marker — a
    // figure picked for a different age has no relationship to this year's track.
    onPatch({ [yearKey]: String(y), [annualKey]: String(Math.round(medMonthlyOf(age) * 12)) });
  };

  return (
    <div className="gov-block">
      <div className="gov-row" style={{marginBottom: maxAnnual > 0 ? 14 : 0}}>
        <div>
          <div className="gov-name">{label}</div>
          <div className="gov-sub">starts at age {selectedAge}</div>
        </div>
        <select className="gov-year-sel" value={effectiveYear} onChange={e => pickYear(e.target.value)}>
          {years.map(y => (
            <option key={y.year} value={y.year}>{y.year} · age {y.age}</option>
          ))}
        </select>
      </div>
      {maxAnnual > 0 && (
        <>
          <div className="gov-row" style={{marginBottom:6}}>
            <span className="gov-sub">Estimated annual amount</span>
            <span className="gov-amount">{fmt$(amt)}<span className="gov-sub"> / yr</span></span>
          </div>
          <div className="gov-slider-wrap">
            <div className="gov-med-tick" style={{left:`calc(9px + (100% - 18px) * ${medPct/100})`}}
              title={`National average ${fmt$(medAnnual)}/yr`}/>
            <input type="range" className="sp-range" min={0} max={maxAnnual} step={120}
              value={Math.min(amt, maxAnnual)}
              onChange={e => onPatch({ [annualKey]: String(+e.target.value) })} />
            <div className="gov-slider-labels">
              <span>$0</span>
              <span>max {fmt$(maxAnnual)}</span>
            </div>
          </div>
          <div className="gov-hint">
            {kind === "cpp"
              ? <>The green marker is the national average. Enter your own estimate (drag to <strong>$0</strong> if this benefit doesn't apply to you).</>
              : <>The green marker is the eligible amount if you lived in Canada since 18 (drag to <strong>$0</strong> if this benefit doesn't apply to you).</>}
          </div>
        </>
      )}
    </div>
  );
}

// ── Employer DB pension / annuity input (amount + start age) ──────────────────
// Retirement wizard only, rendered directly beneath the CPP/OAS pair. This is
// deliberately NOT a GovBenefitInput clone: a workplace pension has no
// legislated maximum, so there is no reference table to cap a slider against
// and no national-average med marker to plot. It is a free dollar figure off
// the user's pension statement plus a start age — closer to the estate-target
// input than to CPP. SavingsPhase never estimates the amount, exactly as it
// never estimates a CPP/OAS entitlement.
//
// Keep PENSION_AGE_MIN/MAX in sync with app._PENSION_AGE_MIN/_MAX, which
// re-clamps whatever arrives.
const PENSION_AGE_MIN = 50, PENSION_AGE_MAX = 75;
function PensionInput({ birthYear, annual, startAge, onPatch }) {
  const age  = parseInt(startAge) || 65;
  const amt  = Math.max(0, parseFloat(annual) || 0);
  const ages = [];
  for (let a = PENSION_AGE_MIN; a <= PENSION_AGE_MAX; a++) ages.push(a);
  return (
    <div className="gov-block">
      <div className="gov-row" style={{marginBottom:14}}>
        <div>
          <div className="gov-name">Workplace pension or annuity</div>
          <div className="gov-sub">{amt > 0 ? `starts at age ${age}` : "none"}</div>
        </div>
        <select className="gov-year-sel" value={String(age)}
          onChange={e => onPatch({ pensionStartAge: e.target.value })}>
          {ages.map(a => (
            <option key={a} value={String(a)}>
              {birthYear ? `${birthYear + a} · age ${a}` : `age ${a}`}
            </option>
          ))}
        </select>
      </div>
      <div className="gov-row" style={{marginBottom:0}}>
        <span className="gov-sub">Annual amount</span>
        <div className="sp-input-wrap" style={{width:150}}>
          <span className="pfx-sym">$</span>
          <input className="sp-input pfx" type="number" min="0" placeholder="0" step={1000}
            value={annual}
            onChange={e => onPatch({ pensionAnnual: e.target.value })}
            onBlur={e => { const v = parseFloat(e.target.value);
                           if (!(v >= 0)) onPatch({ pensionAnnual: "0" }); }} />
        </div>
      </div>
      <div className="gov-hint">
        Enter the yearly amount from your pension statement, in today's dollars. Leave it at{" "}
        <strong>$0</strong> if this doesn't apply to you.
      </div>
    </div>
  );
}

// ── Plan landing hub (enriched goal cards + goals timeline) ───────────────────
// Tabler icon per goal type. "other" resolves to the preset's own icon (the one
// shown in the goal-setup step) or a generic target for a fully custom name.
const GOAL_TI_ICON = { retirement: "ti-beach", home: "ti-home", education: "ti-school", other: "ti-target" };
const OTHER_PRESET_ICON = Object.fromEntries(OTHER_GOAL_PRESETS.map(p => [p.id, p.icon]));
function goalTiIcon(goalId, form) {
  if (goalId !== "other") return GOAL_TI_ICON[goalId] || "ti-target";
  const preset = form && form.goalPreset;
  return preset && OTHER_PRESET_ICON[preset] ? "ti-" + OTHER_PRESET_ICON[preset] : "ti-target";
}
function planGoalName(goalId, form) {
  if (goalId === "other") {
    const custom = form && (String(form.goalName || "").trim() || form.goalPreset);
    if (custom) return custom;
  }
  return GOAL_TYPES.find(g => g.id === goalId)?.label || "Goal";
}
// Deep-equality stringify with recursively sorted object keys — plain
// JSON.stringify is order-sensitive, and `ips_documents.form` round-trips
// through a Postgres JSONB column, which does not preserve key insertion
// order. Comparing un-normalized JSON.stringify output against a form that
// was built via object spreads in JS would report "changed" constantly, even
// with byte-identical content, purely from key reordering.
function stableStringify(v) {
  if (v === null || typeof v !== "object") return JSON.stringify(v);
  if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
  return "{" + Object.keys(v).sort().map(k => JSON.stringify(k) + ":" + stableStringify(v[k])).join(",") + "}";
}
// True when a wizard form (the live `form` state, or a parked localStorage
// draft's form) differs from what's actually persisted for that goal — i.e.
// this goal has unsaved changes. Compares against `hydrateForm(sv.form, ...)`,
// not raw `sv.form`, so hydration-time normalization (default-filling newer
// fields, the equityTouched coercion) never itself counts as a change. This
// deliberately covers the *whole* form, not just allocation-affecting fields:
// any wizard-step edit (estate target, contributions, CPP/OAS, lifestyle,
// accounts, ...) can change what the plan document/projection show without
// yet reaching the server, and all of them need the same "unsaved" signal.
// See plan-tab.md § "Plan changes can't silently go unsaved".
function formDirtyCheck(liveForm, sv, userName) {
  if (!sv || !sv.form) return false;
  return stableStringify(liveForm) !== stableStringify(hydrateForm(sv.form, userName));
}
// True when a Save-side edit changed the plan itself since it was last signed
// — today only the Manage modal's Accounts panel, which stamps
// ips_documents.needs_review_at without bumping version/updated_at (bumping
// them would silently re-sign the plan). persist() moves updated_at past the
// stamp on the next Accept Plan, so the state clears itself with no separate
// reset path — the same trick editCount plays against saved.at.
//
// Deliberately NOT folded into formDirtyCheck: that compares the wizard form
// against sv.form, and the PATCH updates sv.form too, so it correctly reports
// "not dirty". This is a different question — document-vs-accounts — and
// overloading formDirtyCheck to answer it would reintroduce the false
// positives plan-tab.md § "Plan changes can't silently go unsaved" avoids.
function planNeedsReview(sv) {
  if (!sv || !sv.form || !sv.needs_review_at || !sv.updated_at) return false;
  return Date.parse(sv.needs_review_at) > Date.parse(sv.updated_at);
}
// One-line eyebrow under the goal name — what the plan NEEDS vs what it's PROJECTED
// to reach, e.g. "$500k down payment planned · $21k projected".
//
// Retirement states BOTH sides as an annual income ("$50k/yr planned · $74k/yr
// projected") so they are directly comparable: the projected side is
// `sustainable_income` (the engine's `smile_peak` — the same "Can sustain" figure the
// Save tab's Income Surplus box shows), not the portfolio's terminal value. Every
// other goal is a lump sum, so it compares dollar-to-dollar.
//
// Amounts use the same compact format as the Save tab's goal cards. Falls back to the
// target year when there is no projection yet.
function planGoalMeta(goalId, form, projView) {
  const amt = parseFloat(form.targetIncome);
  const parts = [];
  if (amt > 0) {
    if (goalId === "retirement")   parts.push(fmtCompact(amt) + "/yr planned");
    else if (goalId === "home")    parts.push(fmtCompact(amt) + " down payment planned");
    else                           parts.push(fmtCompact(amt) + " planned");
  }
  const sustain = goalId === "retirement" && projView ? projView.sustainable_income : null;
  if (sustain != null) {
    parts.push(fmtCompact(sustain) + "/yr projected");
  } else if (projView && projView.projected_value != null) {
    parts.push(fmtCompact(projView.projected_value) + " projected");
  } else {
    const year = (projView && projView.target_year) || parseInt(form.retireYear) || null;
    if (year) parts.push("target " + year);
  }
  return parts.join(" · ") || "Plan saved";
}
// Short asset-class labels for the plan allocation legend (matches the design).
const PLAN_AC_LABEL = {
  "Canadian Equity": "Canada", "Developed Markets": "Developed", "Emerging Markets": "Emerging",
  "Fixed Income": "Fixed income", "Cash": "Cash",
};
const PLAN_AC_ORDER = ["Canadian Equity", "Developed Markets", "Emerging Markets", "Fixed Income", "Cash"];
// Aggregate the saved allocation's holdings into stacked-bar segments by asset class.
function planAllocSegs(allocation) {
  const holdings = allocation && allocation.holdings;
  if (!holdings || !holdings.length) return [];
  const byClass = new Map();
  holdings.forEach(h => byClass.set(h.asset_class, (byClass.get(h.asset_class) || 0) + Number(h.weight || 0)));
  const order = cls => { const i = PLAN_AC_ORDER.indexOf(cls); return i === -1 ? 99 : i; };
  return [...byClass.entries()]
    .sort((a, b) => order(a[0]) - order(b[0]))
    .map(([cls, w]) => ({ label: PLAN_AC_LABEL[cls] || cls, weight: w, color: classColor(cls) }));
}
// Shared goal-signal vocabulary — the single source of truth for BOTH the Plan hub
// cards (PlanGoalCard) and the Save tab goal cards (PortfolioTab). Both now render the
// same backend-computed `signal` from `projection_view`. Both use the same green/amber/red
// thresholds (≥target / ≥0.8×target / below), so they must read the same words.
// Wording per style.md § Signal colours. Do not inline these strings at a call site.
const SIGNAL_LABEL = { green: "On track", amber: "Slightly behind", red: "Off track" };
const SIGNAL_TEXT  = { green: "#2d7a47", amber: "#a1450f", red: "#b34030" }; // 4.5:1 text
const SIGNAL_DOT   = { green: "#2d7a47", amber: "#c2571a", red: "#b34030" }; // 3:1 graphical

// The signal is NOT computed here. It arrives on `projection_view.signal`, from the
// backend's single `_goal_projection` helper (median terminal value in today's dollars
// vs the goal's target — the FI number for retirement, the dollar target otherwise).
// Do not reintroduce a client-side `planSignal`: two implementations is exactly how
// the Plan and Save tabs came to disagree.
const PLAN_COLLISION_GAP = 1.5; // years apart below which timeline labels alternate

// Mini projection chart: p25–p75 band (the middle 50% of simulations) + p50 median
// line + end dot, in today's dollars. `mc` is the shared backend `projection_view`.
// Coloured by the goal's signal so it agrees with the Save tab's goal card — an
// off-track goal must not draw a reassuring green line.
function PlanMiniChart({ mc, color }) {
  if (!mc || !mc.p50 || mc.p50.length < 2) return null;
  const stroke = color || "#f5a623";
  const x0 = 4, x1 = 396, yT = 6, yB = 44;
  const n = mc.p50.length;
  let lo = Math.min(...mc.p25), hi = Math.max(...mc.p75);
  const pad = (hi - lo) * 0.1 || 1; lo -= pad; hi += pad;
  const X = i => x0 + (x1 - x0) * (i / (n - 1));
  const Y = v => yB - (yB - yT) * ((v - lo) / (hi - lo));
  let top = "", bot = "";
  mc.p75.forEach((v, i) => { top += (i ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1) + " "; });
  for (let i = n - 1; i >= 0; i--) bot += "L" + X(i).toFixed(1) + " " + Y(mc.p25[i]).toFixed(1) + " ";
  const med = mc.p50.map((v, i) => (i ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1)).join(" ");
  const last = mc.p50[n - 1];
  return (
    <svg className="pl-chart" viewBox="0 0 400 50" width="100%" height="42" preserveAspectRatio="none">
      <path d={top + bot + "Z"} fill={stroke} fillOpacity="0.12" stroke="none" />
      <path d={med} fill="none" stroke={stroke} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
      <circle cx={X(n - 1)} cy={Y(last)} r="2.6" fill={stroke} />
    </svg>
  );
}

// Thin stacked allocation bar + colored-dot legend.
function PlanAllocBar({ segs }) {
  if (!segs || !segs.length) return null;
  return (
    <>
      <div className="pl-alloc-bar">
        {segs.map((s, i) => (
          <div key={i} className="pl-alloc-seg" style={{ width: (s.weight * 100).toFixed(2) + "%", background: s.color }} />
        ))}
      </div>
      <div className="pl-alloc-legend">
        {segs.map((s, i) => (
          <span key={i}><i style={{ background: s.color }} />{s.label} {wpct(s.weight)}</span>
        ))}
      </div>
    </>
  );
}

// "Goals over time" strip — one Now→target-year timeline for all saved goals.
// Segment flex-grow is proportional to the year-gap (chronologically accurate);
// nodes within PLAN_COLLISION_GAP of the previous flip above/below to stay legible.
function GoalsTimeline({ nodes, onJump }) {
  const nowYear = new Date().getFullYear();
  const ordered = [...nodes].sort((a, b) => a.year - b.year);
  const items = [{ now: true, year: nowYear }, ...ordered];
  let placement = "above";
  return (
    <div className="gt-row">
      {items.map((node, i) => {
        const segs = [];
        if (i > 0) {
          const gap = node.year - items[i - 1].year;
          placement = gap <= PLAN_COLLISION_GAP ? (placement === "above" ? "below" : "above") : "above";
          segs.push(<div key={"seg" + i} className="gt-seg" style={{ flexGrow: Math.max(gap, 0.6) }} />);
        }
        if (node.now) {
          return (
            <React.Fragment key="now">
              {segs}
              <div className="gt-node now">
                <div className="gt-node-label">Now</div>
                <div className="gt-node-dot now" />
                <div className="gt-node-year">{node.year}</div>
              </div>
            </React.Fragment>
          );
        }
        return (
          <React.Fragment key={node.goalId}>
            {segs}
            <button type="button"
              className={"gt-node gt-node-btn" + (placement === "below" ? " below" : "")}
              aria-label={"Jump to " + node.name + " plan"}
              onClick={() => onJump(node.goalId)}>
              <div className="gt-node-label"><i className={"ti " + node.icon} aria-hidden="true" /><span className="gt-node-name">{node.name}</span></div>
              <div className="gt-node-dot" style={{ background: node.dot }} />
              <div className="gt-node-year">{node.year}</div>
            </button>
          </React.Fragment>
        );
      })}
    </div>
  );
}

// One enriched goal card (configured plan) or a setup-prompt card (no plan yet).
function PlanGoalCard({ goalId, sv, d, highlighted, isDraft, onEnter, onClear }) {
  const desc = GOAL_TYPES.find(g => g.id === goalId)?.desc || "";
  if (!sv || !sv.form) {
    return (
      <div className="goal-card pl-card pl-card-empty" onClick={() => onEnter(goalId)}>
        <div className="pl-card-hd">
          <div className="pl-card-id">
            <i className={"ti " + (GOAL_TI_ICON[goalId] || "ti-target")} aria-hidden="true" />
            <div>
              <div className="goal-name">{GOAL_TYPES.find(g => g.id === goalId)?.label || "Goal"}</div>
              <div className="pl-card-meta">{desc}</div>
            </div>
          </div>
        </div>
        {isDraft ? (
          <>
            <div className="pl-empty-body">Draft in progress — click to continue.</div>
            <div className="goal-card-footer">
              <span />
              <button className="goal-card-clear" onClick={e => { e.stopPropagation(); onClear(goalId); }}>Discard draft</button>
            </div>
          </>
        ) : (
          <div className="pl-empty-body">No plan yet — click to build one.</div>
        )}
      </div>
    );
  }
  return (
    <div id={"pl-goal-" + goalId}
      className={"goal-card pl-card" + (highlighted ? " pl-card-highlight" : "")}
      onClick={() => onEnter(goalId)}>
      <div className="pl-card-hd">
        <div className="pl-card-id">
          <i className={"ti " + d.icon} aria-hidden="true" />
          <div>
            <div className="goal-name">{d.name}</div>
            <div className="pl-card-meta">{d.meta}</div>
          </div>
        </div>
        {d.signal && (
          <span className={"pf-signal pf-signal-" + d.signal} style={{ color: SIGNAL_TEXT[d.signal] }}>
            <SignalDot signal={d.signal} />{SIGNAL_LABEL[d.signal]}
          </span>
        )}
      </div>

      {d.mc && (
        <>
          <div className="pl-sec-lbl">
            <span>Projection</span>
            <span>{d.projValue != null ? "~" + fmtCompact(d.projValue) + " by " + d.year : "by " + d.year}</span>
          </div>
          <PlanMiniChart mc={d.mc} color={SIGNAL_DOT[d.signal]} />
        </>
      )}

      {d.allocSegs.length > 0 && (
        <>
          <div className="pl-sec-lbl"><span>Sample allocation</span><span /></div>
          <PlanAllocBar segs={d.allocSegs} />
        </>
      )}

      <div className="goal-card-footer">
        <span />
        <button className="goal-card-clear" onClick={e => { e.stopPropagation(); onClear(goalId); }}>Clear</button>
      </div>
    </div>
  );
}

function PlanHub({ savedByGoal, user, draftGoalId, onEnterGoal, onClear }) {
  const [highlightId, setHighlightId] = useState(null);
  const goalIds = GOAL_TYPES.map(g => g.id);

  // Signature of everything that affects a card's chart / signal / allocation /
  // meta, so the (random) Monte-Carlo runs are memoised and stay stable across
  // unrelated re-renders (e.g. a timeline highlight).
  const sig = goalIds.map(id => {
    const sv = savedByGoal[id];
    if (!sv || !sv.form) return id + ":none";
    const f = sv.form, a = sv.allocation, pv = sv.projection_view;
    const allocSig = a && a.holdings ? a.holdings.map(h => h.asset_class + Math.round(Number(h.weight || 0) * 1000)).join(",") : "";
    return [id, f.retireYear, f.goalName, f.goalPreset, f.targetIncome,
            pv && pv.projected_value, pv && pv.target, pv && pv.signal,
            pv && pv.sustainable_income, allocSig].join("|");
  }).join("~");

  const derived = useMemo(() => {
    const out = {};
    for (const id of goalIds) {
      const sv = savedByGoal[id];
      if (!sv || !sv.form) continue;
      const f = sv.form, a = sv.allocation;
      // The projection is computed once on the backend (today's dollars) and rendered
      // identically here, in the plan document, and on the Save tab's goal card.
      // Never recompute it client-side — that is how Plan and Save drifted apart.
      const pv = sv.projection_view;
      out[id] = {
        icon: goalTiIcon(id, f),
        name: planGoalName(id, f),
        meta: planGoalMeta(id, f, pv),
        year: (pv && pv.target_year) || parseInt(f.retireYear) || null,
        mc: pv && pv.ok ? pv : null,
        projValue: pv && pv.ok ? pv.projected_value : null,
        signal: pv ? pv.signal : null,
        allocSegs: planAllocSegs(a),
      };
    }
    return out;
  }, [sig]); // eslint-disable-line react-hooks/exhaustive-deps

  const savedIds = goalIds.filter(id => savedByGoal[id] && savedByGoal[id].form);
  const timelineNodes = savedIds
    .filter(id => derived[id] && derived[id].year)
    .map(id => ({
      goalId: id, name: derived[id].name, icon: derived[id].icon, year: derived[id].year,
      dot: derived[id].signal ? SIGNAL_DOT[derived[id].signal] : "#a0aab4",
    }));

  const jumpToGoal = (goalId) => {
    const card = document.getElementById("pl-goal-" + goalId);
    if (card) {
      const y = card.getBoundingClientRect().top + window.pageYOffset - 92;
      window.scrollTo({ top: y, behavior: "smooth" });
    }
    setHighlightId(goalId);
    setTimeout(() => setHighlightId(cur => (cur === goalId ? null : cur)), 1200);
  };

  return (
    <div>
      <h1 className="sp-h1">Your Investment Plans</h1>
      <p className="sp-lead">Each goal has its own plan — a different time horizon often calls for a different allocation and risk tolerance.</p>

      {timelineNodes.length > 0 && (
        <>
          <div className="sp-section-label" style={{ marginBottom: 2 }}>Goals over time</div>
          <p className="gt-cap">Where each plan lands between now and its target year.</p>
          <div className="db-card" style={{ padding: "22px 26px 24px" }}>
            <GoalsTimeline nodes={timelineNodes} onJump={jumpToGoal} />
          </div>
          <div className="sp-section-label" style={{ marginTop: 28 }}>Your plans</div>
        </>
      )}

      <div className="pl-grid">
        {goalIds.map(id => (
          <PlanGoalCard key={id} goalId={id} sv={savedByGoal[id]} d={derived[id]}
            highlighted={highlightId === id} isDraft={draftGoalId === id} onEnter={onEnterGoal} onClear={onClear} />
        ))}
      </div>
    </div>
  );
}

// ── Wizard draft persistence (localStorage) ─────────────────────────────────
// Steps 1-5 (and step 6 before the first "Accept Plan") live purely in React
// state — nothing reaches the backend until persist() succeeds. Mirroring the
// in-progress {activeGoal, step, form, docEdits, atHub} here means a refresh
// resumes exactly what was on screen instead of silently discarding it.
// Plain navigation (logo, Plan tab, "← All plans"/"← Back") never discards the
// draft — there's no visual cue those are destructive. Discarding is always an
// explicit act: the hub's "Discard draft" button, or a real save superseding it.
const WIZARD_DRAFT_KEY_PREFIX = "sp_wizard_draft_";
// Bumped when the meaning of a stored `step` changes. v2 = the Details→
// Goals+Accounts split: a v1 draft's step numbers refer to the old 5-step
// wizard, so anything at or past Climate has to shift up one or a refresh
// would drop the user on the wrong step (v1 step 2 "Climate" would reopen as
// the new step 2, Accounts). Only `step` is remapped — `form` is unchanged by
// the split, since both new steps write the same flat field set as before.
const WIZARD_DRAFT_VERSION = 2;
const WIZARD_STEP_MIGRATION = { 1: 1, 2: 3, 3: 4, 4: 5, 5: 6 }; // v1 step → v2 step
function migrateWizardDraft(d) {
  if (!d || d.v === WIZARD_DRAFT_VERSION) return d;
  return { ...d, step: WIZARD_STEP_MIGRATION[d.step] || d.step, v: WIZARD_DRAFT_VERSION };
}
function loadWizardDraft(userSlug) {
  try {
    const raw = localStorage.getItem(WIZARD_DRAFT_KEY_PREFIX + userSlug);
    return raw ? migrateWizardDraft(JSON.parse(raw)) : null;
  } catch (e) { return null; }
}
function saveWizardDraft(userSlug, draft) {
  try { localStorage.setItem(WIZARD_DRAFT_KEY_PREFIX + userSlug, JSON.stringify({ ...draft, v: WIZARD_DRAFT_VERSION })); }
  catch (e) { /* storage unavailable/full — draft just won't survive a refresh */ }
}
function clearWizardDraft(userSlug) {
  try { localStorage.removeItem(WIZARD_DRAFT_KEY_PREFIX + userSlug); } catch (e) { /* no-op */ }
}
// Flip the stored draft's `atHub` flag without touching its content — called on
// plain navigation back to the hub so a refresh from the hub shows the hub
// (with a "draft in progress" card) instead of jumping back into the wizard.
function markDraftAtHub(userSlug) {
  const d = loadWizardDraft(userSlug);
  if (d) saveWizardDraft(userSlug, { ...d, atHub: true });
}

// ── Wizard step rail (desktop icon rail + mobile collapsed row) ───────────────
// The app's only persistent step bar — see the .wz-* header comment in nav.css
// for why nothing else gets one. Both presentations are derived from one
// stepMeta array (done/active/upcoming per step) so the two breakpoints can
// never disagree on step state; only the presentation differs, and the mobile
// row + expander are CSS-toggled at ≤768px in nav.css.
//
// Desktop is a rail of icon discs joined by connector tracks that fill green as
// you advance. The fill is the one piece of motion here and it is load-bearing:
// a track animates from scaleX(0) to scaleX(1) when the step before it
// completes, which is what makes the bar read as progress rather than as six
// independent states. Do not swap it for a plain colour change.
// The final node is the only one whose "done" state does not mean "you can get
// here" — it means the plan is SIGNED, which is what `planStatus` (from
// IPSBuilder) reports. Green disc + tick only when signed and current; green
// disc + amber ! when signed but stale; neutral and unbadged when never signed.
// Both halves were real contradictions before: the rail claimed the plan was
// done while the chip on that very page said "Accounts changed — review and
// re-sign", and it ticked plans that had never been saved at all.
function WizardSubNav({ step, canReachStep, setStep, goalLabel, planStatus }) {
  const [open, setOpen] = useState(false);
  // Backwards is always free; forwards needs every input step before the target
  // to be satisfied (canReachStep). Never gated on `saved`. See the "Wizard step
  // sub-nav navigation" note on why isDone/canClick must stay one value.
  const stepMeta = STEPS.map((label, i) => {
    const target = i + 1;
    const isDone = target < step || canReachStep(target);
    return { ...STEP_RAIL[i], target, isDone, isActive: target === step, canClick: isDone };
  });
  const current = stepMeta[step - 1];
  const jump = (target, canClick) => { if (canClick) { setStep(target); setOpen(false); } };
  // The meta row counts the five INPUT steps, matching each step's in-page
  // eyebrow — the sixth node is the plan, the destination, not a step 6 of 6.
  const remaining = INPUT_STEPS + 1 - step;

  return (
    <nav className="wz-bar" aria-label="Plan steps">
      <div className="wz-inner">
        <div className="wz-meta">
          <span className="wz-kick">Step <b>{Math.min(step, INPUT_STEPS)}</b> of {INPUT_STEPS}{goalLabel ? ` · ${goalLabel}` : ""}</span>
          <span className="wz-remain">
            {remaining <= 0 ? "Your plan is ready" : remaining === 1 ? "1 step to your plan" : `${remaining} steps to your plan`}
          </span>
        </div>
        <div className="wz-rail">
          {stepMeta.map((s, i) => {
            const isGoal = i === stepMeta.length - 1;
            const reached = s.isDone || s.isActive;
            // Input steps: "done" = reachable, tick when done and not current.
            // Plan node: "done" = signed, and its badge reports plan state, so
            // unlike the tick it shows on the current step too — it's a fact
            // about the plan, not about where you're standing. Both are gated on
            // `reached`: a badge on a locked, greyed-out node is one the user
            // can't act on, and `reached`-but-unsigned gets .reached (navigable,
            // but no green fill it hasn't earned).
            const badge = isGoal ? (reached ? planStatus : null)
                                 : (s.isDone && !s.isActive ? "signed" : null);
            const state = s.isActive ? " active"
                        : isGoal ? (planStatus ? " done" : s.isDone ? " reached" : "")
                        : s.isDone ? " done" : "";
            return (
              <React.Fragment key={i}>
                {i > 0 && <span className={"wz-track" + (stepMeta[i - 1].target < step ? " done" : "")} />}
                <button
                  className={"wz-node" + (isGoal ? " goal" : "") + state}
                  onClick={() => jump(s.target, s.canClick)}
                  disabled={!s.canClick}
                  aria-current={s.isActive ? "step" : undefined}
                >
                  <span className="wz-disc">
                    <i className={"ti " + s.icon} aria-hidden="true" />
                    {badge === "draft" && <span className="wz-warn" title="Signed, but changed since — review and re-sign"><i className="ti ti-exclamation-mark" aria-hidden="true" /></span>}
                    {badge === "signed" && <span className="wz-tick"><i className="ti ti-check" aria-hidden="true" /></span>}
                  </span>
                  <span className="wz-lbl">{s.label}</span>
                </button>
              </React.Fragment>
            );
          })}
        </div>
      </div>

      <button className="wz-mobile" onClick={() => setOpen(v => !v)} aria-expanded={open}>
        {current && <span className="wz-mobile-disc"><i className={"ti " + current.icon} aria-hidden="true" /></span>}
        <span>
          <span className="wz-mobile-kicker">Step {Math.min(step, INPUT_STEPS)} of {INPUT_STEPS}</span>
          <span className="wz-mobile-now">{current ? current.label : ""}</span>
        </span>
        <span className="wz-mobile-count">
          <span className="wz-mobile-pips" aria-hidden="true">
            {STEPS.slice(0, INPUT_STEPS).map((_, i) => <i key={i} className={i < step ? "on" : ""} />)}
          </span>
          <i className={"ti ti-chevron-down wz-mobile-chev" + (open ? " open" : "")} aria-hidden="true" />
        </span>
      </button>
      <div className={"wz-mobile-panel" + (open ? " open" : "")}>
        <div className="wz-mobile-list">
          {stepMeta.map((s, i) => {
            // Same three-way plan state as the desktop rail — mobile has no
            // badge layer, so it lands in the marker itself: green = signed,
            // amber = signed but stale, neutral = never signed.
            const isGoal = i === stepMeta.length - 1;
            const reached = s.isDone || s.isActive;
            const state = s.isActive ? " active"
                        : isGoal ? (planStatus ? " done" : s.isDone ? " reached" : "")
                        : s.isDone ? " done" : "";
            return (
              <React.Fragment key={i}>
                {i > 0 && <div className="wz-mobile-line" />}
                <button
                  className={"wz-mobile-row" + (isGoal ? " goal" : "")
                    + (isGoal && reached && planStatus === "draft" ? " warn" : "") + state}
                  onClick={() => jump(s.target, s.canClick)}
                  disabled={!s.canClick}
                  aria-current={s.isActive ? "step" : undefined}
                >
                  <span className="wz-mobile-dot"><i className={"ti " + s.icon} aria-hidden="true" /></span>
                  {s.label}
                </button>
              </React.Fragment>
            );
          })}
        </div>
      </div>
    </nav>
  );
}

// ── IPS Builder wizard ───────────────────────────────────────────────────────────
function IPSBuilder({ user, onLogout, onSwitchTab, onIpsSaved, onUserUpdated, onNavigate, planHubKey, onDraftPending, onDraftGoalInfo, onStalePlans, onFormDirty, onReviewDue, planRefreshKey }) {
  const [step, setStep] = useState(0);
  const [activeGoal, setActiveGoal] = useState(null); // null = hub | "retirement"|"home"|etc
  const [savedByGoal, setSavedByGoal] = useState({}); // { goalId: { form, version, updated_at, ... } }
  const [form, setForm] = useState(() => ({ ...EMPTY_FORM, name: user.name || "" }));
  const [loaded, setLoaded] = useState(false);
  const [saved, setSaved] = useState(null);     // {version, at}
  const [saveState, setSaveState] = useState("");// "" | saving | saved | error
  const [clearConfirmGoal, setClearConfirmGoal] = useState(null);
  // True when the live wizard form differs from what's actually persisted for
  // this goal — see plan-tab.md § "Plan changes can't silently go unsaved".
  // Declared this early (rather than down by editCount, where it first lived)
  // so the preview-fetch effect below and the document's own chart-selection
  // logic can share the exact same signal as DocStatusChip — a goal that
  // reads "This plan has unsaved changes" must be showing the same live
  // preview its charts are drawing, not a stale saved one.
  const formDirty = formDirtyCheck(form, savedByGoal[activeGoal], user.name);
  // Whenever there's nothing saved yet, or what's saved no longer matches the
  // wizard, the plan document's charts must show a live, non-persisted
  // preview instead of the last-accepted numbers — otherwise "signing off" on
  // the document reviews a projection that doesn't reflect what's on screen.
  const previewActive = !saved || formDirty;
  // Which goal (if any) the single localStorage draft slot currently belongs to
  // — read synchronously on mount so the hub can show a "draft in progress"
  // card on first paint, not just after the restore effect runs.
  const [draftGoalId, setDraftGoalId] = useState(() => {
    const d = loadWizardDraft(user.slug);
    return (d && d.activeGoal && d.step > 0) ? d.activeGoal : null;
  });
  const openHelp = (articleId) => { onNavigate("help", { tab: "plan", articleId: articleId || null }); };

  // Editable Plan document (case 6) — in-place edit layer.
  const [docEdits, setDocEdits] = useState({ blocks: {}, notes: [] });
  const [hoveredBlockId, setHoveredBlockId] = useState(null);
  const [editingBlockId, setEditingBlockId] = useState(null);
  const [buffer, setBuffer] = useState("");
  const [editingHoldings, setEditingHoldings] = useState(false);
  const [holdBuffer, setHoldBuffer] = useState([]);
  const [hoveredAddSection, setHoveredAddSection] = useState(null);
  const [docEditErr, setDocEditErr] = useState(false);
  // Cache for the case-5 Savings Projection preview's unsaved-plan MC run
  // (computeIpsMC — unseeded, Math.random()-based). Editing is now always-on
  // in case 6 (see saveDocEdits), so hovering any editable block updates
  // hoveredBlockId and re-renders IPSBuilder on every mouse move — without
  // this cache that silently redrew the chart with a brand new random
  // projection on every hover, even though nothing about the plan changed.
  // Keyed on a signature of computeIpsMC's actual inputs (see the case-5 IIFE
  // below), so a recompute only happens when the plan itself changes.
  const _ipsMcCacheRef = React.useRef(null); // { sig, mc }

  // Government-benefit reference table (start age → CPP/OAS monthly figures),
  // fetched once — drives the retirement CPP/OAS dropdowns and amount sliders.
  const [cppOasRef, setCppOasRef] = useState(null); // null until loaded; {age: row}

  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));

  // Plain navigation back to the hub — logo, the Plan nav tab, "← All plans",
  // "← Back" from step 1. This must NOT discard the local draft: none of these
  // read as a destructive action (there's no visual cue clicking the logo would
  // lose anything), so the draft stays in localStorage and the hub shows it as
  // "draft in progress" (see PlanGoalCard) until the user explicitly discards
  // it or the goal is actually saved. markDraftAtHub flags where we left off, so
  // a refresh from here shows the hub too instead of jumping back into the wizard.
  const goToHub = () => {
    markDraftAtHub(user.slug);
    setActiveGoal(null);
    setStep(0);
  };

  // Shared by the mount-time restore and by resuming a draft card from the hub
  // — applies {step, form, docEdits} from a local draft, reconciling `saved`
  // against the server record for this goal if one already exists (the draft
  // may hold edits made on top of an already-signed plan).
  const applyDraft = (draft, sv) => {
    setStep(draft.step);
    setForm(hydrateForm(draft.form, user.name));
    setDocEdits(draft.docEdits && typeof draft.docEdits === "object" ? draft.docEdits : { blocks: {}, notes: [] });
    setSaved(sv && sv.form ? { version: sv.version, at: sv.updated_at } : null);
  };

  // Load all saved IPSs on mount — populate the hub.
  useEffect(() => {
    (async () => {
      const r = await api("/api/ips");
      if (r.ok && r.data && r.data.goals) {
        setSavedByGoal(r.data.goals);
      }
      setLoaded(true);
    })();
  }, []);

  // Resume an in-progress wizard draft after a refresh — but only jump back
  // into the wizard if that's genuinely where the user was. `atHub` tracks
  // whether they'd navigated back to the hub before the refresh (see
  // markDraftAtHub); if so, leave step/activeGoal at their hub defaults and let
  // the hub's "draft in progress" card (below) be the resume point instead.
  // Runs once, after the saved-goals fetch above resolves, so `saved` can be
  // reconciled against the server's record for this goal if one exists.
  const _draftRestored = React.useRef(false);
  useEffect(() => {
    if (!loaded || _draftRestored.current) return;
    _draftRestored.current = true;
    const draft = loadWizardDraft(user.slug);
    if (draft && draft.activeGoal && draft.step > 0 && !draft.atHub) {
      setActiveGoal(draft.activeGoal);
      applyDraft(draft, savedByGoal[draft.activeGoal]);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loaded]);

  // Mirror the in-progress draft to localStorage (debounced) while the wizard
  // is open — always atHub:false, since being in the wizard is definitionally
  // not being at the hub. Clearing is always explicit (see clearWizardDraft
  // call sites below) so this write effect never has to guess whether it's
  // safe to wipe the slot — it only ever writes.
  useEffect(() => {
    if (!loaded || !activeGoal || step === 0) return;
    const t = setTimeout(() => {
      saveWizardDraft(user.slug, { activeGoal, step, form, docEdits, atHub: false });
      setDraftGoalId(activeGoal);
    }, 400);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loaded, activeGoal, step, form, docEdits]);

  // Let App know whether there's an in-progress, never-yet-saved wizard draft,
  // so it can nudge the user with a banner if they wander off to another tab
  // without saving, and — for the Save tab's goal ribbon — which goal it's for
  // (icon + label), so a ghost card can represent it there too. Scoped to
  // "never saved" (not "saved with further unsaved edits") to keep the signal
  // simple and unambiguous.
  //
  // Deliberately keyed off `draftGoalId` + `savedByGoal`, NOT `activeGoal`/
  // `step`/`saved` — those three reset the moment goToHub() runs (plain
  // navigation via the logo, "← All plans", etc.), which must NOT make the
  // banner/ghost card vanish; the draft is still sitting in localStorage,
  // just parked at the hub. `draftGoalId` is the one piece of state that
  // survives goToHub() untouched (see § "Wizard draft persistence").
  useEffect(() => {
    if (!loaded) return;
    const sv = savedByGoal[draftGoalId];
    const pendingGoal = draftGoalId && !(sv && sv.form) ? draftGoalId : null;
    onDraftPending && onDraftPending(!!pendingGoal);

    // Unsaved-changes signal: draftGoalId also gets set for an *already-saved*
    // goal (the write effect above sets it unconditionally) — if the parked
    // draft's form differs from what's actually persisted (any field, not
    // just allocation — estate target, contributions, CPP/OAS, etc. all feed
    // the plan document/projection just as directly), the Save/Track tabs are
    // showing stale numbers relative to what the user was just looking at in
    // the wizard, with no other cue that it hasn't been re-saved (the "make a
    // change, see the doc update, assume it's saved" trap). Scoped to a
    // *saved* goal specifically — a never-saved goal already gets
    // DraftPlanBanner instead.
    if (draftGoalId && sv && sv.form) {
      const draft = loadWizardDraft(user.slug);
      const draftForm = draft && draft.form;
      if (draftForm && formDirtyCheck(draftForm, sv, user.name)) {
        onFormDirty && onFormDirty({
          goalId: draftGoalId,
          label: planGoalName(draftGoalId, draftForm),
        });
      } else {
        onFormDirty && onFormDirty(null);
      }
    } else {
      onFormDirty && onFormDirty(null);
    }

    if (!pendingGoal) { onDraftGoalInfo && onDraftGoalInfo(null); return; }
    const draft = loadWizardDraft(user.slug);
    const draftForm = (draft && draft.form) || form;
    onDraftGoalInfo && onDraftGoalInfo({
      goalId: pendingGoal,
      label: planGoalName(pendingGoal, draftForm),
      icon: goalTiIcon(pendingGoal, draftForm),
    });
  }, [loaded, draftGoalId, savedByGoal]); // eslint-disable-line react-hooks/exhaustive-deps

  // Report saved plans that haven't been re-saved in over a year — the review
  // loop's coarse backstop (see StalePlanBanner). Computed here because App
  // doesn't hold savedByGoal; sorted stalest-first so App's banner surfaces
  // the most overdue plan. A successful persist() refetches /api/ips, which
  // updates savedByGoal's updated_at and clears the goal from this list.
  useEffect(() => {
    if (!loaded || !onStalePlans) return;
    const now = Date.now();
    const stale = Object.entries(savedByGoal)
      .filter(([g, sv]) => sv && sv.form && sv.updated_at
        && (now - Date.parse(sv.updated_at)) > STALE_PLAN_MS)
      .map(([g, sv]) => ({ goalId: g, label: planGoalName(g, sv.form), updatedAt: sv.updated_at }))
      .sort((a, b) => Date.parse(a.updatedAt) - Date.parse(b.updatedAt));
    onStalePlans(stale);
  }, [loaded, savedByGoal]); // eslint-disable-line react-hooks/exhaustive-deps

  // Report a goal whose signature went stale because the Save tab changed which
  // accounts the plan covers. Same shape/dismissal semantics as onFormDirty
  // above; App picks ONE of the two to show (unsaved changes wins).
  useEffect(() => {
    if (!loaded || !onReviewDue) return;
    const hit = Object.entries(savedByGoal).find(([, sv]) => planNeedsReview(sv));
    onReviewDue(hit ? { goalId: hit[0], label: planGoalName(hit[0], hit[1].form) } : null);
  }, [loaded, savedByGoal]); // eslint-disable-line react-hooks/exhaustive-deps

  // A Save-tab account change bumps planRefreshKey. This tab stays mounted, so
  // savedByGoal (and the parked draft, and any open wizard form) would still
  // name the old accounts — re-read the server's copy and reconcile the two
  // client-side mirrors of `accounts` onto it. Reconciling only `accounts` is
  // deliberate: the PATCH is the newer, explicit statement of which accounts
  // the plan covers, and leaving a stale list in the wizard form would make
  // formDirtyCheck report a phantom "unsaved changes" (the exact false
  // positive plan-tab.md § "Plan changes can't silently go unsaved" avoids).
  // The ref guard skips the initial mount.
  const _prevPlanKey = React.useRef(planRefreshKey);
  useEffect(() => {
    if (_prevPlanKey.current === planRefreshKey) return;
    _prevPlanKey.current = planRefreshKey;
    (async () => {
      const g = await api("/api/ips");
      if (!g.ok || !g.data || !g.data.goals) return;
      const goals = g.data.goals;
      setSavedByGoal(goals);
      const serverAccts = (id) => (goals[id] && goals[id].form && goals[id].form.accounts) || null;
      // Order-sensitive compares on purpose: stableStringify does NOT sort
      // arrays, so formDirtyCheck sees ["tfsa","rrsp"] and ["rrsp","tfsa"] as
      // different forms. Matching the server's exact array is what clears it.
      const liveAccts = activeGoal && serverAccts(activeGoal);
      if (liveAccts) {
        setForm(f => stableStringify(f.accounts) === stableStringify(liveAccts)
          ? f : { ...f, accounts: [...liveAccts] });
      }
      const draft = loadWizardDraft(user.slug);
      const draftAccts = draft && draft.activeGoal && serverAccts(draft.activeGoal);
      if (draft && draft.form && draftAccts &&
          stableStringify(draft.form.accounts || []) !== stableStringify(draftAccts)) {
        saveWizardDraft(user.slug, { ...draft, form: { ...draft.form, accounts: [...draftAccts] } });
      }
    })();
  }, [planRefreshKey]); // eslint-disable-line react-hooks/exhaustive-deps

  // Both the Savings Projection (accumulation) chart and, for retirement, the
  // Retirement Withdrawals decumulation chart need to show what the user is
  // *currently* reviewing, not just what was last accepted — CPP/OAS gating,
  // tax brackets, and the meltdown-order solve all live in
  // projection_engine.py and aren't reproducible client-side. Fetch a one-off,
  // non-persisted preview from the same engine whenever there's nothing
  // trustworthy in savedByGoal to read: either this goal has never been saved
  // (`!saved`), or it has, but the live form no longer matches what's saved
  // (`formDirty` — e.g. an estate target or CPP/OAS change made after the
  // last Accept Plan). Covers all four goal types, not just retirement —
  // /api/ips/preview already supports them (see its handler).
  const [previewProj, setPreviewProj] = useState(null);
  useEffect(() => {
    if (!(step === 6 && previewActive)) {
      setPreviewProj(null);
      return;
    }
    let cancelled = false;
    const t = setTimeout(async () => {
      const r = await api("/api/ips/preview", { method: "POST", body: { ...form, goal: activeGoal } });
      if (!cancelled && r.ok && r.data) setPreviewProj(r.data);
    }, 400);
    return () => { cancelled = true; clearTimeout(t); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeGoal, step, previewActive, form]);

  // Early sustainability check (Accounts step, 2026-08-13). Without this, a
  // plan headed for a shortfall isn't flagged until Plan Review — by which
  // point Allocation, Climate and Governance are all behind the user. Runs the
  // SAME engine early (via /api/ips/preview, same as above) standing in for
  // the two inputs that genuinely aren't chosen yet at this step: a balanced
  // 60/40 allocation (never read from `form.equity`, which only reflects the
  // duration default or a later manual choice) and, for retirement, average
  // CPP/OAS taken at 65 (never the wizard's own age-70 default or whatever the
  // sliders on THIS SAME page currently show — deferral is a separate lever
  // from "is this plan fundamentally underfunded", and reading it live would
  // make the banner flicker as the user drags a slider it isn't about).
  // Deliberately not persisted, not shown as the plan's real projection, and
  // never gates canProceed()/stepValid(2) — educational nudge only, same
  // stance as the Allocation step's tooAggressive/tooConservative warnings.
  const [acctShortfall, setAcctShortfall] = useState(null); // projection_view | null
  useEffect(() => {
    if (step !== 2) { setAcctShortfall(null); return; }
    let cancelled = false;
    const t = setTimeout(async () => {
      const overrides = { equity: 60 };
      if (activeGoal === "retirement" && cppOasRef && cppOasRef[65]) {
        const ref65 = cppOasRef[65];
        overrides.cppStartAge = "65";
        overrides.oasStartAge = "65";
        overrides.cppAnnual = String(Math.round(Number(ref65.cpp_avg_monthly || 0) * 12));
        overrides.oasAnnual = String(Math.round(Number(ref65.oas_max_monthly || 0) * 12));
      }
      const r = await api("/api/ips/preview", {
        method: "POST", body: { ...form, ...overrides, goal: activeGoal },
      });
      if (!cancelled) {
        const v = r.ok && r.data && r.data.projection_view;
        setAcctShortfall(v && v.ok ? v : null);
      }
    }, 500);
    return () => { cancelled = true; clearTimeout(t); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [step, activeGoal, form, cppOasRef]);

  // "Within 5% of a shortfall" is a stricter bar than the saved-plan signal's
  // 80% amber line (projection.md § Signal logic) — deliberately: this estimate
  // stands in a generic allocation and benefit timing, so it should read as an
  // early, close-call nudge, not a precise verdict.
  const acctShortfallFlag = !!(acctShortfall && acctShortfall.target > 0
    && acctShortfall.projected_value < acctShortfall.target * 1.05);

  // A cross-tab "show me my plans" request (Save/Track "Add a plan" / "Go to Plans")
  // bumps planHubKey. This tab stays mounted, so `step` would otherwise still point at
  // the last plan the user opened — reset to the hub so they can pick an empty slot.
  // The ref guard skips the initial mount (nothing to reset yet).
  const _prevHubKey = React.useRef(planHubKey);
  useEffect(() => {
    if (_prevHubKey.current === planHubKey) return;
    _prevHubKey.current = planHubKey;
    markDraftAtHub(user.slug);
    setActiveGoal(null);
    setStep(0);
  }, [planHubKey]);

  // Load the CPP/OAS reference table once (retirement wizard uses it).
  useEffect(() => {
    (async () => {
      const r = await api("/api/cpp-oas-reference");
      if (r.ok && r.data && Array.isArray(r.data.rows)) {
        const byAge = {};
        r.data.rows.forEach(row => { byAge[row.start_age] = row; });
        setCppOasRef(byAge);
      }
    })();
  }, []);

  // Duration-matched slider default (methodology.md § 13): until the user
  // touches the allocation slider, its position tracks the goal's timeline —
  // a 2-year goal defaults bonds-heavy, a 30-year goal equity-heavy. Stops
  // the moment `equityTouched` flips (slider onChange); never fires for
  // reopened stored plans (hydration coerces their flag to true).
  useEffect(() => {
    if (!activeGoal || form.equityTouched) return;
    const sugg = suggestedEquityForHorizon(planHorizonYears(form));
    if (sugg != null && sugg !== form.equity) set("equity", sugg);
    // eslint-disable-next-line
  }, [activeGoal, form.equityTouched, form.retireYear]);

  // Auto-set benchmark from equity % + climate screen.
  useEffect(() => {
    setForm(f => {
      const eq = f.equity;
      const bm =
        eq <=  5 ? (f.climateOn ? "ESGB" : "VAB")  :
        eq <= 20 ? "VCIP" :
        eq <= 35 ? "TCON" :
        eq <= 50 ? "ZCON" :
        eq <= 60 ? (f.climateOn ? "ZESG" : "VBAL") :
        eq <= 80 ? "VGRO" : "VEQT";
      return f.benchmark === bm ? f : { ...f, benchmark: bm };
    });
  }, [form.equity, form.climateOn]);

  // Auto-derive retireAge from birth_year + retireYear when birth_year is known.
  useEffect(() => {
    if (!user.birth_year || !form.retireYear) return;
    const derived = String(parseInt(form.retireYear) - user.birth_year);
    if (derived !== form.retireAge) set("retireAge", derived);
  }, [form.retireYear, user.birth_year]);

  // Birth year is needed only for retirement math (CPP/OAS/decumulation ages).
  // When it isn't on the profile but is derivable from a retirement plan's
  // retireYear + a manually-entered retireAge, silently compute and persist it —
  // no prompt. Also backfills the profile for anyone who saved a retirement plan
  // before birth year existed as a profile concept.
  useEffect(() => {
    if (activeGoal !== "retirement" || user.birth_year) return;
    if (!form.retireYear || !form.retireAge) return;
    const by = parseInt(form.retireYear) - parseInt(form.retireAge);
    if (!Number.isFinite(by)) return;
    // Sanity-gate against mid-typing races (e.g. "6" before "60"): only persist
    // when the implied *current* age is plausible, so a transient partial entry
    // can't write a wrong birth year (which would then overwrite retireAge).
    const curAge = new Date().getFullYear() - by;
    if (curAge < 18 || curAge > 100) return;
    (async () => {
      const r = await api("/api/auth/me", { method: "PATCH", body: { birth_year: by } });
      if (r.ok && r.data && r.data.user) onUserUpdated && onUserUpdated(r.data.user);
    })();
  }, [activeGoal, user.birth_year, form.retireYear, form.retireAge]);

  // CPP and OAS are required, not optional — pre-populate both to age 70 (both
  // benefits taken, deferred) with that year's national-average amount the
  // first time the section is available. 70 is a deliberate nudge: the
  // calculator's implicit goal is to draw down investable assets to bridge to
  // delayed government benefits, which form a larger inflation-protected
  // annuity for life. A saved plan's own values (including a deliberate $0
  // amount, or a different start age) are preserved — the guards only fire when
  // the field is still blank. The amount can be zeroed later if it doesn't apply.
  useEffect(() => {
    if (activeGoal !== "retirement" || !user.birth_year || !cppOasRef) return;
    const ref70 = cppOasRef[70];
    if (!ref70) return;
    const patch = {};
    if (!form.cppYear) {
      patch.cppYear = String(user.birth_year + 70);
      patch.cppStartAge = "70";
      patch.cppAnnual = String(Math.round(Number(ref70.cpp_avg_monthly || 0) * 12));
    }
    if (!form.oasYear) {
      patch.oasYear = String(user.birth_year + 70);
      patch.oasStartAge = "70";
      patch.oasAnnual = String(Math.round(Number(ref70.oas_max_monthly || 0) * 12));
    }
    if (Object.keys(patch).length) setForm(f => ({ ...f, ...patch }));
  }, [activeGoal, user.birth_year, cppOasRef, form.cppYear, form.oasYear]);

  // Keep the backend start ages in sync with the year dropdowns (UI-only),
  // mirroring how retireAge derives from retireYear.
  useEffect(() => {
    if (activeGoal !== "retirement" || !user.birth_year) return;
    const age = form.cppYear ? String(parseInt(form.cppYear) - user.birth_year) : "";
    if (age !== form.cppStartAge) set("cppStartAge", age);
  }, [form.cppYear, user.birth_year, activeGoal]);
  useEffect(() => {
    if (activeGoal !== "retirement" || !user.birth_year) return;
    const age = form.oasYear ? String(parseInt(form.oasYear) - user.birth_year) : "";
    if (age !== form.oasStartAge) set("oasStartAge", age);
  }, [form.oasYear, user.birth_year, activeGoal]);

  const alloc = useAllocation(form.equity, form.climateOn);
  const metrics = alloc
    ? { ret: alloc.exp_return, vol: alloc.exp_vol, label: alloc.profile, dd8: alloc.drawdown,
        mer: alloc.blended_mer, yld: alloc.blended_yield, holdings: alloc.holdings, source: alloc.registry_source }
    : (() => { const c = getCMA(form.equity); return { ret: c.ret, vol: c.vol, label: c.label, dd8: c.dd8, mer: null, yld: null, holdings: [], source: null }; })();

  // The Sample Holdings section (and only that section — ret/vol/dd8/label
  // stay on the generic model everywhere else, including the alloc1/alloc2
  // document prose) reflects a single-fund override when one is set: the
  // fund's own MER/yield replace the blended figures so "these funds cost
  // about X / pay about Y" stays consistent with the one row shown right
  // below it. See plan-tab.md § "Set my allocation based on existing Portfolio".
  const effectiveMetrics = form.singleFundOverride
    ? { ...metrics,
        holdings: [{ ticker: form.singleFundOverride, asset_class: "Multi-Asset", weight: 1, mer: form.singleFundMer }],
        mer: form.singleFundMer, yld: form.singleFundYield }
    : metrics;

  // The goal's real Save-tab holdings, lazily fetched on the two steps that
  // read them and only for an already-saved goal (a portfolio only exists once
  // a goal has been saved at least once), via the same endpoint the Holdings
  // modal itself reads:
  //   step 2 (Accounts)    → the read-only per-account balances a funded goal
  //                          shows in place of the editable cards
  //   step 4 (Allocation)  → § "Set my allocation based on existing Portfolio"
  // One fetch, one cache — do not add a second effect for the Accounts step.
  const [holdingsSnapshot, setHoldingsSnapshot] = useState(null);
  useEffect(() => {
    if ((step !== 2 && step !== 4) || !activeGoal || !savedByGoal[activeGoal]) {
      setHoldingsSnapshot(null);
      return;
    }
    let cancelled = false;
    api(`/api/portfolio/holdings-edit/${activeGoal}`).then(r => {
      if (cancelled) return;
      setHoldingsSnapshot(r.ok ? r.data : null);
    });
    return () => { cancelled = true; };
  }, [step, activeGoal, savedByGoal]);

  // null means "nothing derivable" (no portfolio yet, or a cash-only one) —
  // the Allocation step's sync button simply doesn't render in that case.
  const actualHoldingsData = React.useMemo(
    () => (holdingsSnapshot ? computeActualAllocation(holdingsSnapshot) : null),
    [holdingsSnapshot]);

  const availableAccounts = ALL_ACCOUNTS.filter(a => !activeGoal || a.goals.includes(activeGoal));
  const toggleAcct = id => {
    if (form.accounts.includes(id)) {
      const { [id]: _removed, ...rest } = form.accountBalances;
      setForm(f => ({ ...f, accounts: f.accounts.filter(x => x !== id), accountBalances: rest }));
    } else {
      set("accounts", [...form.accounts, id]);
    }
  };

  // Accounts step, funded goal — the real per-account balances that replace the
  // editable cards. See plan-tab.md § "Accounts on a funded goal".
  const realBalance = (savedByGoal[activeGoal] || {}).projection_view?.current_value || 0;
  const isFunded = realBalance > 0;
  const fundedRows = React.useMemo(
    () => (isFunded ? fundedAccountRows(holdingsSnapshot, availableAccounts, form.accounts) : null),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [isFunded, holdingsSnapshot, activeGoal, form.accounts]);

  // Only a column that actually HOLDS money is locked — that's the part the
  // wizard can't change (see § "Accounts on a funded goal"). A `null` balance
  // means the snapshot is still loading, so those lock too rather than flicker
  // through an editable state. Everything else stays a live checkbox:
  // `ips_documents.accounts` is written ONLY here, and it gates both the
  // Save-tab Holdings editor's columns (app.holdings_edit_get) and which
  // buckets a manual contribution may name (app.contributions_add) — so this
  // list is the one and only way to bring a new account into a plan. Do not
  // lock the whole list; that strands a user who starts using an account
  // mid-plan with no way to add it anywhere in the app.
  const heldRows = fundedRows ? fundedRows.filter(r => r.balance === null || r.balance > 0) : null;
  const lockedIds = new Set(
    heldRows ? heldRows.flatMap(r => (r.named.length ? r.named : r.ids)) : []);
  const addableAccounts = heldRows ? availableAccounts.filter(a => !lockedIds.has(a.id)) : [];

  // Money sitting in an account the plan doesn't name is a real gap — the
  // document's withdrawal order and asset-location table are built from
  // form.accounts, so an unnamed funded account silently disappears from both.
  // Additive only: an account the user deliberately selected but hasn't funded
  // yet is left alone (it renders as $0), so this can never take a choice away.
  // Idempotent, so it marks the plan dirty once and then stops — do not extend
  // it to write BALANCES into the form, which would re-dirty every saved plan
  // on every visit as market prices move (and would be ignored anyway: for a
  // funded goal both the save and the preview read the real portfolio value,
  // never form.accountBalances — see projection.md § POST /api/ips/preview).
  useEffect(() => {
    if (!fundedRows) return;
    const missing = fundedRows
      .filter(r => (r.balance || 0) > 0 && !r.named.length)
      .map(r => r.defaultId);
    if (!missing.length) return;
    setForm(f => ({ ...f, accounts: [...f.accounts, ...missing.filter(id => !f.accounts.includes(id))] }));
  }, [fundedRows]);

  const bondPct = 100 - form.equity;
  const acctLabels = ALL_ACCOUNTS.filter(a => form.accounts.includes(a.id)).map(a => a.label);

  // Required fields for one step. Step 1 (Goals) and step 2 (Accounts) each gate
  // only their own screen — the required set didn't change when Details was split
  // in two, only which screen each field sits on. Steps 3+ hold no required
  // fields (every one of them has a default in EMPTY_FORM).
  const stepValid = (n) => {
    if (n === 1) {
      const yearFuture  = !!form.retireYear && parseInt(form.retireYear) > new Date().getFullYear();
      // The age requirement only applies to retirement — home/education/other
      // goals are a fixed dollar amount by a fixed future year and never need age.
      const ageOk       = activeGoal !== "retirement" ? true : (user.birth_year ? true : !!form.retireAge);
      const motivationOk = activeGoal === "retirement"
        ? (form.lifestyleChoices||[]).length > 0 || (form.lifestyle||"").trim()
        : true;
      const goalNameOk  = activeGoal !== "other" || !!(form.goalName.trim() || form.goalPreset);
      return !!(yearFuture && ageOk && form.targetIncome && motivationOk && goalNameOk);
    }
    // A funded goal demonstrably has accounts — its step-2 list is a read-only
    // mirror of the real portfolio, not a choice — so it can never fail this.
    // Belt-and-braces against a stale/empty form.accounts leaving a real plan
    // stuck on a screen with nothing on it to fix.
    if (n === 2) return isFunded || form.accounts.length > 0;
    return true;
  };
  // The bottom "Continue" button: gates the step you're standing on.
  const canProceed = () => stepValid(step);
  // A sub-nav forward jump: every input step *before* the target must be
  // satisfied. With one combined Details step "the current step passes" happened
  // to imply "everything required so far passes"; once the required fields live
  // on two screens it doesn't, and without this you could jump Goals → Plan with
  // no account ever selected. Going backwards is always free (see WizardSubNav).
  const canReachStep = (target) => {
    for (let n = 1; n < target; n++) if (!stepValid(n)) return false;
    return true;
  };

  // Persist the IPS to the backend (server recomputes holdings from the form).
  const persist = async () => {
    setSaveState("saving");
    const r = await api("/api/ips", { method: "POST", body: { ...form, goal: activeGoal } });
    if (r.ok && r.data && r.data.saved) {
      const { version, updated_at } = r.data.saved;
      setSaved({ version, at: updated_at });
      // Keep the enriched Plan hub in sync: store the freshly-computed allocation
      // (same shape the /api/ips load returns) so a first-time save renders its
      // chart + allocation bar without waiting for a full refetch.
      setSavedByGoal(prev => ({ ...prev, [activeGoal]: { ...prev[activeGoal], form, allocation: alloc || (prev[activeGoal] && prev[activeGoal].allocation), version, updated_at } }));
      setSaveState("saved");
      // The accepted plan is now the source of truth server-side — drop the
      // local override so a later refresh reads from savedByGoal, not a stale draft.
      clearWizardDraft(user.slug);
      setDraftGoalId(null);
      onIpsSaved && onIpsSaved();
      // Flush any document edits made while reviewing the plan before this first
      // Accept (see § "Editable Plan document" — editing is allowed pre-save,
      // but saveDocEdits skips the PATCH until a row exists to attach them to).
      // The ips_documents row now exists, so this is the one place they reach
      // the backend for a goal that's never been saved before.
      if (Object.keys(docEdits.blocks).length > 0 || docEdits.notes.length > 0) {
        const er = await api(`/api/ips/${activeGoal}/edits`, { method: "PATCH", body: docEdits });
        setDocEditErr(!er.ok);
      }
      // A save changes the contributions/target/allocation AND re-seeds the portfolio's
      // CASH row, so the server-side projection is now different. Refetch it — the
      // optimistic merge above would otherwise leave a stale `projection_view` on the
      // hub card and the plan document.
      (async () => {
        const g = await api("/api/ips");
        if (g.ok && g.data && g.data.goals) setSavedByGoal(g.data.goals);
      })();
      return true;
    }
    setSaveState("error");
    return false;
  };

  // Handles both "Clear plan" (a real, saved plan — deletes it server-side) and
  // "Discard draft" (a never-saved in-progress draft — nothing to delete on the
  // server, just drop the local copy). Reused across both flows per the
  // clearConfirmGoal modal, which picks its own copy based on which case this is.
  const handleClear = async (goalId) => {
    setClearConfirmGoal(null);
    if (savedByGoal[goalId] && savedByGoal[goalId].form) {
      const r = await api(`/api/ips/${goalId}`, { method: "DELETE" });
      if (!r.ok) return;
      setSavedByGoal(prev => { const next = { ...prev }; delete next[goalId]; return next; });
    }
    if (draftGoalId === goalId) {
      clearWizardDraft(user.slug);
      setDraftGoalId(null);
    }
  };

  const activeGoalLabel = activeGoal === "other" && (form.goalName.trim() || form.goalPreset)
    ? (form.goalName.trim() || form.goalPreset)
    : (GOAL_TYPES.find(t => t.id === activeGoal)?.label || "");

  const planTitle = `${user.name}'s ${{ retirement:"Retirement Plan", home:"Home Purchase Plan", education:"Education Fund Plan", other: form.goalName.trim() || form.goalPreset || "Savings Plan" }[activeGoal] || "Plan"}`;
  const planTargetAmt = parseFloat(form.targetIncome) || 0;

  // ── Withdrawal order ──
  const withdrawalOrder = () => {
    const o = [];
    if (form.accounts.includes("rrsp"))    o.push("RRSP / Spousal RRSP / LIRA — withdraw first; bridges me to CPP and OAS and is most tax-efficient");
    if (form.accounts.includes("fhsa"))    o.push("FHSA — use for my home purchase");
    if (form.accounts.includes("taxable")) o.push("Taxable account — Canadian dividends get a tax break; I only pay tax on gains when I sell");
    if (form.accounts.includes("resp"))    o.push("RESP — withdraw when my child starts school");
    if (form.accounts.includes("tfsa"))    o.push("TFSA — keep for last; every dollar here grows and is withdrawn completely tax-free");
    return o;
  };

  const assetLocation = () => {
    const rows = [];
    if (form.accounts.includes("rrsp"))    rows.push({ acct:"RRSP / Spousal RRSP / LIRA", strat:"Bonds and fixed income — interest earned here isn't taxed until withdrawal. Draw this down first in retirement." });
    if (form.accounts.includes("tfsa"))    rows.push({ acct:"TFSA",    strat:"Stocks — growth here is completely tax-free, so this is the best home for long-term investments." });
    if (form.accounts.includes("taxable")) rows.push({ acct:"Taxable", strat:"Canadian stocks and low-turnover ETFs — Canadian dividends get a tax break, and I only pay capital gains tax when I sell." });
    if (form.accounts.includes("fhsa"))    rows.push({ acct:"FHSA",    strat:"Conservative or short-term bonds — this money needs to be ready when I buy my home, so I keep it stable." });
    if (form.accounts.includes("resp"))    rows.push({ acct:"RESP",    strat:"Balanced to start, shifting toward bonds as my child gets closer to post-secondary — protecting gains when they're needed most." });
    return rows;
  };

  // ── Editable Plan document (case 6) ─────────────────────────────────────
  const docCtx = { form, metrics, activeGoal, activeGoalLabel, bondPct,
    withdrawOrderList: withdrawalOrder(), assetLocationRows: assetLocation() };

  const saveDocEdits = async (next) => {
    setDocEdits(next);
    // Pre-Accept there's no ips_documents row yet to PATCH — the edit still
    // takes effect locally (and rides along in the wizard's own localStorage
    // draft via the debounced write effect above, so it survives a refresh
    // exactly like any other unsaved form field) and is flushed to the
    // backend by persist() the moment Accept Plan creates the row.
    if (!saved) return;
    setSavedByGoal(prev => prev[activeGoal] ? { ...prev, [activeGoal]: { ...prev[activeGoal], doc_edits: next } } : prev);
    const r = await api(`/api/ips/${activeGoal}/edits`, { method: "PATCH", body: next });
    setDocEditErr(!r.ok);
  };
  const commitBlock = (blockId, value) => {
    setEditingBlockId(null);
    saveDocEdits({ ...docEdits, blocks: { ...docEdits.blocks,
      [blockId]: { value, baseline: DOC_BLOCK_DEFAULTS[blockId](docCtx), editedAt: new Date().toISOString() } } });
  };
  const restoreBlock = (blockId) => {
    const rec = docEdits.blocks[blockId];
    if (!rec) return;
    saveDocEdits({ ...docEdits, blocks: { ...docEdits.blocks,
      [blockId]: { ...rec, baseline: DOC_BLOCK_DEFAULTS[blockId](docCtx), editedAt: new Date().toISOString() } } });
  };
  const commitHoldings = (rowsValue) => {
    setEditingHoldings(false);
    const baseline = holdingsDefaultFromMetrics(effectiveMetrics);
    saveDocEdits({ ...docEdits, blocks: { ...docEdits.blocks,
      holdings: { value: rowsValue, baseline, editedAt: new Date().toISOString() } } });
  };
  const restoreHoldings = () => {
    const rec = docEdits.blocks.holdings;
    if (!rec) return;
    const baseline = holdingsDefaultFromMetrics(effectiveMetrics);
    saveDocEdits({ ...docEdits, blocks: { ...docEdits.blocks, holdings: { ...rec, baseline, editedAt: new Date().toISOString() } } });
  };
  const addNote = (section) => {
    const id = "n" + Date.now();
    setBuffer("");
    setEditingBlockId("note:" + id);
    setHoveredAddSection(null);
    setDocEdits(prev => ({ ...prev, notes: [...prev.notes, { id, section, value: "", editedAt: new Date().toISOString() }] }));
  };
  const commitNote = (noteId, value) => {
    setEditingBlockId(null);
    saveDocEdits({ ...docEdits, notes: docEdits.notes.map(n => n.id === noteId ? { ...n, value, editedAt: new Date().toISOString() } : n) });
  };
  const removeNote = (noteId) => {
    saveDocEdits({ ...docEdits, notes: docEdits.notes.filter(n => n.id !== noteId) });
  };
  const editCount = (() => {
    const sinceAt = saved ? new Date(saved.at).getTime() : 0;
    let n = 0;
    for (const id of Object.keys(docEdits.blocks)) {
      const isActive = id === "holdings" ? holdingsBlockActive(docEdits, metrics) : docBlockActive(id, docEdits, docCtx);
      if (isActive && new Date(docEdits.blocks[id].editedAt).getTime() > sinceAt) n++;
    }
    for (const note of docEdits.notes) {
      if (new Date(note.editedAt).getTime() > sinceAt) n++;
    }
    return n;
  })();
  // formDirty/previewActive are computed once, near the top of IPSBuilder —
  // see plan-tab.md § "Plan changes can't silently go unsaved".
  //
  // The state of the plan itself, for the wizard rail's "Your plan" node:
  //   "signed" → signed AND current  → green disc + tick
  //   "draft"  → signed but stale    → green disc + amber !
  //   null     → never signed        → neutral disc, NO badge
  // Three-way rather than a boolean because the two badges are mutually
  // exclusive by construction, and because `null` has to be distinguishable from
  // "signed" — that distinction is the whole point (see below).
  //
  // The signed/draft split mirrors DocStatusChip's own `draft` terms exactly, in
  // its order of severity (local wizard edits, a stale signature after an account
  // change, reworded doc blocks), so the rail can never show a green tick while
  // the chip on that same page reads "Working draft — review and re-sign". Keep
  // the two formulas in step if DocStatusChip's `draft` ever changes.
  //
  // `!saved → null` is load-bearing and is NOT part of DocStatusChip's draft
  // test. A tick means "signed, current, happy" and an amber ! means "you signed
  // this and it needs attention" — a goal never carried to the end has earned
  // neither, so it gets no badge and no green fill at all. Folding `!saved` into
  // the draft branch made the wizard open on step 1 flagging a document the user
  // had never seen; letting it fall through to the signed branch put a green tick
  // on a plan that was never signed. DocStatusChip covers the never-saved case
  // separately and much louder, in red, on the plan page itself.
  const planStatus = !saved ? null
    : (formDirty || planNeedsReview(savedByGoal[activeGoal]) || editCount > 0) ? "draft" : "signed";
  const resignPlan = () => {
    document.getElementById("doc-sig")?.scrollIntoView({ behavior: "smooth", block: "center" });
  };

  // Editing is available regardless of save state — pencils, add-note, and the
  // holdings grid all work purely against local docEdits/form/metrics (see
  // saveDocEdits above for the one place saved state actually matters: whether
  // an edit's PATCH reaches the backend immediately or waits for persist()).
  const editCtl = {
    enabled: true, docEdits, ctx: docCtx,
    hoveredBlockId, setHoveredBlockId, editingBlockId, setEditingBlockId, buffer, setBuffer,
    onCommit: commitBlock, onRestore: restoreBlock,
    editingHoldings, setEditingHoldings, holdBuffer, setHoldBuffer,
    onCommitHoldings: commitHoldings, onRestoreHoldings: restoreHoldings,
    hoveredAddSection, setHoveredAddSection, onAddNote: addNote,
    onCommitNote: commitNote, onRemoveNote: removeNote,
  };

  // ── Render steps ──────────────────────────────────────────────────────────
  const renderStep = () => {
    switch(step) {

      // ── 0: HUB ──
      case 0: {
        const enterGoal = (goalId) => {
          setActiveGoal(goalId);
          setEditingBlockId(null); setHoveredBlockId(null); setBuffer("");
          setEditingHoldings(false); setHoldBuffer([]); setHoveredAddSection(null); setDocEditErr(false);
          const sv = savedByGoal[goalId];
          // A local draft for this exact goal takes priority — it may hold edits
          // on top of an already-signed plan, or be the entire content of a goal
          // that's never reached the backend at all. Either way it's more recent
          // than sv.form.
          const draft = loadWizardDraft(user.slug);
          if (draft && draft.activeGoal === goalId && draft.step > 0) {
            applyDraft(draft, sv);
            return;
          }
          if (sv && sv.form) {
            setForm(hydrateForm(sv.form, user.name));
            setSaved({ version: sv.version, at: sv.updated_at });
            setDocEdits(sv.doc_edits && typeof sv.doc_edits === "object"
              ? { blocks: sv.doc_edits.blocks || {}, notes: sv.doc_edits.notes || [] }
              : { blocks: {}, notes: [] });
            setStep(6);
          } else {
            const fresh = { ...EMPTY_FORM, name: user.name || "" };
            if (goalId === "retirement" && user.retire_age) fresh.retireAge = String(user.retire_age);
            setForm(fresh);
            setSaved(null);
            setDocEdits({ blocks: {}, notes: [] });
            setStep(1);
          }
        };
        return (
          <PlanHub savedByGoal={savedByGoal} user={user} draftGoalId={draftGoalId}
            onEnterGoal={enterGoal} onClear={setClearConfirmGoal} />
        );
      }

      // ── 1: GOALS ──
      // What you're saving for: the goal itself, its timeline and target, what
      // matters to you, and (retirement) anything you want left over at the end.
      // The money side — where it's held, what funds it — is step 2.
      case 1: {
        // Code-point length, matching how cleanGoalName counts — Array.from
        // iterates code points, so a name with astral characters isn't reported
        // as twice its real length by the "n/25" counter below.
        const goalNameLen = Array.from(form.goalName || "").length;
        return (
        <div>
          <h1 className="sp-h1">Your goal</h1>
          <p className="sp-lead">Tell us about your <strong>{activeGoalLabel.toLowerCase()||"goal"}</strong>.<br/>Set your timeline and target, and what matters most to you.</p>

          {activeGoal === "other" && (
            <div style={{marginBottom:16}}>
              <div className="sp-label" style={{marginBottom:8}}>Goal name</div>
              <div className="rp-grid" style={{gridTemplateColumns:"repeat(3,1fr)",marginBottom:10}}>
                {OTHER_GOAL_PRESETS.map(p => {
                  const sel = form.goalPreset === p.id && !form.goalName.trim();
                  return (
                    <div key={p.id} className={`rp-card${sel?" selected":""}`}
                      onClick={() => set("goalPreset", form.goalPreset === p.id ? "" : p.id)}>
                      <div className="rp-check"><i className="ti ti-check rp-check-icon" aria-hidden="true"/></div>
                      <i className={`ti ti-${p.icon} rp-icon`} aria-hidden="true"/>
                      <div className="rp-title">{p.id}</div>
                    </div>
                  );
                })}
              </div>
              {/* Clamped through cleanGoalName on every keystroke, not just
                  validated on blur — a controlled value that never grows past
                  GOAL_NAME_MAX makes the cap self-evident (the field simply
                  stops accepting) instead of silently truncating at save time.
                  maxLength is the native belt-and-braces guard; it counts
                  UTF-16 units, so for astral characters it can stop a shade
                  before 25 code points. The counter reads from the same
                  code-point count as the clamp, so it can never claim room the
                  field won't accept. */}
              <input className="sp-input" type="text" placeholder="or type your own goal name…"
                maxLength={GOAL_NAME_MAX} value={form.goalName}
                onChange={e=>set("goalName",cleanGoalName(e.target.value))} />
              <div className="sp-hint" style={{marginTop:5,display:"flex",justifyContent:"space-between",gap:12}}>
                <span>This becomes the label on your plan card — short names read best.</span>
                <span style={{fontFamily:"'IBM Plex Mono',monospace",flexShrink:0,
                  color: goalNameLen >= GOAL_NAME_MAX ? "#b34030" : undefined}}>
                  {goalNameLen}/{GOAL_NAME_MAX}
                </span>
              </div>
            </div>
          )}
          <div className="sp-section-label">
            {planTitle}
          </div>
          <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:12}}>
            <div className="sp-field">
              <label className="sp-label">{goalYearLabel(activeGoal)}</label>
              <input className="sp-input" type="number" min={new Date().getFullYear()+1} max="2080"
                placeholder="e.g. 2045"
                value={form.retireYear} onChange={e=>set("retireYear",e.target.value)} />
              {form.retireYear && parseInt(form.retireYear) <= new Date().getFullYear() && (
                <div className="sp-hint" style={{marginTop:5,color:"#c0392b"}}>Target year must be in the future.</div>
              )}
              {form.retireYear && parseInt(form.retireYear) > new Date().getFullYear() && (
                <div className="sp-hint" style={{marginTop:5}}>
                  {parseInt(form.retireYear) - new Date().getFullYear()} years from today
                  {user.birth_year && form.retireAge ? ` · age ${form.retireAge}` : ""}
                </div>
              )}
            </div>
            {(activeGoal === "retirement" && !user.birth_year) ? (
              <div className="sp-field">
                <label className="sp-label">Your age at that time</label>
                <input className="sp-input" type="number" min="18" max="90" placeholder="e.g. 60"
                  value={form.retireAge} onChange={e=>set("retireAge",e.target.value)} />
              </div>
            ) : (
              <div className="sp-field">
                <label className="sp-label">{goalAmountLabel(activeGoal)}</label>
                <div className="sp-input-wrap">
                  <span className="pfx-sym">$</span>
                  <input className="sp-input pfx" type="number" placeholder="50,000" step={1000}
                    value={form.targetIncome} onChange={e=>set("targetIncome",e.target.value)} />
                </div>
              </div>
            )}
          </div>
          {(activeGoal === "retirement" && !user.birth_year) && (
            <div className="sp-field">
              <label className="sp-label">{goalAmountLabel(activeGoal)}</label>
              <div className="sp-input-wrap">
                <span className="pfx-sym">$</span>
                <input className="sp-input pfx" type="number" placeholder="50,000" step={1000}
                  value={form.targetIncome} onChange={e=>set("targetIncome",e.target.value)} />
              </div>
            </div>
          )}
          <div style={{marginTop:32,marginBottom:8}}>
            {activeGoal === "retirement" ? (
              <>
                <div className="sp-section-label" style={{marginBottom:6}}>What matters most to you?</div>
                <p className="sp-hint" style={{marginBottom:14}}>Choose everything that resonates — your plan will reflect what you're working toward.</p>
                <div className="rp-grid">
                  {LIFESTYLE_OPTIONS.map(opt => {
                    const sel = (form.lifestyleChoices||[]).includes(opt.id);
                    return (
                      <div key={opt.id} className={`rp-card${sel?" selected":""}`}
                        onClick={() => {
                          const cur = form.lifestyleChoices || [];
                          set("lifestyleChoices", sel ? cur.filter(x=>x!==opt.id) : [...cur, opt.id]);
                        }}>
                        <div className="rp-check"><i className="ti ti-check rp-check-icon" aria-hidden="true"/></div>
                        <i className={`ti ti-${opt.icon} rp-icon`} aria-hidden="true"/>
                        <div className="rp-title">{opt.label}</div>
                        <div className="rp-sub">{opt.desc}</div>
                      </div>
                    );
                  })}
                </div>
                {(() => { const n = (form.lifestyleChoices||[]).length; return n > 0 && <div className="rp-count"><strong>{n}</strong> {n===1?"priority":"priorities"} selected</div>; })()}
                <div className="sp-hint" style={{marginBottom:6}}>Anything else on your mind? <span style={{color:"#647071"}}>(optional)</span></div>
                <textarea className="sp-input" rows="2"
                  placeholder="Make a positive impact on future generations"
                  maxLength={TEXT_LIMITS.lifestyle}
                  value={form.lifestyle}
                  onChange={e=>set("lifestyle",cleanText(e.target.value,TEXT_LIMITS.lifestyle,true))} />

                <div style={{marginTop:24}}>
                  <div className="sp-section-label" style={{marginBottom:6}}>Leaving a legacy</div>
                  <p className="sp-hint" style={{marginBottom:14}}>
                    If desired, set aside an amount for leaving a legacy at the end of the plan in line with the goals above.{" "}
                    <button type="button" className="sp-inline-help"
                      onClick={() => openHelp("leaving-a-legacy")} title="Help">?</button>
                  </p>
                  <div className="sp-input-wrap" style={{width:180}}>
                    <span className="pfx-sym">$</span>
                    <input className="sp-input pfx" type="number" min="0" placeholder="0" step={10000}
                      value={form.estateTarget}
                      onChange={e=>set("estateTarget", e.target.value)}
                      onBlur={e => { const v = parseFloat(e.target.value); if (v < 0) set("estateTarget", "0"); }} />
                  </div>
                </div>
              </>
            ) : (
              <>
                <div className="sp-section-label" style={{marginBottom:6}}>Why this matters <span style={{color:"#647071",fontWeight:400}}>(optional)</span></div>
                <textarea className="sp-input" rows="3"
                  placeholder=""
                  maxLength={TEXT_LIMITS.lifestyle}
                  value={form.lifestyle}
                  onChange={e=>set("lifestyle",cleanText(e.target.value,TEXT_LIMITS.lifestyle,true))} />
              </>
            )}
          </div>
        </div>
        );
      }

      // ── 2: ACCOUNTS ──
      // How the goal gets funded: retirement income sources (CPP/OAS, a DB
      // pension), the accounts the money sits in, and the yearly contribution.
      case 2: {
        // `isFunded`/`realBalance`/`fundedRows` are hoisted next to
        // availableAccounts — see the comment there and plan-tab.md
        // § "Accounts on a funded goal".
        return (
        <div>
          <h1 className="sp-h1">Your accounts</h1>
          <p className="sp-lead">
            {activeGoal === "retirement"
              ? <>Where this plan is funded from — the income you'll receive in retirement, the accounts you hold, and what you add each year.</>
              : <>Select the accounts you'll use for this plan, and what you plan to add each year.</>}
          </p>

          {activeGoal === "retirement" && user.birth_year && cppOasRef && (
            <div style={{marginTop:32}}>
              <div className="sp-section-label" style={{marginBottom:6}}>Government Benefits</div>
              <p className="sp-hint" style={{marginBottom:14}}>
                Enter <strong>your own</strong> expected benefits — look them up with the free{" "}
                <a href="https://www.canada.ca/en/services/benefits/publicpensions/cpp/amount.html"
                   target="_blank" rel="noopener noreferrer"
                   style={{color:"#1a4a6b",textDecoration:"underline"}}>Service Canada estimate&nbsp;↗</a>.
                Set an amount to <strong>$0</strong> if a benefit doesn't apply to you.
                Why we default to 70, and how to read the sliders:{" "}
                <button type="button" className="sp-inline-help"
                  onClick={() => openHelp("government-benefits-cpp-oas")} title="Help">?</button>
              </p>
              <div className="gov-grid">
                <GovBenefitInput kind="cpp" label="CPP" birthYear={user.birth_year}
                  minAge={60} maxAge={70} refMap={cppOasRef}
                  year={form.cppYear} annual={form.cppAnnual}
                  yearKey="cppYear" annualKey="cppAnnual"
                  onPatch={patch => setForm(f => ({ ...f, ...patch }))} />
                <GovBenefitInput kind="oas" label="OAS" birthYear={user.birth_year}
                  minAge={65} maxAge={70} refMap={cppOasRef}
                  year={form.oasYear} annual={form.oasAnnual}
                  yearKey="oasYear" annualKey="oasAnnual"
                  onPatch={patch => setForm(f => ({ ...f, ...patch }))} />
              </div>
            </div>
          )}

          {/* Its own section, immediately below the CPP/OAS pair: a workplace
              pension is taxed the same way but is not a government benefit, and
              this block does not depend on cppOasRef or a birth year the way
              the sliders above do. */}
          {activeGoal === "retirement" && (
            <div style={{marginTop:32}}>
              <div className="sp-section-label" style={{marginBottom:6}}>Other retirement income</div>
              <p className="sp-hint" style={{marginBottom:14}}>
                If you'll receive a <strong>defined-benefit pension</strong> from an employer, or
                income from an annuity you've purchased, add it here. Your plan treats it as regular
                taxable income, like CPP. How it changes your plan, and the indexing assumption to
                check on your statement:{" "}
                <button type="button" className="sp-inline-help"
                  onClick={() => openHelp("workplace-pension-income")} title="Help">?</button>
              </p>
              <div className="gov-grid">
                <PensionInput birthYear={user.birth_year}
                  annual={form.pensionAnnual} startAge={form.pensionStartAge}
                  onPatch={patch => setForm(f => ({ ...f, ...patch }))} />
              </div>
            </div>
          )}

          <div className="sp-section-label" style={{marginTop:32}}>Accounts</div>
          {heldRows ? (
            // Funded goal: the accounts holding money are a mirror, not an
            // editor — every control on them would be a no-op, since
            // seed_portfolio_holdings never re-seeds a funded goal (db.md
            // § "Do not overwrite real user holdings") and both the save and
            // the preview read the real portfolio value, not
            // form.accountBalances. Adding a NEW account stays live below:
            // that's a plan decision, not a balance, and this list is the only
            // place in the app that can make one.
            <>
              <div className="sp-hint" style={{marginBottom:10}}>
                These are the accounts this goal holds today (<strong>{fmt$(realBalance)}</strong> in total). Balances follow your real holdings — change them on the{" "}
                <button type="button" onClick={() => onSwitchTab("portfolio")}
                  style={{background:"none",border:"none",padding:0,color:"#1a4a6b",textDecoration:"underline",cursor:"pointer",font:"inherit"}}>
                  Save tab
                </button>.
              </div>
              <div className="acct-list">
                {heldRows.map(r => (
                  <div key={r.key} className="acct-card sel acct-locked"
                    title="Held in your real portfolio — change it on the Save tab">
                    <div className="acct-check">
                      <i className="ti ti-lock" aria-hidden="true"/>
                    </div>
                    <div style={{flex:1}}>
                      <div className="acct-name">{r.label}</div>
                      <div className="acct-desc">{r.desc}</div>
                    </div>
                    <div className={"acct-bal" + (r.balance > 0 ? "" : " acct-bal-none")}>
                      {r.balance === null ? "—" : fmt$(Math.round(r.balance))}
                    </div>
                  </div>
                ))}
              </div>
              {addableAccounts.length > 0 && (
                <>
                  {/* Short pointer, not a workflow: the Save tab's Manage →
                      Accounts panel now does this from where the user actually
                      is when they need it, so the ordering no longer has to be
                      spelled out here. */}
                  <div className="sp-hint" style={{marginTop:16,marginBottom:8}}>
                    Starting to use another account? Tick it here — or any time from{" "}
                    <button type="button" onClick={() => onSwitchTab("portfolio")}
                      style={{background:"none",border:"none",padding:0,color:"#1a4a6b",textDecoration:"underline",cursor:"pointer",font:"inherit"}}>
                      Manage → Accounts
                    </button> on the Save tab.
                  </div>
                  <div className="acct-list">
                    {addableAccounts.map(a => {
                      const sel = form.accounts.includes(a.id);
                      return (
                        <div key={a.id} className={`acct-card${sel?" sel":""}`} onClick={() => toggleAcct(a.id)}>
                          <div className="acct-check">
                            {sel && (
                              <svg width="9" height="7" viewBox="0 0 9 7" fill="none">
                                <path d="M1 3.5L3 5.5L8 1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
                              </svg>
                            )}
                          </div>
                          <div style={{flex:1}}>
                            <div className="acct-name">{a.label}</div>
                            <div className="acct-desc">{a.desc}</div>
                          </div>
                          {/* No balance field: for a funded goal the starting
                              balance is never read (the real portfolio wins),
                              so offering one would be the same dead input the
                              locked rows above exist to remove. */}
                          <div className="acct-bal acct-bal-none">
                            {sel ? "no holdings yet" : ""}
                          </div>
                        </div>
                      );
                    })}
                  </div>
                </>
              )}
            </>
          ) : (
            <div className="acct-list">
              {availableAccounts.map(a => {
                const sel = form.accounts.includes(a.id);
                const bal = form.accountBalances[a.id];
                return (
                  <div key={a.id} className={`acct-card${sel?" sel":""}`} onClick={() => toggleAcct(a.id)}>
                    <div className="acct-check">
                      {sel && (
                        <svg width="9" height="7" viewBox="0 0 9 7" fill="none">
                          <path d="M1 3.5L3 5.5L8 1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
                        </svg>
                      )}
                    </div>
                    <div style={{flex:1}}>
                      <div className="acct-name">{a.label}</div>
                      <div className="acct-desc">{a.desc}</div>
                    </div>
                    {sel && (
                      <div style={{display:"flex",alignItems:"center",gap:6,marginLeft:12}} onClick={e => e.stopPropagation()}>
                        <div className="sp-input-wrap" style={{width:130}}>
                          <span className="pfx-sym">$</span>
                          <input className="sp-input pfx" type="number" min="0" placeholder="0" step={1000}
                            value={bal || ""}
                            onChange={e => set("accountBalances", {...form.accountBalances, [a.id]: e.target.value})} />
                        </div>
                        {bal ? (
                          <svg width="14" height="11" viewBox="0 0 14 11" fill="none">
                            <path d="M1 5.5L5 9.5L13 1" stroke="#8a5709" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
                          </svg>
                        ) : <div style={{width:14}}/>}
                      </div>
                    )}
                  </div>
                );
              })}
            </div>
          )}

          <div className="sp-field" style={{marginTop:28}}>
            <label className="sp-label">Annual contribution</label>
            <div className="sp-hint">Planned savings across all accounts per year — used to evaluate plan sustainability.</div>
            <div style={{display:"flex",alignItems:"center",gap:10}}>
              <div className="sp-input-wrap" style={{flex:1}}>
                <span className="pfx-sym">$</span>
                <input className="sp-input pfx" type="number" min="0" placeholder="0" step={1000}
                  value={form.annualSavings} onChange={e=>set("annualSavings",e.target.value)} />
              </div>
              <span style={{fontSize:13,color:"#647071",whiteSpace:"nowrap"}}>/yr</span>
            </div>
          </div>

          {acctShortfallFlag && (
            <div style={{padding:"11px 14px",borderRadius:4,background:"rgba(194,87,26,.08)",border:"1px solid rgba(194,87,26,.35)",fontSize:12.5,color:"#a1450f",lineHeight:1.55,marginTop:20}}>
              <strong>This plan may come up short.</strong> Consider increasing your contribution above, or{" "}
              <button type="button" onClick={() => setStep(1)}
                style={{background:"none",border:"none",padding:0,color:"#a1450f",textDecoration:"underline",cursor:"pointer",font:"inherit",fontWeight:600}}>
                adjusting your target
              </button>.
            </div>
          )}
        </div>
        );
      }

      // ── 3: CLIMATE ──
      case 3: return (
        <div>
          <h1 className="sp-h1">Climate Risk in Your Portfolio</h1>
          <p className="sp-lead">Climate change is a real financial risk. Long-term investors who ignore it face unique threats to their savings as the world changes.</p>

          <div className="climate-card">
            <div style={{padding:"12px 16px 4px",fontSize:12,fontWeight:600,color:"#5a6a72",letterSpacing:".06em",textTransform:"uppercase",fontFamily:"'IBM Plex Mono',monospace"}}>Why it matters</div>
            <ul style={{margin:0,padding:"4px 16px 14px 20px",listStyle:"disc",display:"flex",flexDirection:"column",gap:8}}>
              <li style={{fontSize:13,color:"#1a1a1a",lineHeight:1.5,paddingLeft:4}}><strong>Property damage</strong> — Severe weather causes costly, uninsured losses for businesses and real estate.</li>
              <li style={{fontSize:13,color:"#1a1a1a",lineHeight:1.5,paddingLeft:4}}><strong>Stranded businesses</strong> — High-pollution industries are losing ground as policy and technology shift to clean energy.</li>
              <li style={{fontSize:13,color:"#1a1a1a",lineHeight:1.5,paddingLeft:4}}><strong>Lawsuits &amp; rules</strong> — Governments and courts are increasingly penalizing heavy polluters, dragging down their value.</li>
            </ul>
            <div className="climate-footer">
              <div>
                <div className="climate-footer-text">Actively reduce climate risk exposure</div>
                <div className="climate-footer-sub">Favour hand-picked eco-friendly, low-carbon funds — verified as genuinely green, not just re-labelled</div>
              </div>
              <label className="toggle">
                <input type="checkbox" checked={form.climateOn} onChange={e=>set("climateOn",e.target.checked)} />
                <span className="toggle-track"></span>
              </label>
            </div>
          </div>

          {form.climateOn && (
            <div className="climate-on-note">
              <strong>Your suggested holdings will favour eco-friendly, low-carbon funds</strong> where available, and your plan will include a standing commitment to check that any green fund you hold is genuinely different from standard market indexes — not just re-labelled.
            </div>
          )}
          {!form.climateOn && (
            <div style={{padding:"11px 14px",borderRadius:4,background:"#f5f0e8",border:"1px solid #e3ddd1",fontSize:12.5,color:"#5a6a72",lineHeight:1.55}}>
              Your plan will note these risks and commit to reviewing them each year. You can turn on the climate screen at any future review.
            </div>
          )}
        </div>
      );

      // ── 4: ALLOCATION ──
      case 4: {
        const horizonYrs = planHorizonYears(form);
        const suggEq = suggestedEquityForHorizon(horizonYrs);
        const tooAggressive = suggEq != null && form.equity - suggEq >= 25;
        const tooConservative = suggEq != null && suggEq - form.equity >= 20;
        return (
        <div>
          <h1 className="sp-h1">Allocation and risk tolerance</h1>
          <p className="sp-lead">Move the slider to find an allocation you can hold through a difficult year — without changing course. Return and downturn calculations are based on historical data. Your suggested account composition is included in the document you generate.</p>

          {actualHoldingsData && (
            <div style={{padding:"14px 16px",borderRadius:6,marginBottom:20,
                         background: form.allocFromPortfolio ? "rgba(245,166,35,.09)" : "#fff",
                         border: "1px solid " + (form.allocFromPortfolio ? "#1a4a6b" : "#d4cfc5")}}>
              {form.allocFromPortfolio ? (
                <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",gap:12,flexWrap:"wrap"}}>
                  <span style={{fontSize:13,color:"#1a1a1a"}}>
                    <i className="ti ti-check" style={{color:"#8a5709",marginRight:6}} aria-hidden="true" />
                    Synced to your {activeGoalLabel} portfolio
                    {actualHoldingsData.singleFund
                      ? ` — ${actualHoldingsData.singleFund.ticker}, ${actualHoldingsData.equityPct}% equity.`
                      : ` — ${actualHoldingsData.equityPct}% equity.`}
                  </span>
                  <button onClick={() => setForm(f => ({ ...f, singleFundOverride: "", singleFundMer: null, singleFundYield: null, allocFromPortfolio: false }))}
                    style={{background:"none",border:"none",cursor:"pointer",color:"#647071",fontSize:12,textDecoration:"underline",fontFamily:"system-ui",flexShrink:0}}>
                    Reset
                  </button>
                </div>
              ) : (
                <div>
                  <button className="sp-btn sp-btn-primary" onClick={() => setForm(f => ({ ...f,
                        equity: actualHoldingsData.equityPct, equityTouched: true, allocFromPortfolio: true,
                        singleFundOverride: actualHoldingsData.singleFund ? actualHoldingsData.singleFund.ticker : "",
                        singleFundMer: actualHoldingsData.singleFund ? actualHoldingsData.singleFund.mer : null,
                        singleFundYield: actualHoldingsData.singleFund ? actualHoldingsData.singleFund.yield : null,
                      }))}>
                    Set my allocation based on existing Portfolio
                  </button>
                  <div className="sp-hint" style={{marginTop:8,marginBottom:0}}>
                    {actualHoldingsData.singleFund
                      ? `Your ${activeGoalLabel} portfolio holds ${actualHoldingsData.singleFund.ticker} (${actualHoldingsData.equityPct}% equity)`
                      : `Your ${activeGoalLabel} portfolio is currently ${actualHoldingsData.equityPct}% equity`}
                    {" "}— or adjust your risk level below.
                  </div>
                </div>
              )}
            </div>
          )}

          <div className="alloc-card">
            <div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline",marginBottom:14}}>
              <span style={{fontSize:13,fontWeight:500,color:"#1a1a1a"}}>Bonds / Equity</span>
              <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12,color:"#5a6a72"}}>{bondPct}% bonds / {form.equity}% equity</span>
            </div>
            <input type="range" className="sp-range" min="0" max="100" step="5"
              value={form.equity}
              onChange={e=>setForm(f=>({ ...f, equity: +e.target.value, equityTouched: true,
                singleFundOverride: "", singleFundMer: null, singleFundYield: null, allocFromPortfolio: false }))}
              style={{marginBottom:6}} />
            <div style={{display:"flex",justifyContent:"space-between",fontFamily:"'IBM Plex Mono',monospace",fontSize:10,color:"#647071",marginBottom:suggEq != null ? 10 : 20}}>
              <span>All bonds</span>
              <span>Balanced</span>
              <span>All equity</span>
            </div>
            {suggEq != null && (
              <div className="sp-hint" style={{marginBottom:(tooAggressive || tooConservative) ? 10 : 20}}>
                Suggested for this goal's ~{horizonYrs}-year timeline: <strong>{suggEq}% equity</strong>
                {form.equityTouched && form.equity !== suggEq ? " — you can choose your own mix based on your risk tolerance and circumstances." : ""}
              </div>
            )}
            {tooAggressive && (
              <div style={{padding:"11px 14px",borderRadius:4,background:"rgba(194,87,26,.08)",border:"1px solid rgba(194,87,26,.35)",fontSize:12.5,color:"#a1450f",lineHeight:1.55,marginBottom:20}}>
                <strong>More aggressive than your timeline suggests.</strong> This goal is about {horizonYrs} {horizonYrs === 1 ? "year" : "years"} away, and stocks generally need well over a decade to reliably ride out a bad stretch — a downturn shortly before {form.retireYear} could leave too little time to recover. Money needed sooner is usually better held in bonds and cash. Review your risk tolerance and circumstances to select the right mix for you.
              </div>
            )}
            {tooConservative && (
              <div style={{padding:"11px 14px",borderRadius:4,background:"rgba(194,87,26,.08)",border:"1px solid rgba(194,87,26,.35)",fontSize:12.5,color:"#a1450f",lineHeight:1.55,marginBottom:20}}>
                <strong>More conservative than your timeline suggests.</strong> This goal is about {horizonYrs} {horizonYrs === 1 ? "year" : "years"} away — usually enough time to ride out a bad stretch of markets and let stocks do their work. Holding this much in bonds and cash risks not keeping pace with what this goal needs by {form.retireYear}. Review your risk tolerance and circumstances to select the right mix for you.
              </div>
            )}
            <div className="alloc-metrics">
              <div className="alloc-metric">
                <div className="alloc-metric-label">Exp. return</div>
                <div><span className="alloc-metric-val">{metrics.ret}</span><span className="alloc-metric-unit">%/yr</span></div>
              </div>
              <div className="alloc-metric">
                <div className="alloc-metric-label">Worst downturn</div>
                <div><span className="alloc-metric-val" style={{color:"#b34030"}}>−{metrics.dd8 ? metrics.dd8.split("–").pop() || metrics.dd8.split("-").pop() : "—"}</span></div>
              </div>
              <div className="alloc-metric">
                <div className="alloc-metric-label">Profile</div>
                <div className="alloc-metric-label-val">{metrics.label}</div>
              </div>
            </div>
            <div style={{marginTop:14,paddingTop:12,borderTop:"1px solid var(--border-soft)",display:"flex",alignItems:"center",gap:8}}>
              <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10,letterSpacing:".12em",textTransform:"uppercase",color:"var(--muted)"}}>Suggested benchmark</span>
              <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:13,fontWeight:600,color:"var(--text)"}}>{form.benchmark}</span>
              <span style={{fontSize:12,color:"var(--soft)"}}>— {(BENCHMARK_NAMES[form.benchmark] || "").replace(/^[A-Z]+ – /,"")}</span>
            </div>
          </div>
        </div>
      );
      }

      // ── 5: GOVERNANCE ──
      case 5: return (
        <div>
          <h1 className="sp-h1">Governance rules</h1>
          <p className="sp-lead">The most important function of a plan is to <strong>pre-commit to a process</strong> before emotions are running high. These rules are your future self's instructions to your present self.</p>

          <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:24}}>
            <div>
              <div className="sp-section-label">Review cadence</div>
              <div className="sp-field">
                <div className="radio-list">
                  {CADENCES.map(c=>(
                    <div key={c.id} className={`radio-opt${form.cadence===c.id?" sel":""}`} onClick={()=>set("cadence",c.id)}>
                      <div className="radio-dot"><div className="radio-inner"></div></div>
                      <div>
                        <div className="radio-text">{c.label}</div>
                        <div className="radio-sub">{c.sub}</div>
                      </div>
                    </div>
                  ))}
                </div>
              </div>
            </div>

            <div>
              <div className="sp-section-label">Rebalancing trigger</div>
              <div className="sp-field">
                <div className="radio-list">
                  {TRIGGERS.map(t=>(
                    <div key={t.id} className={`radio-opt${form.trigger===t.id?" sel":""}`} onClick={()=>set("trigger",t.id)}>
                      <div className="radio-dot"><div className="radio-inner"></div></div>
                      <div>
                        <div className="radio-text">{t.label}</div>
                        <div className="radio-sub">{t.sub}</div>
                      </div>
                    </div>
                  ))}
                </div>
                <div className="sp-hint" style={{marginTop:7}}>How far can any asset class drift from its target before you rebalance?</div>
              </div>
            </div>
          </div>

          <div className="sp-section-label" style={{marginTop:28}}>Additional information <span style={{color:"#647071",fontWeight:400}}>(optional)</span></div>
          <div className="sp-field">
            <div className="sp-hint">Anything else you want captured directly in your plan — exceptions to these rules, special circumstances, or notes to your future self.</div>
            <textarea className="sp-input" rows="3"
              placeholder="e.g. I'll pause contributions during a planned home purchase in 2027"
              maxLength={TEXT_LIMITS.additionalInfo}
              value={form.additionalInfo||""}
              onChange={e=>set("additionalInfo",cleanText(e.target.value,TEXT_LIMITS.additionalInfo,true))} />
          </div>
        </div>
      );

      // ── 6: GENERATED IPS ──
      case 6: {
        // Menu-equivalent jump for ReplayLink — never call persist() here,
        // same rule as the sub-nav (see § "Wizard step sub-nav navigation").
        const goToStep = (n) => setStep(n);

        // The authoritative projection (today's dollars) — the same object the
        // Plan hub card and the Save tab goal card render, EXCEPT while
        // previewActive (goal never saved, or saved but locally edited — see
        // plan-tab.md § "Plan changes can't silently go unsaved"), where the
        // persisted value is exactly what's stale and the fresh, non-persisted
        // /api/ips/preview response is what belongs on screen instead. Hoisted
        // here (rather than inside the Savings Projection block below, its only
        // reader before now) so the shortfall banner reads the identical value
        // instead of re-deriving the previewActive/previewOk fallback a second
        // time — two copies of that fallback is exactly how the gross terminal
        // number and the "still short" verdict could disagree.
        const previewOk = previewProj && previewProj.projection_view && previewProj.projection_view.ok;
        const projView = (previewActive && previewOk)
          ? previewProj.projection_view
          : ((savedByGoal[activeGoal] || {}).projection_view || null);
        const hasProj  = !!(projView && projView.ok);
        // Real shortfall, from the real projection — unlike the Accounts-step
        // check (acctShortfallFlag) this has no 5% buffer and no stand-in
        // assumptions: by this step the allocation, climate screen and (for
        // retirement) CPP/OAS are the user's actual choices, so `signal` is
        // exactly _signal_from's verdict (projection.md § Signal logic) —
        // amber and red both mean projected < target; green is the only value
        // this doesn't flag.
        const planShortfall = hasProj && projView.signal && projView.signal !== "green";

        return (
        <div>
          <h1 className="sp-h1">Your Investment Plan</h1>
          <p className="sp-lead">Review your plan below.
            {saved
              ? " It's saved to your account — return any time to refine it as your circumstances change. Hover any section and use the pencil to edit it in place — changes save to your account automatically."
              : <> Hover any section and use the pencil to fine-tune it before you commit — nothing reaches your account until you <button className="doc-status-link" onClick={resignPlan}>click Accept Plan</button> below.</>}
          </p>
          {planShortfall && (
            <div style={{padding:"11px 14px",borderRadius:4,background:"rgba(194,87,26,.08)",border:"1px solid rgba(194,87,26,.35)",fontSize:12.5,color:"#a1450f",lineHeight:1.55,marginBottom:20}}>
              <strong>This plan may still fall short of its target.</strong> Review your contributions, allocation or target on the earlier steps before you sign.
            </div>
          )}
          {docEditErr && <div className="doc-saved" style={{color:"#b34030",marginTop:8}}>Edit failed to save — check your connection</div>}

          <div className="ips-doc">
            <div className="doc-header">
              <div className="doc-status-row">
                <DocStatusChip saved={saved} editCount={editCount} formDirty={formDirty}
                  reviewDue={planNeedsReview(savedByGoal[activeGoal])} onResign={resignPlan} />
              </div>
              <div className="doc-name">{planTitle}</div>
              <div className="doc-meta">
                {saved ? `Signed ${today(saved.at)}` : `Prepared ${today()}`} &nbsp;·&nbsp; Target: {fmt$(planTargetAmt)}{activeGoal === "retirement" ? "/yr" : ""} &nbsp;·&nbsp; Accounts: {acctLabels.join(", ")||"—"}
              </div>
            </div>

            <div className="doc-section">
              <div className="doc-section-label">Purpose</div>
              <div className="doc-body" style={{display:"flex",flexDirection:"column",gap:15}}>
                <EditableText blockId="purpose1" editCtl={editCtl} />
                {(form.lifestyleChoices||[]).length > 0 && <EditableText blockId="purpose2" editCtl={editCtl} />}
                {(form.lifestyle || (form.lifestyleChoices||[]).length > 0) && (
                  <ReplayLink step={1} goToStep={goToStep} title="Edit in wizard — Goals">
                    {form.lifestyle && <p><em>"{form.lifestyle}"</em></p>}
                    {(form.lifestyleChoices||[]).length > 0 && (() => {
                      const chosen = form.lifestyleChoices || [];
                      const activeGroups = LIFESTYLE_GROUPS
                        .map(g => ({ ...g, selected: g.items.filter(id => chosen.includes(id)) }))
                        .filter(g => g.selected.length > 0);
                      if (!activeGroups.length) return null;
                      return (
                        <div className="lifestyle-tag-groups">
                          {activeGroups.map(g => (
                            <div key={g.id} className="lifestyle-tag-group">
                              <div className="lifestyle-tag-group-header">
                                <i className={`${g.icon} lifestyle-tag-group-icon ti`} style={{color:g.color}} aria-hidden="true"/>
                                <span className="lifestyle-tag-group-label" style={{color:g.color}}>{g.label}</span>
                              </div>
                              <div className="lifestyle-tags">
                                {g.selected.map(id => {
                                  const opt = LIFESTYLE_OPTIONS.find(o=>o.id===id);
                                  return opt ? (
                                    <span key={id} className="lifestyle-tag"
                                      style={{background:g.tagBg,color:g.tagColor}}>
                                      {opt.label}
                                    </span>
                                  ) : null;
                                })}
                              </div>
                            </div>
                          ))}
                        </div>
                      );
                    })()}
                  </ReplayLink>
                )}
              </div>
              {docEdits.notes.filter(n=>n.section==="purpose").map(n=>
                <NoteBlock key={n.id} note={n} editCtl={editCtl} />)}
              <AddNoteZone section="purpose" editCtl={editCtl} />
            </div>

            <div className="doc-section">
              <div className="doc-section-label">Investment Goals</div>
              <div className="doc-body" style={{display:"flex",flexDirection:"column",gap:15}}>
                <EditableText blockId="goals1" editCtl={editCtl} />
                <EditableText blockId="goals2" editCtl={editCtl} />
                {activeGoal === "retirement" && parseFloat(form.estateTarget) > 0 && (
                  // Non-editable — same category as the withdrawals section's CPP/OAS
                  // paragraph: reports the wizard's estateTarget input verbatim. To a
                  // human reader this is a goal (what to leave behind), even though
                  // the projection engine only consumes it as a decumulation input —
                  // see the withdrawals section for the model-assumption paragraphs.
                  <ReplayLink step={1} goToStep={goToStep} title="Edit in wizard — Goals">
                    <p style={{margin:0}}>I've chosen to leave about {fmt$(Math.round(parseFloat(form.estateTarget)))} (in today's dollars) behind — the plan protects that amount in roughly 9 out of 10 market outcomes, and my spending above it flexes up in strong markets and down in weak ones.</p>
                  </ReplayLink>
                )}
              </div>
              {docEdits.notes.filter(n=>n.section==="goals").map(n=>
                <NoteBlock key={n.id} note={n} editCtl={editCtl} />)}
              <AddNoteZone section="goals" editCtl={editCtl} />
            </div>

            {(() => {
              // projView/hasProj come from the outer case-6 scope (hoisted for
              // the shortfall banner above) — only falls through to the
              // client-side computeIpsMC placeholder while the preview is
              // still in flight and nothing persisted exists either.
              const formBal  = Object.values(form.accountBalances || {}).reduce((s,v) => s+(parseFloat(v)||0), 0);
              const retireYr = parseInt(form.retireYear) || 0;
              const currentYear = new Date().getFullYear();
              const horizon = retireYr > currentYear + 1 ? retireYr - currentYear : 0;
              if (!hasProj && (horizon < 2 || (formBal <= 0 && !(parseFloat(form.annualSavings) > 0)))) return null;
              const contrib  = parseFloat(form.annualSavings) || 0;
              const totalBal = hasProj ? projView.current_value : formBal;
              const fiNum = activeGoal !== "retirement" ? null
                : hasProj ? projView.target
                : (parseFloat(form.targetIncome) > 0 ? parseFloat(form.targetIncome) * 25 : null);
              // Computed once and shared by both the intro sentence and the chart
              // below it, so the stated terminal amount can never disagree with
              // the number the chart itself draws/annotates.
              const mu = (metrics.ret || 5) / 100;
              const sigma = (metrics.vol || 8) / 100;
              // computeIpsMC is unseeded (Math.random()) — cache it against a
              // signature of its own inputs so unrelated re-renders (e.g. hovering
              // an editable block elsewhere on the page) reuse the same run
              // instead of drawing a brand new random projection every time.
              // Only reached while the backend preview hasn't answered yet (or
              // failed) and there's no persisted projection either to fall back on.
              let mc;
              if (hasProj) {
                mc = projView;
              } else {
                const sig = `${activeGoal}|${totalBal}|${contrib}|${horizon}|${mu}|${sigma}`;
                if (_ipsMcCacheRef.current && _ipsMcCacheRef.current.sig === sig) {
                  mc = _ipsMcCacheRef.current.mc;
                } else {
                  mc = computeIpsMC(totalBal, contrib, horizon, mu, sigma);
                  _ipsMcCacheRef.current = { sig, mc };
                }
              }
              const termVal = mc.p50[mc.p50.length - 1];
              return (
                <div className="doc-section">
                  <div className="doc-section-label" style={{display:"flex",alignItems:"center",gap:10}}>
                    <ReplayLink step={1} goToStep={goToStep} title="Edit in wizard — Goals" compact>
                      Savings Projection
                    </ReplayLink>
                  </div>
                  <div className="doc-body">
                    <p style={{marginBottom:10}}>
                      Starting from <strong>{fmt$(totalBal)}</strong> across my accounts, I plan to
                      {contrib > 0 ? <> save <strong>{fmt$(contrib)}/year</strong></> : <> keep it invested</>}
                      {" "}in a <strong>{metrics.label}</strong> portfolio over the next <strong>{horizon} years</strong> to
                      reach <strong>{fmtCompact(termVal)}</strong> (in today's dollars):
                    </p>
                    <IpsProjectionChart form={form} metrics={metrics} goal={activeGoal} projection={projView} mc={mc} />
                    <div style={{display:"flex",gap:18,marginTop:8,flexWrap:"wrap",alignItems:"center"}}>
                      <span style={{display:"flex",alignItems:"center",gap:5,fontSize:11,color:"#647071",fontFamily:"'IBM Plex Mono',monospace"}}>
                        <svg width="22" height="8" viewBox="0 0 22 8"><path d="M0 4 L22 4" stroke="#f5a623" strokeWidth="1.5"/></svg>
                        Median outcome
                      </span>
                      <span style={{display:"flex",alignItems:"center",gap:5,fontSize:11,color:"#647071",fontFamily:"'IBM Plex Mono',monospace"}}>
                        <svg width="22" height="8" viewBox="0 0 22 8"><rect x="0" y="1" width="22" height="6" fill="#f5a623" fillOpacity="0.12" rx="1"/></svg>
                        Middle 50% of simulations
                      </span>
                      {fiNum && (
                        <span style={{display:"flex",alignItems:"center",gap:5,fontSize:11,color:"#1a4a6b",fontFamily:"'IBM Plex Mono',monospace"}}>
                          <svg width="22" height="8" viewBox="0 0 22 8"><line x1="0" y1="4" x2="22" y2="4" stroke="#1a4a6b" strokeWidth="1" strokeDasharray="4 3"/></svg>
                          Plan target
                        </span>
                      )}
                    </div>
                    <p style={{fontSize:11.5,color:"#647071",marginTop:6,lineHeight:1.5}}>
                      {!hasProj
                        ? "Preview from 2,000 simulations using lognormal annual returns — save this plan to see the full projection."
                        : previewActive
                        ? "Monte-Carlo simulation of correlated stock/bond returns, shown in today's dollars (inflation-adjusted). This reflects your unsaved changes — save this plan to make it official."
                        : "Monte-Carlo simulation of correlated stock/bond returns, shown in today's dollars (inflation-adjusted)."}
                      {" "}Not a forecast — actual outcomes vary with market conditions and changes to contributions.
                    </p>
                  </div>
                </div>
              );
            })()}

            <div className="doc-section">
              <div className="doc-section-label" style={{display:"flex",alignItems:"center",gap:10}}>
                Climate Risk
                {form.climateOn && (
                  <ReplayLink step={3} goToStep={goToStep} title="Edit in wizard — Climate" compact>
                    <span className="climate-badge-inline">Climate-aware</span>
                  </ReplayLink>
                )}
              </div>
              <div className="doc-body" style={{display:"flex",flexDirection:"column",gap:15}}>
                <EditableText blockId="climate1" editCtl={editCtl} />
                <EditableText blockId="climate2" editCtl={editCtl} />
              </div>
              {docEdits.notes.filter(n=>n.section==="climate").map(n=>
                <NoteBlock key={n.id} note={n} editCtl={editCtl} />)}
              <AddNoteZone section="climate" editCtl={editCtl} />
            </div>

            <div className="doc-section">
              <div className="doc-section-label">Asset Allocation</div>
              <div className="doc-body" style={{display:"flex",flexDirection:"column",gap:15}}>
                <EditableText blockId="alloc1" editCtl={editCtl} />
                {effectiveMetrics.holdings && effectiveMetrics.holdings.length > 0 && (
                  <>
                    <p style={{margin:0}}>Here's a sample portfolio that fits this mix{form.climateOn ? ", using eco-friendly funds where available" : ""}. These funds cost about <strong>{pct(effectiveMetrics.mer,2)}/year</strong> in fees and pay about <strong>{pct(effectiveMetrics.yld,2)}/year</strong> in income:</p>
                    <EditableHoldings editCtl={editCtl} metrics={effectiveMetrics} />
                    <p style={{fontSize:12,color:"#647071",margin:0,lineHeight:1.5}}>These are examples only — not a recommendation to buy any specific fund. This is general information, not financial advice.</p>
                  </>
                )}
                <EditableText blockId="alloc2" editCtl={editCtl} />
              </div>
              {docEdits.notes.filter(n=>n.section==="alloc").map(n=>
                <NoteBlock key={n.id} note={n} editCtl={editCtl} />)}
              <AddNoteZone section="alloc" editCtl={editCtl} />
            </div>

            {assetLocation().length > 0 && (
              <div className="doc-section">
                <div className="doc-section-label">Where I Hold What</div>
                <div className="doc-body" style={{display:"flex",flexDirection:"column",gap:15}}>
                  <EditableText blockId="whereIntro" editCtl={editCtl} />
                  <table className="doc-table">
                    <thead><tr><th>Account</th><th>What goes here and why</th></tr></thead>
                    <tbody>
                      {assetLocation().map((r,i)=>(
                        <tr key={i}><td><strong>{r.acct}</strong></td><td>{r.strat}</td></tr>
                      ))}
                    </tbody>
                  </table>
                </div>
                {docEdits.notes.filter(n=>n.section==="where").map(n=>
                  <NoteBlock key={n.id} note={n} editCtl={editCtl} />)}
                <AddNoteZone section="where" editCtl={editCtl} />
              </div>
            )}

            {withdrawalOrder().length > 1 && (
              <div className="doc-section">
                <div className="doc-section-label">{activeGoal === "retirement" ? "Retirement Withdrawals" : "Order to Withdraw"}</div>
                <div className="doc-body" style={{display:"flex",flexDirection:"column",gap:15}}>
                  <p style={{margin:0}}>When I start taking money out, I'll draw from my accounts in this order to pay the least tax over my lifetime:</p>
                  <EditableText blockId="withdraw" editCtl={editCtl} />
                  {activeGoal === "retirement" && (() => {
                    // Same shared decumulation bundle the Save tab's AccountBalancesChart
                    // renders (savedByGoal[goal].projection.decumulation) — except while
                    // previewActive (never saved, or saved but locally edited, e.g. a
                    // changed estate target or a moved CPP/OAS start age), where the
                    // persisted bucket series is exactly what no longer matches the
                    // wizard and the non-persisted preview fetched above belongs on
                    // screen instead. Falls back to the persisted series if the preview
                    // hasn't answered yet, so the chart doesn't blank out mid-debounce.
                    const decum = (previewActive && previewProj?.projection?.decumulation)
                      || (savedByGoal[activeGoal] || {}).projection?.decumulation;
                    if (!decum || !decum.ages || decum.ages.length < 2) return null;
                    return (
                      <div>
                        <p style={{margin:"0 0 8px"}}>
                          Here's how my account balances are projected to draw down over retirement, in today's dollars
                          {previewActive ? " (reflecting my unsaved changes)" : ""}:
                        </p>
                        <AccountBalancesChart decum={decum} />
                      </div>
                    );
                  })()}
                  {form.accounts.includes("rrsp") && activeGoal === "retirement" && (
                  <ReplayLink step={2} goToStep={goToStep} title="Edit in wizard — Accounts">
                  {(() => {
                    // Reports the CPP/OAS start ages & amounts actually chosen in
                    // the wizard (non-editable — the wizard inputs are the source
                    // of truth). Handles a zeroed-out benefit (e.g. immigration).
                    const cppA = parseInt(form.cppStartAge) || 65;
                    const oasA = parseInt(form.oasStartAge) || 65;
                    const cppAmt = Math.round(parseFloat(form.cppAnnual) || 0);
                    const oasAmt = Math.round(parseFloat(form.oasAnnual) || 0);
                    if (cppAmt <= 0 && oasAmt <= 0) {
                      return <p style={{margin:0}}>My plan assumes I won't receive CPP or OAS, so I'll fund my retirement entirely from my own savings — drawing from my accounts in the order above to keep my lifetime tax bill as low as I can.</p>;
                    }
                    if (cppA === 70 && oasA === 70 && cppAmt > 0 && oasAmt > 0) {
                      return <p style={{margin:0}}>Taking RRSP income before 70 lets me delay CPP and OAS. Waiting until 70 to collect those benefits locks in a much higher payment for life — which really adds up over a long retirement.</p>;
                    }
                    let whenStr;
                    if (cppAmt > 0 && oasAmt > 0 && cppA === oasA) {
                      whenStr = `CPP and OAS at age ${cppA}`;
                    } else {
                      const ps = [];
                      if (cppAmt > 0) ps.push(`CPP at age ${cppA}`);
                      if (oasAmt > 0) ps.push(`OAS at age ${oasA}`);
                      whenStr = ps.join(" and ");
                    }
                    const amtParts = [];
                    if (cppAmt > 0) amtParts.push(`${fmt$(cppAmt)}/yr from CPP`);
                    if (oasAmt > 0) amtParts.push(`${fmt$(oasAmt)}/yr from OAS`);
                    const amtStr = ` — about ${amtParts.join(" and ")} in today's dollars`;
                    return <p style={{margin:0}}>My plan assumes I start {whenStr}{amtStr}. I'll draw from my RRSP and other accounts to bridge my income until then, keeping my overall tax bill as low as I can across retirement.</p>;
                  })()}
                  </ReplayLink>
                  )}
                  {activeGoal === "retirement" && (() => {
                    // Reports the wizard's pension input verbatim (non-editable —
                    // same category as the CPP/OAS paragraph above). Gated on a
                    // positive amount rather than on the RRSP account, so a
                    // pension-funded retiree with no RRSP still sees it stated.
                    const pAmt = Math.round(parseFloat(form.pensionAnnual) || 0);
                    if (pAmt <= 0) return null;
                    const pAge = parseInt(form.pensionStartAge) || 65;
                    return (
                      <ReplayLink step={2} goToStep={goToStep} title="Edit in wizard — Accounts">
                        <p style={{margin:0}}>From age {pAge} I expect about {fmt$(pAmt)}/yr (in today's dollars) from a workplace pension or annuity. That's regular taxable income, so my plan counts it toward my tax bill and my OAS clawback the same way it counts CPP — and it reduces how much I need to withdraw from my own accounts each year.</p>
                      </ReplayLink>
                    );
                  })()}
                  {activeGoal === "retirement" && (() => {
                    // Three fixed model-assumption statements (non-editable — same
                    // category as the CPP/OAS paragraph above: they report what the
                    // projection engine assumes, not choices the user can reword).
                    // The "I've chosen to leave $X behind" statement itself lives in
                    // the Investment Goals section above — it's a goal to a human
                    // reader, even though estateTarget only reaches the engine as a
                    // decumulation input.
                    const estateAmt = Math.round(parseFloat(form.estateTarget) || 0);
                    const spendClause = estateAmt > 0
                      ? `above the ${fmt$(estateAmt)} I've set aside`
                      : "over my lifetime";
                    // Deliberately no sentence naming the withdrawal order here.
                    // One existed while the engine auto-selected the order, and
                    // it claimed the plan "compares them and uses whichever
                    // leaves me the most to spend after tax" — a claim the
                    // engine no longer makes, and should not: the order is an
                    // input, and choosing it well needs household-level context
                    // this model does not have. The AccountBalancesChart
                    // subtitle already names the order in use, which is a
                    // statement of fact rather than of optimality. Do not
                    // reintroduce optimality copy without an engine that can
                    // actually justify it.
                    return (
                      <>
                        <p style={{margin:0}}>These retirement numbers assume that once I retire, I shift to a more conservative mix — fewer stocks, more bonds — so a bad stretch of markets early in retirement can't derail the plan. My saving years can ride out market swings; my spending years shouldn't have to.</p>
                        <p style={{margin:0}}>My savings are meant to be used to support a meaningful life. Instead of locking in one fixed withdrawal for life, I'll revisit the plan regularly: when markets have been kind I can comfortably spend more to fund my goals, and when they've lagged I'll trim back. Adjusting as I go lets my savings support the years I can enjoy them most, and means my savings are fully used {spendClause} rather than left as an unplanned surplus at the end.</p>
                        <p style={{margin:0}}>This plan is funded through age 96 — further than most Canadians will need. My CPP and OAS don't stop there: they're guaranteed and inflation-protected for as long as I live, which is why this plan leans on them as the foundation of my later years.</p>
                      </>
                    );
                  })()}
                </div>
                {docEdits.notes.filter(n=>n.section==="withdraw").map(n=>
                  <NoteBlock key={n.id} note={n} editCtl={editCtl} />)}
                <AddNoteZone section="withdraw" editCtl={editCtl} />
              </div>
            )}

            <div className="doc-section">
              <div className="doc-section-label">Staying the Course</div>
              <div className="doc-body" style={{display:"flex",flexDirection:"column",gap:15}}>
                <EditableText blockId="discipline1" editCtl={editCtl} />
                <EditableText blockId="discipline2" editCtl={editCtl} />
              </div>
              {docEdits.notes.filter(n=>n.section==="discipline").map(n=>
                <NoteBlock key={n.id} note={n} editCtl={editCtl} />)}
              <AddNoteZone section="discipline" editCtl={editCtl} />
            </div>

            <div className="doc-section">
              <div className="doc-section-label">How I'll Measure Progress</div>
              <div className="doc-body">
                <EditableText blockId="measure1" editCtl={editCtl} />
              </div>
              {docEdits.notes.filter(n=>n.section==="measure").map(n=>
                <NoteBlock key={n.id} note={n} editCtl={editCtl} />)}
              <AddNoteZone section="measure" editCtl={editCtl} />
            </div>

            <div className="doc-section">
              <div className="doc-section-label">My Rules</div>
              <div className="doc-body">
                <EditableText blockId="rules" editCtl={editCtl} />
              </div>
              {docEdits.notes.filter(n=>n.section==="rules").map(n=>
                <NoteBlock key={n.id} note={n} editCtl={editCtl} />)}
              <AddNoteZone section="rules" editCtl={editCtl} />
            </div>

            {(form.additionalInfo||"").trim() && (
              <div className="doc-section">
                <div className="doc-section-label">Additional Information</div>
                <div className="doc-body">
                  <p>{form.additionalInfo}</p>
                </div>
              </div>
            )}

            {saved && <PlanReviewBand goal={activeGoal} user={user} saved={saved} form={form} previewActive={previewActive} />}

            <div className="doc-sig" id="doc-sig">
              <div className="doc-sig-note">By signing, I'm committing to follow this plan as my default approach to investing. I'll come back to it at each scheduled review and update it whenever my life meaningfully changes.</div>
              <div className="doc-sig-lines">
                <div className="doc-sig-field">
                  <div style={{minHeight:40,display:"flex",alignItems:"flex-end"}}>
                    {saved && <div className="doc-sig-name">{form.name || user.name || ""}</div>}
                  </div>
                  <div className="doc-sig-underline"/>
                  Signature
                </div>
                <div className="doc-sig-field">
                  <div style={{minHeight:40,display:"flex",alignItems:"flex-end"}}>
                    {saved && <div className="doc-sig-date">{ts(saved.at)}</div>}
                  </div>
                  <div className="doc-sig-underline"/>
                  Date
                </div>
              </div>
            </div>
          </div>

          <div className="doc-actions">
            <button className="sp-btn sp-btn-primary" onClick={persist} disabled={saveState==="saving"}>
              {saveState==="saving" ? "Saving…" : "Accept Plan"}
            </button>
            {/* TODO: replace with /api/ips/:id/pdf (server-side PDF) */}
            <button className="sp-btn" onClick={()=>window.print()}>Print / Save as PDF</button>
            <button className="sp-btn" onClick={()=>onSwitchTab("portfolio")} disabled={!saved}>View Savings →</button>
            <button className="sp-btn" onClick={goToHub}>← All plans</button>
            {saveState==="error" && (
              <span className="doc-saved" style={{color:"#b34030"}}>Save failed — check your connection</span>
            )}
          </div>
        </div>
        );
      }

      default: return null;
    }
  };

  if (!loaded) return <div className="sp-splash">Loading your plan…</div>;

  return (
    <>
      {clearConfirmGoal && (() => {
        const goalLabel = clearConfirmGoal === "other" && (savedByGoal["other"]?.form?.goalName?.trim() || savedByGoal["other"]?.form?.goalPreset)
          ? (savedByGoal["other"].form.goalName.trim() || savedByGoal["other"].form.goalPreset)
          : GOAL_TYPES.find(g=>g.id===clearConfirmGoal)?.label;
        const isSaved = !!(savedByGoal[clearConfirmGoal] && savedByGoal[clearConfirmGoal].form);
        return (
          <div className="sp-overlay">
            <div className="sp-modal">
              <div className="sp-modal-title">{isSaved ? "Clear this plan?" : "Discard this draft?"}</div>
              <p className="sp-modal-body">
                {isSaved
                  ? <>Your saved plan for <strong>{goalLabel}</strong> will be permanently deleted. You'll need to start over from scratch.</>
                  : <>Your in-progress draft for <strong>{goalLabel}</strong> will be discarded — it was never saved. You'll need to start over from scratch.</>}
              </p>
              <div className="sp-modal-actions">
                <button className="sp-btn" onClick={()=>setClearConfirmGoal(null)}>Cancel</button>
                <button className="sp-btn sp-btn-danger" onClick={()=>handleClear(clearConfirmGoal)}>{isSaved ? "Clear plan" : "Discard draft"}</button>
              </div>
            </div>
          </div>
        );
      })()}
      <div className={"sp-root" + (step === STEPS.length ? " sp-doc-print" : "")}>

        {/* Nav / progress */}
        <nav className="sp-nav">
          <div className="sp-nav-inner">
            <a className="sp-logo" href="#" onClick={e=>{ e.preventDefault(); if(step>0){ goToHub(); } }}
              style={step>0?{cursor:"pointer"}:{}}>
              {logoIcon("plan")}<span className="sp-logo-text">SavingsPhase</span>
            </a>
            {onSwitchTab && (
              <div className="sp-section-tabs">
                <button className="sp-section-tab active" onClick={goToHub}>Plan</button>
                <button className="sp-section-tab" onClick={() => onSwitchTab("portfolio")}>Save</button>
                <button className="sp-section-tab" onClick={() => onSwitchTab("dashboard")}>Track</button>
              </div>
            )}
            <div className="sp-nav-end">
              <button className="sp-help-btn" onClick={() => openHelp(null)} title="Help">?</button>
              <UserMenu user={user} onLogout={onLogout} onUserUpdated={onUserUpdated} onNavigate={onNavigate} />
              <MobileNavDrawer
                activeTab="plan"
                onPlanClick={goToHub}
                onSwitchTab={onSwitchTab}
                onHelp={() => openHelp(null)}
                onNavigate={onNavigate}
                onLogout={onLogout}
                user={user}
              />
            </div>
          </div>
        </nav>

        {/* Wizard step rail */}
        {step > 0 && (
          <WizardSubNav step={step} canReachStep={canReachStep} setStep={setStep}
            goalLabel={activeGoalLabel} planStatus={planStatus} />
        )}

        {/* Content */}
        <div className={"sp-page" + (step === 0 ? " pl-page" : "")}>
          {renderStep()}
          {step > 0 && step < STEPS.length && (
            <div className="sp-btn-row">
              <button className="sp-btn" onClick={()=>{
                if(step===1){ goToHub(); } else { setStep(s=>s-1); }
              }}>← Back</button>
              <button
                className="sp-btn sp-btn-primary"
                disabled={!canProceed() || saveState==="saving"}
                onClick={()=>{ setStep(s=>s+1); }}
              >
                {step===INPUT_STEPS ? "Review Plan →" : "Continue →"}
              </button>
            </div>
          )}
          {step === 4 && (
            <div style={{marginTop:16}}>
              <div className="alloc-dd-note">
                In a severe market downturn (2008-level), a {String(metrics.label).toLowerCase()} portfolio typically draws down <strong>{metrics.dd8}</strong>. Can you hold through that without changing course?
              </div>
              <div className="alloc-disclaimer">
                Expected return is a nominal, 10–20 year estimate from the SavingsPhase asset registry, after fund fees (MER) — not a guarantee{metrics.source === "assets_csv" ? " (offline registry copy)" : ""}.
              </div>
            </div>
          )}
        </div>
        <SiteFooter />
      </div>
    </>
  );
}

// ── Shared nav for Portfolio / Dashboard / Profile / Alerts / Help pages ──────
function PageNav({ activeTab, onSwitchTab, user, onLogout, onUserUpdated, onNavigate }) {
  return (
    <nav className="sp-nav">
      <div className="sp-nav-inner">
        <a className="sp-logo" href="#" onClick={e => { e.preventDefault(); onSwitchTab("plan"); }}>
          {logoIcon(activeTab)}<span className="sp-logo-text">SavingsPhase</span>
        </a>
        <div className="sp-section-tabs">
          <button className={`sp-section-tab${activeTab === "plan" ? " active" : ""}`}
            onClick={() => onSwitchTab("plan")}>Plan</button>
          <button className={`sp-section-tab${activeTab === "portfolio" ? " active" : ""}`}
            onClick={() => onSwitchTab("portfolio")}>Save</button>
          <button className={`sp-section-tab${activeTab === "dashboard" ? " active" : ""}`}
            onClick={() => onSwitchTab("dashboard")}>Track</button>
        </div>
        <div className="sp-nav-end">
          <button className="sp-help-btn" onClick={() => onNavigate("help", { tab: activeTab || "plan" })} title="Help">?</button>
          <UserMenu user={user} onLogout={onLogout} onUserUpdated={onUserUpdated} onNavigate={onNavigate} />
          <MobileNavDrawer
            activeTab={activeTab}
            onPlanClick={() => onSwitchTab("plan")}
            onSwitchTab={onSwitchTab}
            onHelp={() => onNavigate("help", { tab: activeTab || "plan" })}
            onNavigate={onNavigate}
            onLogout={onLogout}
            user={user}
          />
        </div>
      </div>
    </nav>
  );
}

// ── Portfolio tab — overview design ──────────────────────────────────────────

function AllocationBar({ allocation }) {
  const total = allocation.reduce((s, a) => s + a.current_pct, 0) || 100;
  return (
    <div className="pf-stack-bar">
      {allocation.map(a => (
        <div key={a.asset_class}
          className="pf-stack-seg"
          style={{width:`${(a.current_pct / total * 100).toFixed(2)}%`, background: classColor(a.asset_class)}}
          title={`${AC_LABELS[a.asset_class] || a.asset_class}: ${a.current_pct.toFixed(1)}%`}
        />
      ))}
    </div>
  );
}

function AllocVsPlanView({ allocation }) {
  const data = (allocation || [])
    .filter(a => a.current_pct > 0 || a.target_pct > 0)
    .slice().sort((a, b) => Math.abs(b.drift) - Math.abs(a.drift));
  if (!data.length) return (
    <div style={{padding:"16px 0",color:"#647071",fontSize:12.5}}>No allocation data to display.</div>
  );
  const LBL_W = 120, BAR_L = 130, BAR_W = 300, ROW_H = 48, TOP_PAD = 8, AXIS_H = 26, ANNO_W = 164;
  const VB_W = BAR_L + BAR_W + ANNO_W;
  const allPct = data.flatMap(a => [a.current_pct, a.target_pct]);
  const scaleMax = Math.ceil(Math.max(...allPct, 5) / 5) * 5;
  const px = BAR_W / scaleMax;
  const svgH = TOP_PAD + data.length * ROW_H + AXIS_H;
  const tickStep = scaleMax <= 25 ? 5 : scaleMax <= 50 ? 10 : scaleMax <= 75 ? 15 : 20;
  const ticks = [];
  for (let v = 0; v <= scaleMax; v += tickStep) ticks.push(v);
  return (
    <svg viewBox={`0 0 ${VB_W} ${svgH}`} width="100%" height={svgH}
      style={{display:"block",fontFamily:"'IBM Plex Mono',monospace",overflow:"visible"}}>
      {/* Grid lines + axis labels */}
      {ticks.map(v => {
        const x = BAR_L + v * px;
        return (
          <g key={v}>
            <line x1={x} y1={TOP_PAD - 4} x2={x} y2={TOP_PAD + data.length * ROW_H + 4}
              stroke={v === 0 ? "#cfc7b8" : "#ece6da"} strokeWidth={v === 0 ? 1 : 0.75} />
            <text x={x} y={TOP_PAD + data.length * ROW_H + AXIS_H - 8}
              textAnchor="middle" fontFamily="'IBM Plex Mono',monospace" fontSize={10} fill="#a0998c">
              {v}%
            </text>
          </g>
        );
      })}
      {/* Rows */}
      {data.map((a, i) => {
        const y = TOP_PAD + i * ROW_H;
        const aPct = a.current_pct, tPct = a.target_pct;
        const drift = aPct - tPct;
        const barEnd = BAR_L + aPct * px;
        const tickX  = BAR_L + tPct * px;
        const midY   = y + ROW_H / 2;
        const absD   = Math.abs(drift);
        const onTrack = absD < 1;
        const over   = drift > 0;
        const gapCol = onTrack ? "#9aa0a8" : over ? "#c2571a" : "#b34030";
        const gapTextCol = onTrack ? "#9aa0a8" : over ? "#a1450f" : "#b34030";
        const label  = AC_LABELS[a.asset_class] || a.asset_class;
        const annoX  = Math.max(barEnd, tickX) + 10;
        return (
          <g key={a.asset_class}>
            {/* Row label */}
            <text x={LBL_W - 4} y={midY + 4} textAnchor="end"
              fontSize={13} fill="#1a1a1a" fontWeight={500}
              fontFamily="system-ui,sans-serif">{label}</text>
            {/* Background track */}
            <rect x={BAR_L} y={midY - 8} width={BAR_W} height={16} rx={2} fill="#f0ebe1" />
            {/* Actual bar */}
            <rect x={BAR_L} y={midY - 8} width={Math.max(aPct * px, 0)} height={16} rx={2}
              fill={classColor(a.asset_class)} />
            {/* Gap dotted line */}
            {!onTrack && (() => {
              const x1 = Math.min(barEnd, tickX), x2 = Math.max(barEnd, tickX);
              return <line x1={x1} y1={midY} x2={x2} y2={midY}
                stroke={gapCol} strokeWidth={2} strokeDasharray="2 2" opacity={0.85} />;
            })()}
            {/* Target tick */}
            <line x1={tickX} y1={midY - 13} x2={tickX} y2={midY + 13}
              stroke="#1a1a1a" strokeWidth={2} />
            {/* Annotation */}
            <text x={annoX} y={midY - 2} fontSize={11} fill="#5a6a72"
              fontFamily="'IBM Plex Mono',monospace">
              {`${aPct.toFixed(0)}% → ${tPct.toFixed(0)}%`}
            </text>
            <text x={annoX} y={midY + 13} fontSize={11} fill={gapTextCol} fontWeight={700}
              fontFamily="'IBM Plex Mono',monospace">
              {onTrack ? "on plan" : (over ? "+" : "−") + absD.toFixed(1) + "pp " + (over ? "over" : "under")}
            </text>
          </g>
        );
      })}
    </svg>
  );
}

function AllocMixView({ allocation }) {
  const active = allocation.filter(a => a.current_pct > 0);
  if (!active.length) return (
    <div style={{padding:"16px 0",color:"#647071",fontSize:12.5}}>No current holdings to display.</div>
  );
  const donutData = active.map(a => ({ weight: a.current_pct / 100, asset_class: a.asset_class, ticker: a.asset_class }));
  return (
    <div className="pf-mix-wrap">
      <Donut holdings={donutData} size={200} thickness={38} />
      <div className="pf-mix-legend">
        {active.map(a => (
          <div key={a.asset_class} className="pf-mix-leg-row">
            <span className="pf-mix-leg-name">
              <span className="pf-alloc-dot" style={{background: classColor(a.asset_class)}}/>
              {AC_LABELS[a.asset_class] || a.asset_class}
            </span>
            <span className="pf-mix-leg-pct">{a.current_pct.toFixed(1)}%</span>
          </div>
        ))}
      </div>
    </div>
  );
}

function AllocByAccountView({ allocation, goalTotal }) {
  const CLS_ORDER = ["Fixed Income","Developed Markets","Canadian Equity",
                     "Emerging Markets","Preferred Shares","Cash","Alternatives"];
  const ord = c => { const i = CLS_ORDER.indexOf(c); return i < 0 ? 99 : i; };
  const LOC_COL = { RRSP: "#6c3483", TFSA: "#1a8ba3", Taxable: "#d4880e" };

  const buckets = [
    { key: "RRSP",    field: "rrsp_amt" },
    { key: "TFSA",    field: "tfsa_amt" },
    { key: "Taxable", field: "taxable_amt" },
  ];

  const total = goalTotal || (allocation || []).reduce((s, a) =>
    s + (a.rrsp_amt || 0) + (a.tfsa_amt || 0) + (a.taxable_amt || 0), 0);

  const presentCls = new Set();
  const rows = buckets.map(({ key, field }) => {
    const bktTotal = (allocation || []).reduce((s, a) => s + (a[field] || 0), 0);
    if (bktTotal <= 0) return null;
    const slices = (allocation || [])
      .filter(a => (a[field] || 0) > 0)
      .map(a => { presentCls.add(a.asset_class); return { cls: a.asset_class, v: Math.round(a[field] / bktTotal * 100) }; })
      .sort((x, y) => ord(x.cls) - ord(y.cls));
    return { key, bktTotal, bktPct: total > 0 ? (bktTotal / total * 100).toFixed(0) : 0, slices };
  }).filter(Boolean);

  if (!rows.length) return (
    <div style={{padding:"16px 0",color:"#647071",fontSize:12.5}}>No account data available.</div>
  );

  const present = [...presentCls].sort((x, y) => ord(x) - ord(y));

  return (
    <div>
      <div className="stk-list">
        {rows.map(({ key, bktTotal, bktPct, slices }) => (
          <div key={key} className="stk-row">
            <div className="stk-head">
              <span className="stk-name" style={{color: LOC_COL[key]}}>{key}</span>
              <span className="stk-meta">{bktPct}% of portfolio · {fmtCompact(bktTotal)}</span>
            </div>
            <div className="stk-bar">
              {slices.map(s => (
                <div key={s.cls} className="stk-seg"
                  style={{flex:`${s.v} 1 0`, background: classColor(s.cls)}}
                  title={`${AC_LABELS[s.cls] || s.cls}: ${s.v}%`}>
                  {s.v >= 16 && <span className="stk-lbl">{s.v}%</span>}
                </div>
              ))}
            </div>
          </div>
        ))}
      </div>
      <div className="stk-legend">
        {present.map(c => (
          <span key={c} className="stk-leg">
            <span className="stk-dot" style={{background: classColor(c)}}/>
            {AC_LABELS[c] || c}
          </span>
        ))}
      </div>
    </div>
  );
}

// ── Holdings editor ───────────────────────────────────────────────────────────
// v2 "What do you hold?" — holdings-first entry: the user picks one of three
// paths (all cash / one all-in-one fund / individual funds), the system prices
// and classifies what they type, and Cash is always the auto-balanced
// remainder (bucket total − invested), never a value the user types directly.
// See save-tab.md § Holdings editor for the full behavioral spec.

// Maps IPS account IDs → DB column names and display labels.
// FHSA shares columns with TFSA (same tax treatment, same column).
const ACCT_COL_MAP = {
  rrsp:    { units: "rrsp_units",    amount: "rrsp_amount",    label: "RRSP" },
  tfsa:    { units: "tfsa_units",    amount: "tfsa_amount",    label: "TFSA" },
  fhsa:    { units: "tfsa_units",    amount: "tfsa_amount",    label: "FHSA" },
  taxable: { units: "taxable_units", amount: "taxable_amount", label: "Taxable" },
  srrsp:   { units: "srrsp_units",   amount: "srrsp_amount",   label: "Spousal RRSP" },
  resp:    { units: "resp_units",    amount: "resp_amount",    label: "RESP" },
};

// Deduplicated by DB column so FHSA+TFSA show as one column. Module-level
// (not a component-local IIFE) so both the load-time totals/path inference
// and the render path can share it without duplicating the reduction.
function goalAcctColsFor(accounts) {
  const list = accounts && accounts.length ? accounts : ["rrsp", "tfsa", "taxable"];
  const seen = new Set();
  return list.reduce((acc, acct) => {
    const m = ACCT_COL_MAP[acct];
    if (!m || seen.has(m.units)) return acc;
    seen.add(m.units);
    return [...acc, m];
  }, []);
}

// One holding's dollar value in one bucket. Works uniformly for security rows
// (units × price), custom rows, and the raw CASH row loaded from the backend
// (already flagged custom:true by holdings_edit_get) — no ticker special-case.
function holdingBucketValue(h, m) {
  if (!h.custom && h.price) return (h[m.units] || 0) * h.price;
  return h[m.amount] || 0;
}

const ACTUAL_ALLOC_EQUITY_CLASSES = new Set(["Canadian Equity", "Developed Markets", "Emerging Markets", "US Equity"]);
const ACTUAL_ALLOC_FIXED_CLASSES  = new Set(["Fixed Income", "Preferred Shares"]);

// Wizard § "Set my allocation based on existing Portfolio" — derives an
// equity % (and, when applicable, a single-all-in-one-fund detection) from a
// goal's real Save-tab holdings, using the same GET /api/portfolio/holdings-edit/<goal>
// response the Holdings modal itself reads (no new endpoint). Reuses
// goalAcctColsFor/holdingBucketValue from the holdings editor above.
//
// An all-in-one holding's own equity_weight_pct is used directly for the
// equity/fixed split — never decomposed further into Canada/developed/
// emerging sub-components (save-tab.md § same section: "the plan would only
// track this single holding"). Cash, Alternatives, and a custom holding
// manually classified "Multi-Asset" with no registry match are excluded from
// the ratio entirely (same treatment as CASH — there's no sound equity/fixed
// split for them). Returns null when there's nothing derivable (an empty or
// cash-only portfolio) so the caller knows not to render the sync button.
function computeActualAllocation(data) {
  const acctCols = goalAcctColsFor(data?.accounts);
  const tickerMap = {};
  (data?.tickers || []).forEach(t => { tickerMap[t.ticker] = t; });
  const nonCash = (data?.holdings || []).filter(h => h.ticker !== "CASH");
  if (!nonCash.length) return null;

  let equityDollars = 0, fixedDollars = 0;
  nonCash.forEach(h => {
    const v = acctCols.reduce((s, m) => s + holdingBucketValue(h, m), 0);
    if (!v) return;
    const info = !h.custom ? tickerMap[h.ticker] : null;
    if (h.asset_class === "Multi-Asset" && info?.equity_weight_pct != null) {
      equityDollars += v * (info.equity_weight_pct / 100);
      fixedDollars  += v * (1 - info.equity_weight_pct / 100);
    } else if (ACTUAL_ALLOC_EQUITY_CLASSES.has(h.asset_class)) {
      equityDollars += v;
    } else if (ACTUAL_ALLOC_FIXED_CLASSES.has(h.asset_class)) {
      fixedDollars += v;
    }
    // else: Cash-like custom holdings, Alternatives, or an unmatched custom
    // "Multi-Asset" holding — excluded from the ratio, same as CASH itself.
  });
  const total = equityDollars + fixedDollars;
  if (total <= 0) return null;

  const equityPct = Math.round((equityDollars / total * 100) / 5) * 5;
  const soleHolding = nonCash.length === 1 ? nonCash[0] : null;
  const soleInfo = soleHolding && !soleHolding.custom ? tickerMap[soleHolding.ticker] : null;
  const singleFund = (soleHolding && soleHolding.asset_class === "Multi-Asset" && soleInfo?.equity_weight_pct != null)
    ? { ticker: soleHolding.ticker, mer: soleInfo.mer_pct ?? null, yield: soleInfo.yield_pct ?? null }
    : null;

  return { equityPct: Math.max(0, Math.min(100, equityPct)), singleFund };
}

// Which DB bucket columns each wizard account card actually covers. The RRSP
// card owns the spousal column too — its own description reads "RRSP / Spousal
// RRSP / LIRA / RRIF", and _goal_bucket_mix (app.py) sums the same pair — while
// TFSA and FHSA genuinely SHARE one column (same tax treatment, one set of
// DB columns; see ACCT_COL_MAP). That sharing is why the funded view below
// groups by column rather than one row per card: rendering both cards off the
// same column would double-count the money and the rows would no longer sum to
// the portfolio total the section header states.
const ACCT_BUCKET_COLS = {
  rrsp:    ["rrsp", "srrsp"],
  tfsa:    ["tfsa"],
  fhsa:    ["tfsa"],
  taxable: ["taxable"],
  resp:    ["resp"],
};

// Accounts step, funded goal — turns the same GET /api/portfolio/holdings-edit/
// <goal> response computeActualAllocation reads into the read-only rows the
// wizard shows in place of the editable account cards (plan-tab.md
// § "Accounts on a funded goal"). One row per distinct bucket column, so the
// rows always sum to the goal's real portfolio value.
//
// A row is included when its column holds money OR the plan already names one
// of its accounts — a plan-selected account with nothing in it yet still
// belongs on screen (as $0), or the user would silently lose an account they
// deliberately put in their withdrawal order.
//
// `data` may be null (fetch in flight, or failed): balances come back as null
// ("—") and the rows are the plan's own accounts. That's deliberate — a funded
// goal must never fall back to the editable cards, not even for the few hundred
// ms of the fetch, or the dead controls flash on screen before locking.
function fundedAccountRows(data, availableAccounts, selectedIds) {
  const holdings = (data && Array.isArray(data.holdings)) ? data.holdings : null;
  const selected = new Set(selectedIds || []);
  const rows = [];
  const byKey = {};
  availableAccounts.forEach(a => {
    const cols = ACCT_BUCKET_COLS[a.id];
    if (!cols) return;
    const key = cols.join("+");
    if (byKey[key]) { byKey[key].accounts.push(a); return; }
    const balance = holdings === null ? null : cols.reduce((sum, col) => {
      const m = ACCT_COL_MAP[col];
      if (!m) return sum;
      return sum + holdings.reduce((s, h) => s + holdingBucketValue(h, m), 0);
    }, 0);
    byKey[key] = { key, accounts: [a], balance };
    rows.push(byKey[key]);
  });
  const kept = rows.filter(r => (r.balance || 0) > 0 || r.accounts.some(a => selected.has(a.id)));
  if (!kept.length) return null;
  return kept.map(r => {
    // Label off the accounts the plan actually names, so a home-goal user who
    // chose only TFSA doesn't get an FHSA they never asked for in the label.
    // Falls back to every account on the column when the plan names none.
    const named = r.accounts.filter(a => selected.has(a.id));
    const shown = named.length ? named : r.accounts;
    return {
      key: r.key,
      ids: r.accounts.map(a => a.id),
      label: shown.map(a => a.label).join(" / "),
      desc: shown.length === 1 ? shown[0].desc : shown.map(a => a.desc).join(" · "),
      balance: r.balance,
      // Which id represents this column in form.accounts when none is named yet.
      defaultId: r.accounts[0].id,
      named: named.map(a => a.id),
    };
  });
}

// Decomposes one holding's dollar value into the app's real asset classes, for
// the allocation-vs-plan bar only. An all-in-one fund (Multi-Asset with a
// curated equity_weight_pct) splits into Fixed Income + the 3 equity
// sub-classes via EQUITY_SLEEVE_SPLIT — the same proxy allocation.py uses
// everywhere else. Everything else (including a custom holding manually
// classified "Multi-Asset") is 100% its own class — the plan's target has no
// "Multi-Asset" line item, so an all-in-one fund would otherwise never
// reconcile against it.
function holdingsClassBreakdown(assetClass, equityWeightPct, value) {
  if (!value) return {};
  if (assetClass === "Multi-Asset" && equityWeightPct != null) {
    const eq = equityWeightPct / 100;
    const out = { "Fixed Income": value * (1 - eq) };
    Object.keys(EQUITY_SLEEVE_SPLIT).forEach(k => {
      out[k] = value * eq * EQUITY_SLEEVE_SPLIT[k];
    });
    return out;
  }
  return { [assetClass || "Other"]: value };
}

// Live derived state for the editor: per-bucket invested/cash, over-allocated
// buckets, and per-class dollar totals for the allocation bar. Never stored —
// recomputed every render from `rows`+`totals` — so there's no synthetic CASH
// row to keep in sync on every keystroke (materialized only inside save()).
//
// `targetByClass` decides whether a Multi-Asset holding gets decomposed for
// the comparison: normally yes (so an all-in-one fund can be compared against
// a plan whose target is expressed in real Canada/developed/emerging/fixed
// terms). But when the plan's own target is itself ~100% Multi-Asset — i.e.
// the wizard's "Set my allocation based on existing Portfolio" single-fund
// override (plan-tab.md § same name) — decomposing the current holding would
// spread it across 4 classes while the target sits in one, producing a
// nonsensical "everything is off-plan" reading for the one case where current
// and target should show as a clean match. Keep it as a lump "Multi-Asset"
// bucket instead, matching the target's own units, whenever
// `targetByClass["Multi-Asset"]` is itself significant (≥50%).
function computeHoldingsAlloc(rows, totals, acctCols, tickerMap, targetByClass) {
  const keepMultiAssetLump = (targetByClass?.["Multi-Asset"] || 0) >= 50;
  const investedBy = {}, cashByBucket = {}, over = [], byClass = {};
  acctCols.forEach(m => { investedBy[m.units] = 0; });
  rows.forEach(h => {
    const info = !h.custom ? tickerMap[h.ticker] : null;
    const eqPct = info ? info.equity_weight_pct : null;
    acctCols.forEach(m => {
      const v = holdingBucketValue(h, m);
      if (!v) return;
      investedBy[m.units] += v;
      const bd = (h.asset_class === "Multi-Asset" && keepMultiAssetLump)
        ? { "Multi-Asset": v }
        : holdingsClassBreakdown(h.asset_class, eqPct, v);
      Object.keys(bd).forEach(k => { byClass[k] = (byClass[k] || 0) + bd[k]; });
    });
  });
  let total = 0, invested = 0, cash = 0;
  acctCols.forEach(m => {
    const t = totals[m.units] || 0;
    const c = t - investedBy[m.units];
    total += t; invested += investedBy[m.units];
    cash += Math.max(c, 0);
    cashByBucket[m.units] = c;
    if (c < -0.5) over.push(m.label);
  });
  return { investedBy, cashByBucket, over, total, invested, cash, byClass };
}

// Ticker-level diff for a bulk replace (a CSV import replaces `rows` wholesale,
// same as picking a new all-in-one fund) — added / changed / removed vs. the
// last-saved DB state, for the CSV import review banner. Compares only the
// ACTIVE mode's own columns (units for a unit-priced row, amounts for a
// custom one) plus class/economics — not the derived amount a unit-priced row
// also carries, which is recomputed from the live price on every load and
// would otherwise flag "changed" on pure market movement.
function diffHoldingsRows(savedRows, rows) {
  const rowKey = h => {
    const cols = h.custom
      ? ["rrsp_amount", "tfsa_amount", "taxable_amount", "srrsp_amount", "resp_amount"]
      : ["rrsp_units", "tfsa_units", "taxable_units", "srrsp_units", "resp_units"];
    return [
      !!h.custom, h.asset_class || "",
      ...cols.map(c => h[c] ?? ""),
      h.custom_return_pct ?? "", h.custom_mer_pct ?? "", h.custom_income_type ?? "",
    ].join("|");
  };
  const savedMap = {};
  savedRows.forEach(r => { savedMap[r.ticker] = r; });
  const nowSet = new Set(rows.map(r => r.ticker));
  const added = [], changed = [];
  rows.forEach(r => {
    const prev = savedMap[r.ticker];
    if (!prev) { added.push(r.ticker); return; }
    if (rowKey(prev) !== rowKey(r)) changed.push(r.ticker);
  });
  const removed = savedRows.filter(r => !nowSet.has(r.ticker)).map(r => r.ticker);
  return { added, changed, removed };
}

// Download half of the CSV round trip (save-tab.md § Portfolio CSV
// round-trip) — a plain authenticated GET, so this is a raw fetch + Blob
// rather than the JSON-only `api()` helper. Lives at module scope (not
// inside SavingsModuleCard) since it needs nothing but `goal` and is also
// the natural reuse point if a second entry point ever wants it.
async function downloadHoldingsCsv(goal) {
  const res = await fetch(`/api/portfolio/holdings-csv/${goal}`, { credentials: "include" });
  if (!res.ok) {
    let msg = "Download failed";
    try { const j = await res.json(); if (j?.error) msg = j.error; } catch (e) {}
    throw new Error(msg);
  }
  const blob = await res.blob();
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `${goal}-holdings.csv`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

const HOLDINGS_LBL_STY = {
  fontFamily: "'IBM Plex Mono',monospace", fontSize: 9.5, letterSpacing: ".08em",
  textTransform: "uppercase", color: "#647071",
};

function holdingsFmtVal(v) {
  if (!v) return null;
  return v >= 1e6 ? `$${(v / 1e6).toFixed(2)}M` : `$${Math.round(v).toLocaleString()}`;
}

const HOLDINGS_INPUT_STY = {
  width: "100%", boxSizing: "border-box",
  height: 32, padding: "0 8px",
  fontFamily: "'IBM Plex Mono', monospace", fontSize: 12.5,
  border: "1px solid #8b9199", borderRadius: 4,
  background: "#faf9f7", color: "#1a1a1a", outline: "none",
};

const HOLDINGS_PATHS = [
  { key: "cash",       icon: "ti-cash",           title: "All cash",           sub: "Savings or HISA" },
  { key: "aio",        icon: "ti-circle-dashed",  title: "One all-in-one fund", sub: "Simple, low-cost portfolio solutions" },
  { key: "individual", icon: "ti-list-search",    title: "Individual funds",   sub: "Fully customized holdings" },
];

function HoldingsPathCards({ path, onChoose }) {
  return (
    <div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:8}}>
      {HOLDINGS_PATHS.map(p => {
        const sel = path === p.key;
        return (
          <button key={p.key} onClick={() => onChoose(p.key)}
            style={{display:"flex",alignItems:"center",gap:10,textAlign:"left",padding:"9px 12px",
                    borderRadius:4,cursor:"pointer",fontFamily:"system-ui",
                    background: sel ? "rgba(245,166,35,.09)" : "#fff",
                    border: "1px solid " + (sel ? "#1a4a6b" : "#d4cfc5")}}>
            <i className={"ti " + p.icon} style={{fontSize:17,color:sel?"#8a5709":"#647071",flexShrink:0}} aria-hidden="true" />
            <span>
              <span style={{display:"block",fontSize:13,fontWeight:500,color:"#1a1a1a"}}>{p.title}</span>
              <span style={{display:"block",fontSize:11,color:"#647071"}}>{p.sub}</span>
            </span>
          </button>
        );
      })}
    </div>
  );
}

// The CSV round trip's review surface (save-tab.md § Portfolio CSV
// round-trip) — upload error list + the import diff banner. The Download/
// Upload buttons themselves live on the read-only Holdings card
// (`SavingsModuleCard`), not here: this modal is small on purpose, and
// upload is a dry run that opens straight into this review rather than
// adding chrome to a panel that already has path cards, a picker, a table
// and an allocation bar. A successful parse replaces `rows` wholesale (same
// shape as picking a new all-in-one fund) so the existing table, allocation
// bar and Save/Discard footer double as the diff preview and the explicit
// Apply/Cancel step — this component only adds the added/changed/removed
// summary on top of that.
function HoldingsCsvBar({ errors, diff }) {
  if (!errors?.length && !diff) return null;
  return (
    <div style={{marginBottom:12}}>
      {errors && errors.length > 0 && (
        <div style={{marginTop:8,padding:"9px 12px",borderRadius:4,background:"rgba(192,57,43,.08)",
                     fontSize:12,color:"#c0392b",fontFamily:"system-ui",lineHeight:1.5}}>
          {errors.slice(0, 8).map((e, i) => <div key={i}>{e}</div>)}
          {errors.length > 8 && <div>…and {errors.length - 8} more</div>}
        </div>
      )}

      {diff && (
        <div style={{marginTop:8,padding:"9px 12px",borderRadius:4,background:"rgba(245,166,35,.09)"}}>
          <div style={{fontSize:11.5,fontFamily:"system-ui",color:"#5a6a72",marginBottom:diff.added.length||diff.changed.length||diff.removed.length?4:0}}>
            <i className="ti ti-file-diff" style={{marginRight:6,color:"#8a5709"}} aria-hidden="true" />
            Reviewing the import — nothing is saved yet. Save to apply, or Discard to cancel.
          </div>
          <div style={{display:"flex",gap:14,flexWrap:"wrap",fontFamily:"'IBM Plex Mono',monospace",fontSize:11}}>
            {diff.added.length > 0 && (
              <span style={{color:"#2d7a47"}}>{diff.added.length} added <span style={{color:"#647071"}}>({diff.added.slice(0,6).join(", ")}{diff.added.length>6?"…":""})</span></span>
            )}
            {diff.changed.length > 0 && (
              <span style={{color:"#8a5709"}}>{diff.changed.length} changed <span style={{color:"#647071"}}>({diff.changed.slice(0,6).join(", ")}{diff.changed.length>6?"…":""})</span></span>
            )}
            {diff.removed.length > 0 && (
              <span style={{color:"#c0392b"}}>{diff.removed.length} removed <span style={{color:"#647071"}}>({diff.removed.slice(0,6).join(", ")}{diff.removed.length>6?"…":""})</span></span>
            )}
            {!diff.added.length && !diff.changed.length && !diff.removed.length && (
              <span style={{color:"#2d7a47"}}>No changes — this file matches what's already saved.</span>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// Top-3 all-in-one suggestions, ranked by how closely the fund's equity %
// matches the plan's own equity_pct. See save-tab.md § Holdings editor.
function HoldingsAioPicker({ tickers, equityPct, climateScreen, chosen, onPick }) {
  // Climate screen is a strict toggle here too, matching allocation.py's
  // ROLE_TICKERS convention (climate_screen selects ESG vs plain, never a
  // blend of both) — a climate-screened plan only sees ESG-flagged all-in-one
  // funds, everyone else only sees non-ESG ones. `is_esg` is computed
  // server-side from the same notes-keyword signal allocation._is_esg uses.
  const candidates = (tickers || [])
    .filter(t => t.asset_class === "Multi-Asset" && t.equity_weight_pct != null)
    .filter(t => !!t.is_esg === !!climateScreen)
    .map(t => ({ t, diff: Math.abs(t.equity_weight_pct - (equityPct != null ? equityPct : t.equity_weight_pct)) }))
    .sort((a, b) => a.diff - b.diff)
    .slice(0, 3);
  if (!candidates.length) return null;
  return (
    <div style={{marginTop:14}}>
      <div style={{...HOLDINGS_LBL_STY,fontSize:10.5,letterSpacing:".1em",marginBottom:8}}>
        Portfolio funds
        {equityPct != null && (
          <span style={{textTransform:"none",letterSpacing:0,color:"#8a5709"}}>
            {" "}— based on your plan ({equityTierLabel(equityPct)} · {Math.round(equityPct)}% equity{climateScreen ? " · climate-aware" : ""})
          </span>
        )}
      </div>
      <div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:8}}>
        {candidates.map((x, i) => {
          const t = x.t, sel = chosen === t.ticker;
          const eq = Math.round(t.equity_weight_pct);
          return (
            <button key={t.ticker} onClick={() => onPick(t)}
              style={{textAlign:"left",padding:"10px 12px",borderRadius:4,cursor:"pointer",fontFamily:"system-ui",
                      background: sel ? "rgba(245,166,35,.09)" : "#fff",
                      border: "1px solid " + (sel ? "#1a4a6b" : "#d4cfc5")}}>
              <div style={{display:"flex",alignItems:"center",justifyContent:"space-between"}}>
                <span style={{fontFamily:"'IBM Plex Mono',monospace",fontWeight:500,fontSize:13,color:"#1a1a1a"}}>{t.ticker}</span>
                {i === 0 && (
                  <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:9,letterSpacing:".05em",
                                color:"#8a5709",background:"rgba(245,166,35,.16)",borderRadius:99,padding:"2px 6px"}}>
                    Closest to plan
                  </span>
                )}
              </div>
              <div style={{fontSize:11,color:"#647071",marginTop:2,lineHeight:1.35,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>
                {t.notes || t.ticker}
              </div>
              <div style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10.5,color:"#5a6a72",marginTop:5}}>
                {eq}% equity · {100 - eq}% bonds
              </div>
            </button>
          );
        })}
      </div>
    </div>
  );
}

function HoldingsTypeTag({ t }) {
  const isAio = t.asset_class === "Multi-Asset" && t.equity_weight_pct != null;
  const label = isAio ? "All-in-one" : (t.security_type === "Common Share" ? "Stock" : "ETF");
  return (
    <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:9.5,letterSpacing:".05em",
                  // --soft, not --muted: muted is 4.20:1 on the #eee8dc tint (style.md § Warm tints)
                  color: isAio ? "#8a5709" : "#5a6a72", background: isAio ? "rgba(245,166,35,.16)" : "#eee8dc",
                  borderRadius:99,padding:"2px 7px",flexShrink:0,whiteSpace:"nowrap"}}>
      {label}
    </span>
  );
}

// Persistent ticker search — sits above the table rather than behind an
// "Add holding" toggle, and clicking a result commits immediately (adds a
// zeroed row) instead of requiring a second "Add" click. Reuses the existing
// /api/tickers/search debounce (searchCatalog prop) + registry fallback.
function HoldingsTickerSearch({
  placeholder, tkSearch, setTkSearch, showDrop, setShowDrop,
  catalogResults, setCatalogResults, searchCatalog, registryTickers,
  exclude, onPick, onAddManual,
}) {
  const excludeSet = new Set(exclude || []);
  const dropItems = !tkSearch.trim() ? [] :
    (catalogResults.length > 0
      ? catalogResults
      : (registryTickers || []).filter(t =>
          t.ticker.toUpperCase().includes(tkSearch.toUpperCase()) ||
          (t.notes || "").toLowerCase().includes(tkSearch.toLowerCase())))
      .filter(t => !excludeSet.has(t.ticker))
      .slice(0, 8);

  return (
    <div style={{position:"relative"}}>
      <div style={{position:"relative"}}>
        <i className="ti ti-search" style={{position:"absolute",left:11,top:"50%",transform:"translateY(-50%)",
                                             color:"#647071",fontSize:15,pointerEvents:"none"}} aria-hidden="true" />
        <input
          type="text" value={tkSearch} placeholder={placeholder}
          style={{...HOLDINGS_INPUT_STY,paddingLeft:32}}
          onChange={e => {
            const v = e.target.value;
            setTkSearch(v); setShowDrop(true); setCatalogResults([]); searchCatalog(v);
          }}
          onFocus={() => { if (tkSearch.trim()) setShowDrop(true); }}
          onBlur={() => setTimeout(() => setShowDrop(false), 150)}
        />
      </div>
      {showDrop && tkSearch.trim() !== "" && (
        <div style={{position:"absolute",top:"calc(100% + 4px)",left:0,right:0,zIndex:200,
                     background:"#fff",border:"1px solid #d4cfc5",borderRadius:4,
                     boxShadow:"0 6px 20px rgba(26,26,26,.12)",maxHeight:260,overflowY:"auto"}}>
          {dropItems.map(t => (
            <div key={t.ticker} onMouseDown={() => onPick(t)}
              style={{display:"flex",alignItems:"center",gap:8,padding:"9px 12px",cursor:"pointer",
                      borderBottom:"1px solid #f0ebe1"}}>
              <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12,fontWeight:600,
                            color:classTextColor(t.asset_class),minWidth:48,flexShrink:0}}>
                {t.ticker}
              </span>
              <span style={{flex:1,fontSize:11,color:"#5a6a72",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
                {t.name || t.notes || AC_LABELS[t.asset_class] || t.asset_class || ""}
                {t.price ? ` · $${t.price.toFixed(2)}` : ""}
              </span>
              <HoldingsTypeTag t={t} />
            </div>
          ))}
          {dropItems.length === 0 && (
            <div style={{padding:"10px 12px",fontSize:12.5,color:"#647071"}}>No tradable match for "{tkSearch}".</div>
          )}
          <button onMouseDown={() => onAddManual(tkSearch)}
            style={{display:"flex",alignItems:"center",gap:8,width:"100%",textAlign:"left",padding:"9px 12px",
                    background:"#faf8f3",border:"none",cursor:"pointer",color:"#1a4a6b",fontSize:12.5,fontFamily:"system-ui"}}>
            <i className="ti ti-plus" style={{fontSize:14}} aria-hidden="true" />
            Add a mutual fund, GIC or other holding manually
          </button>
        </div>
      )}
    </div>
  );
}

// Trimmed custom-holding form — name + class only. Deliberately simpler than
// capturing every bucket's dollar amount up front: submitting adds a zeroed
// row that's then filled in per-bucket directly in the table, exactly like a
// newly-searched security row (see save-tab.md § Holdings editor).
function HoldingsCustomForm({ initialName, onAdd, onCancel }) {
  const [name, setName] = React.useState(initialName || "");
  const [cls, setCls]   = React.useState("Fixed Income");
  return (
    <div style={{background:"#faf8f3",border:"1px solid #e3ddd1",borderRadius:4,padding:14,margin:"10px 0"}}>
      <div style={{display:"flex",alignItems:"baseline",gap:6,marginBottom:10,flexWrap:"wrap"}}>
        <span style={{...HOLDINGS_LBL_STY,fontSize:11,letterSpacing:".1em"}}>Manual holding</span>
        <span style={{fontSize:11.5,color:"#647071"}}>— mutual fund, GIC, employer plan; you track the value</span>
      </div>
      <div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"flex-end",marginBottom:10}}>
        <div style={{flex:"2 1 180px"}}>
          <div style={{...HOLDINGS_LBL_STY,marginBottom:4}}>Name</div>
          <input value={name} placeholder="e.g. TD Balanced Fund" style={HOLDINGS_INPUT_STY}
                 onChange={e => setName(e.target.value)} autoFocus />
        </div>
        <div style={{flex:"1 1 160px"}}>
          <div style={{...HOLDINGS_LBL_STY,marginBottom:4}}>Asset class</div>
          <select value={cls} onChange={e => setCls(e.target.value)} style={HOLDINGS_INPUT_STY}>
            {ASSET_CLASS_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
          </select>
        </div>
      </div>
      <div style={{display:"flex",gap:8}}>
        <button onClick={() => name.trim() && onAdd(name.trim(), cls)}
          style={{background:"#1a4a6b",color:"#fff",border:"none",borderRadius:4,
                  padding:"5px 14px",fontSize:12,fontFamily:"system-ui",cursor:"pointer"}}>
          Add holding
        </button>
        <button onClick={onCancel}
          style={{background:"none",border:"1px solid #d4cfc5",borderRadius:4,
                  padding:"5px 14px",fontSize:12,fontFamily:"system-ui",cursor:"pointer",color:"#5a6a72"}}>
          Cancel
        </button>
      </div>
    </div>
  );
}

function HoldingsBucketHeader({ acctCols, totals, onTotalChange }) {
  return (
    <div style={{display:"grid",gridTemplateColumns:`${acctCols.map(()=>"1fr").join(" ")} 90px`,gap:8,
                 alignItems:"end",padding:"10px 0 8px",borderBottom:"1px solid #d4cfc5",
                 position:"sticky",top:0,background:"#fff",zIndex:5}}>
      {acctCols.map(m => (
        <div key={m.units}>
          <div style={{...HOLDINGS_LBL_STY,marginBottom:4}}>{m.label} total</div>
          <div style={{display:"flex",border:"1px solid #8b9199",borderRadius:4,overflow:"hidden"}}>
            <span style={{display:"flex",alignItems:"center",justifyContent:"center",width:18,flexShrink:0,
                          fontFamily:"'IBM Plex Mono',monospace",fontSize:11,color:"#647071"}}>$</span>
            <input type="text" inputMode="numeric" value={(totals[m.units] || 0).toLocaleString("en-CA")}
              style={{...HOLDINGS_INPUT_STY,border:"none",borderRadius:0,flex:1,minWidth:0,paddingLeft:0}}
              onChange={e => {
                const v = parseInt(e.target.value.replace(/[^0-9]/g, ""), 10);
                onTotalChange(m.units, isNaN(v) ? 0 : v);
              }} />
          </div>
        </div>
      ))}
      <div style={{...HOLDINGS_LBL_STY,textAlign:"right",alignSelf:"center"}}>Value</div>
    </div>
  );
}

// A manual holding's OWN economics — shown only on a custom/dollar-mode row.
// A 4.51% GIC is one person's contract, not a fact about a symbol, so these live
// on the holding row rather than in the shared fund registry (`db.md §
// portfolio_holdings`). What they drive: `custom_return_pct` − `custom_mer_pct`
// is this holding's contribution to its account's expected return
// (`app._goal_bucket_returns`), and `custom_income_type` is what puts a GIC's
// interest into the non-registered account's tax drag
// (`app._goal_bucket_mix`).
//
// **A blank yield means "leave me out", never 0%.** That is the whole point of
// the feature, so the hint says it in words — a silent zero is what used to
// happen, and it dragged the account holding the GIC ladder down by two full
// percentage points with nothing on screen to show for it.
function HoldingsEconRow({ h, mutRow }) {
  const regRet = h.registry_return_pct;
  const regMer = h.registry_mer_pct;
  // The registry stores the same value set capitalized ('Eligible'/'Income'/…),
  // which is what app._goal_bucket_mix lower-cases on read — do the same here so
  // the fallback label matches the option the user would pick.
  const regInc = INCOME_TYPE_OPTIONS.find(
    o => o.id === String(h.registry_income_type || "").trim().toLowerCase());
  const num = (key, v) => {
    const f = v === "" ? null : parseFloat(v);
    mutRow(h.ticker, { [key]: isNaN(f) ? null : f });
  };
  const hint = h.custom_return_pct != null
    ? null
    : (regRet != null
        ? `Blank — using ${regRet}% from the fund registry.`
        : "No return set — this holding sits out of its account's expected return. It is not counted as 0%.");
  return (
    <div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"flex-end",
                 marginTop:10,paddingLeft:10,borderLeft:"2px solid #f0ede6"}}>
      <div style={{flex:"1 1 110px",minWidth:96}}>
        <div style={{...HOLDINGS_LBL_STY,marginBottom:4}}>Yield / return %</div>
        <input type="number" inputMode="decimal" step="0.01"
               value={h.custom_return_pct ?? ""}
               placeholder={regRet != null ? String(regRet) : "—"}
               style={{...HOLDINGS_INPUT_STY,height:28,fontSize:12,textAlign:"right"}}
               onChange={e => num("custom_return_pct", e.target.value)} />
      </div>
      <div style={{flex:"1 1 90px",minWidth:80}}>
        <div style={{...HOLDINGS_LBL_STY,marginBottom:4}}>MER %</div>
        <input type="number" inputMode="decimal" step="0.01"
               value={h.custom_mer_pct ?? ""}
               placeholder={regMer != null ? String(regMer) : "0"}
               style={{...HOLDINGS_INPUT_STY,height:28,fontSize:12,textAlign:"right"}}
               onChange={e => num("custom_mer_pct", e.target.value)} />
      </div>
      <div style={{flex:"2 1 190px",minWidth:150}}>
        <div style={{...HOLDINGS_LBL_STY,marginBottom:4}}>Income type</div>
        <select value={h.custom_income_type || ""}
                style={{...HOLDINGS_INPUT_STY,height:28,fontSize:12,fontFamily:"system-ui"}}
                onChange={e => mutRow(h.ticker, { custom_income_type: e.target.value || null })}>
          <option value="">{regInc ? `Not set — using ${regInc.label}` : "Not specified"}</option>
          {INCOME_TYPE_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
        </select>
      </div>
      {hint && (
        <div style={{flexBasis:"100%",fontSize:11,color:"#647071",lineHeight:1.45,marginTop:2}}>
          {hint}
        </div>
      )}
    </div>
  );
}

function HoldingsRow({ h, acctCols, isArmed, mutRow, armDel }) {
  const val = acctCols.reduce((s, m) => s + holdingBucketValue(h, m), 0);
  return (
    <div style={{borderBottom:"1px solid #f0ede6",padding:"14px 0"}}>
      <div style={{display:"flex",alignItems:"center",gap:8,marginBottom:10,flexWrap:"wrap"}}>
        <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:13,fontWeight:600,color:classTextColor(h.asset_class)}}>
          {h.ticker}
        </span>
        <span style={{display:"flex",alignItems:"center",gap:4,fontSize:11,color:"#5a6a72"}}>
          <span style={{width:7,height:7,borderRadius:"50%",display:"inline-block",background:classColor(h.asset_class),flexShrink:0}} />
          {AC_LABELS[h.asset_class] || h.asset_class || "—"}
        </span>
        {!h.custom && h.price != null && (
          <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10.5,color:"#5a6a72",
                        background:"#f0ede6",borderRadius:4,padding:"1px 6px"}}>
            ${h.price.toFixed(2)}
          </span>
        )}
        {h.custom && (
          <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:9.5,color:"#5a6a72",
                        background:"#eee8dc",borderRadius:99,padding:"2px 7px"}}>
            Manual
          </span>
        )}
        <button onClick={() => armDel(h.ticker)}
          style={{marginLeft:"auto",background:isArmed?"#c0392b":"none",border:isArmed?"none":"1px solid #d4cfc5",
                  borderRadius:4,padding:"2px 10px",fontSize:10.5,color:isArmed?"#fff":"#647071",
                  cursor:"pointer",fontFamily:"system-ui"}}>
          {isArmed ? "Confirm remove" : "Remove"}
        </button>
      </div>
      <div style={{display:"grid",gridTemplateColumns:`${acctCols.map(()=>"1fr").join(" ")} 90px`,gap:8,alignItems:"end"}}>
        {acctCols.map(m => {
          const key = h.custom ? m.amount : m.units;
          return (
            <div key={key}>
              <div style={{...HOLDINGS_LBL_STY,marginBottom:4}}>{m.label}</div>
              <div style={{display:"flex",border:"1px solid #8b9199",borderRadius:4,overflow:"hidden"}}>
                <input
                  type="number" inputMode="decimal"
                  value={h[key] ?? ""}
                  placeholder="—"
                  style={{...HOLDINGS_INPUT_STY,border:"none",borderRadius:0,flex:1,minWidth:0,textAlign:"right"}}
                  onChange={e => {
                    const v = e.target.value === "" ? null : parseFloat(e.target.value);
                    mutRow(h.ticker, { [key]: isNaN(v) ? null : v });
                  }}
                />
                <span style={{display:"flex",alignItems:"center",justifyContent:"center",width:20,flexShrink:0,
                              borderLeft:"1px solid #d4cfc5",background:"#f5f0e8",
                              fontFamily:"'IBM Plex Mono',monospace",fontSize:10,
                              color:h.custom?"#a1450f":"#647071"}}>
                  {h.custom ? "$" : "#"}
                </span>
              </div>
            </div>
          );
        })}
        <div style={{textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:12.5,color:"#1a1a1a",paddingBottom:6}}>
          {val > 0 ? holdingsFmtVal(val) : <span style={{color:"#647071"}}>—</span>}
        </div>
      </div>
      {h.custom && <HoldingsEconRow h={h} mutRow={mutRow} />}
    </div>
  );
}

function HoldingsCashRow({ acctCols, cashByBucket, cashTotal }) {
  return (
    <div style={{padding:"13px 0 4px",borderTop:"1px dashed #e3ddd1"}}>
      <div style={{display:"flex",alignItems:"baseline",gap:8,marginBottom:10}}>
        <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:13,fontWeight:600,color:"#1a1a1a"}}>CASH</span>
        <span style={{fontSize:11,color:"#647071"}}>Auto-balanced remainder</span>
      </div>
      <div style={{display:"grid",gridTemplateColumns:`${acctCols.map(()=>"1fr").join(" ")} 90px`,gap:8}}>
        {acctCols.map(m => {
          const c = cashByBucket[m.units] || 0;
          const neg = c < -0.5;
          return (
            <div key={m.units} style={{textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:12.5,
                                        color: neg ? "#b34030" : "#647071", paddingRight:9}}>
              {neg ? `−$${Math.round(-c).toLocaleString()}` : `$${Math.round(c).toLocaleString()}`}
            </div>
          );
        })}
        <div style={{textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:13.5,color:"#647071"}}>
          {holdingsFmtVal(Math.max(cashTotal, 0)) || "$0"}
        </div>
      </div>
    </div>
  );
}

// Stable display order for the bottom allocation bar — real asset classes
// first (matching app.py's _AC_ORDER convention), Cash always last.
const HOLDINGS_AC_ORDER = [
  "Fixed Income", "Canadian Equity", "Developed Markets", "Emerging Markets",
  "Preferred Shares", "US Equity", "Alternatives", "Multi-Asset",
];

function HoldingsAllocationBar({ alloc, targetByClass, equityPct, overBuckets }) {
  const seen = new Set(Object.keys(alloc.byClass));
  const extra = Object.keys(targetByClass || {}).filter(k => !seen.has(k) && k !== "Cash");
  const classes = HOLDINGS_AC_ORDER.filter(k => alloc.byClass[k] > 0.5 || (targetByClass || {})[k] > 0.5)
    .concat(extra.filter(k => !HOLDINGS_AC_ORDER.includes(k)));
  const order = [...classes, "Cash"];

  return (
    <div style={{padding:"12px 24px 14px",background:"#faf9f7",borderTop:"1px solid #e3ddd1"}}>
      <div style={{display:"flex",alignItems:"center",gap:10,marginBottom:8,flexWrap:"wrap"}}>
        <span style={{...HOLDINGS_LBL_STY,fontSize:10.5,letterSpacing:".12em"}}>Your allocation vs plan</span>
        {equityPct != null && (
          <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10,color:"#8a5709",
                        background:"rgba(245,166,35,.16)",borderRadius:99,padding:"2px 8px"}}>
            {equityTierLabel(equityPct)} · {Math.round(equityPct)}% equity
          </span>
        )}
        {overBuckets.length ? (
          <span style={{fontSize:11.5,color:"#b34030",marginLeft:"auto"}}>
            <i className="ti ti-alert-triangle" style={{marginRight:4}} aria-hidden="true" />
            {overBuckets.join(" & ")} holdings exceed {overBuckets.length > 1 ? "their totals" : "its total"} — raise the total or reduce a position.
          </span>
        ) : (
          <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10.5,color:"#647071",marginLeft:"auto"}}>
            ${Math.round(alloc.total).toLocaleString()} total
          </span>
        )}
      </div>
      <div style={{display:"flex",height:13,borderRadius:3,overflow:"hidden",background:"#e3ddd1",marginBottom:8}}>
        {order.map(k => {
          const v = k === "Cash" ? alloc.cash : (alloc.byClass[k] || 0);
          const w = alloc.total > 0 ? v / alloc.total * 100 : 0;
          if (w < 0.3) return null;
          return <div key={k} title={`${AC_LABELS[k] || k} ${w.toFixed(1)}%`} style={{width:w+"%",background:classColor(k)}} />;
        })}
      </div>
      <div style={{display:"flex",gap:18,flexWrap:"wrap"}}>
        {order.map(k => {
          const plan = k === "Cash" ? 0 : ((targetByClass || {})[k] || 0);
          const curVal = k === "Cash" ? alloc.cash : (alloc.byClass[k] || 0);
          const cur = alloc.total > 0 ? curVal / alloc.total * 100 : 0;
          if (k !== "Cash" && !plan && cur < 0.5) return null;
          let dTxt, dCol;
          const delta = cur - plan;
          if (k === "Cash") { dTxt = "plan 0%"; dCol = "#647071"; }
          else if (Math.abs(delta) < 0.5) { dTxt = "on plan"; dCol = "#2d7a47"; }
          else if (delta < 0) { dTxt = `${Math.abs(delta).toFixed(1)}pp under`; dCol = "#b34030"; }
          else { dTxt = `${delta.toFixed(1)}pp over`; dCol = "#a1450f"; }
          return (
            <span key={k} style={{display:"inline-flex",alignItems:"center",gap:6,fontSize:12,fontFamily:"system-ui"}}>
              <i style={{width:8,height:8,borderRadius:2,background:classColor(k),display:"inline-block"}} />
              <span style={{color:"#1a1a1a"}}>{AC_LABELS[k] || k}</span>
              <span style={{fontFamily:"'IBM Plex Mono',monospace",color:"#1a1a1a"}}>{cur.toFixed(1)}%</span>
              <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10.5,color:dCol}}>{dTxt}</span>
            </span>
          );
        })}
      </div>
    </div>
  );
}

function HoldingsEditPanel({ goal, onSaved, active, reloadTick, pendingCsvFile, onCsvFileConsumed }) {
  const [data, setData]             = React.useState(null);
  const [rows, setRows]             = React.useState([]);
  const [savedRows, setSavedRows]   = React.useState([]);
  const [totals, setTotals]         = React.useState({});
  const [savedTotals, setSavedTotals] = React.useState({});
  const [path, setPath]             = React.useState(null);
  const [savedPath, setSavedPath]   = React.useState(null);
  const [dirty, setDirty]           = React.useState(false);
  const [busy, setBusy]             = React.useState(false);
  const [err, setErr]               = React.useState(null);
  const [flash, setFlash]           = React.useState(false);
  const [delArm, setDelArm]         = React.useState(null);
  const [tkSearch, setTkSearch]     = React.useState("");
  const [showDrop, setShowDrop]     = React.useState(false);
  const [customDraft, setCustomDraft] = React.useState(null);
  const [catalogResults, setCatalogResults] = React.useState([]);
  const _searchTimer = React.useRef(null);
  // CSV round trip (save-tab.md § Portfolio CSV round-trip). csvImported flags
  // that `rows` currently holds a parsed-but-unsaved import, which is what
  // turns on the added/changed/removed review banner below the table. The
  // Download/Upload buttons themselves live on SavingsModuleCard — this panel
  // only handles the file once it's picked, via `pendingCsvFile`.
  const [csvErrors, setCsvErrors]     = React.useState(null);
  const [csvImported, setCsvImported] = React.useState(false);

  const tickerMap = React.useMemo(() => {
    const m = {};
    (data?.tickers || []).forEach(t => { m[t.ticker] = t; });
    return m;
  }, [data]);

  // Read inside the reload effect without making it a dependency — a dirty
  // panel must not be reseeded out from under the user (see below).
  const dirtyRef = React.useRef(false);
  React.useEffect(() => { dirtyRef.current = dirty; }, [dirty]);

  // `reloadTick` bumps when ANOTHER tab in this modal saves (see
  // PortfolioEditModal) — most importantly the Accounts tab, whose change adds
  // or removes a bucket column here. Both panels stay mounted, so without this
  // the columns only caught up on a modal reopen.
  React.useEffect(() => {
    api(`/api/portfolio/holdings-edit/${goal}`).then(r => {
      if (!r.ok) { setErr(r.data?.error || "Failed to load holdings"); return; }
      // `data` is entirely server-owned (accounts, tickers, prices,
      // target_by_class) so it always refreshes — that's what makes a new
      // bucket column appear. `rows`/`totals`/`path` are the user's in-progress
      // edits and are only reseeded when there's nothing to lose: the modal
      // deliberately preserves work across tab switches, and a reseed would
      // also re-run path inference over a path the user may have chosen by hand.
      setData(r.data);
      if (dirtyRef.current) return;
      const all = r.data.holdings || [];
      const acctCols = goalAcctColsFor(r.data.accounts);
      const nonCash = all.filter(h => h.ticker !== "CASH");
      const totalsInit = {};
      acctCols.forEach(m => {
        totalsInit[m.units] = all.reduce((s, h) => s + holdingBucketValue(h, m), 0);
      });
      const tMap = {};
      (r.data.tickers || []).forEach(t => { tMap[t.ticker] = t; });
      let pathInit;
      if (nonCash.length === 0) {
        const anyTotal = acctCols.some(m => (totalsInit[m.units] || 0) > 0);
        pathInit = anyTotal ? "cash" : null;
      } else if (nonCash.length === 1 && !nonCash[0].custom &&
                 tMap[nonCash[0].ticker]?.asset_class === "Multi-Asset" &&
                 tMap[nonCash[0].ticker]?.equity_weight_pct != null) {
        pathInit = "aio";
      } else {
        pathInit = "individual";
      }
      const snap = JSON.parse(JSON.stringify(nonCash));
      setRows(snap);
      setSavedRows(JSON.parse(JSON.stringify(snap)));
      setTotals(totalsInit);
      setSavedTotals({ ...totalsInit });
      setPath(pathInit);
      setSavedPath(pathInit);
    }).catch(() => setErr("Network error"));
  }, [goal, reloadTick]);

  // Debounced catalog search — fires 200 ms after the user stops typing.
  const searchCatalog = (q) => {
    clearTimeout(_searchTimer.current);
    if (!q.trim()) { setCatalogResults([]); return; }
    _searchTimer.current = setTimeout(() => {
      api(`/api/tickers/search?q=${encodeURIComponent(q.trim())}`)
        .then(r => { if (r.ok) setCatalogResults(r.data.results || []); })
        .catch(() => {});
    }, 200);
  };

  const discard = () => {
    setRows(JSON.parse(JSON.stringify(savedRows)));
    setTotals({ ...savedTotals });
    setPath(savedPath);
    setDirty(false);
    setErr(null);
    setDelArm(null);
    setCsvImported(false);
    setCsvErrors(null);
  };

  // Upload half — parse+validate only, no write. A dry run: on success the
  // parsed rows replace `rows` (same wholesale-replace shape as picking a new
  // all-in-one fund) so the existing table, allocation bar and Save/Discard
  // footer become the diff-preview and explicit-Apply/Cancel steps for free.
  const handleCsvFile = async (file) => {
    if (!file) return;
    setCsvErrors(null);
    let text;
    try {
      text = await file.text();
    } catch (e) {
      setCsvErrors(["Could not read the file"]);
      return;
    }
    const r = await api(`/api/portfolio/holdings-csv-parse/${goal}`, {
      method: "POST",
      body: { csv: text },
    });
    if (!r.ok || !r.data?.ok) {
      setCsvErrors(r.data?.errors || [r.data?.error || "Import failed"]);
      return;
    }
    setRows(r.data.holdings);
    setPath("individual");
    setCsvImported(true);
    setDirty(true);
    setErr(null);
    setDelArm(null);
  };

  // The card's Upload icon picks a file before this panel may even be
  // mounted (it opens the modal to trigger the mount). Once the panel's own
  // data load finishes, process it exactly like an in-panel upload would,
  // then tell the parent to clear `pendingCsvFile` so this doesn't re-fire.
  React.useEffect(() => {
    if (pendingCsvFile && data) {
      handleCsvFile(pendingCsvFile);
      onCsvFileConsumed?.();
    }
  }, [pendingCsvFile, data]);

  const save = async () => {
    setBusy(true);
    setErr(null);
    const acctCols = goalAcctColsFor(data?.accounts);
    const alloc = computeHoldingsAlloc(rows, totals, acctCols, tickerMap, data?.target_by_class);
    const cashRow = { ticker: "CASH", asset_class: "Cash", custom: true };
    acctCols.forEach(m => {
      cashRow[m.amount] = Math.round((alloc.cashByBucket[m.units] || 0) * 100) / 100;
    });
    const payload = [...rows, cashRow];
    const r = await api(`/api/portfolio/holdings-edit/${goal}`, {
      method: "POST",
      body: { holdings: payload },
    });
    setBusy(false);
    if (!r.ok) { setErr(r.data?.error || "Save failed"); return; }
    // Combined-modal panel: stay open so the user can keep editing (or switch
    // to the Contributions tab). Re-baseline the dirty tracker and flash "saved".
    setSavedRows(JSON.parse(JSON.stringify(rows)));
    setSavedTotals({ ...totals });
    setSavedPath(path);
    setDirty(false);
    setDelArm(null);
    setCsvImported(false);
    setFlash(true);
    setTimeout(() => setFlash(false), 2200);
    onSaved();
  };

  const csvDiff = csvImported ? diffHoldingsRows(savedRows, rows) : null;

  return (
    <HoldingsEditPanelBody
      active={active} data={data} err={err} setErr={setErr} busy={busy} flash={flash}
      rows={rows} setRows={setRows} totals={totals} setTotals={setTotals}
      path={path} setPath={setPath} tickerMap={tickerMap}
      dirty={dirty} setDirty={setDirty}
      delArm={delArm} setDelArm={setDelArm}
      tkSearch={tkSearch} setTkSearch={setTkSearch} showDrop={showDrop} setShowDrop={setShowDrop}
      customDraft={customDraft} setCustomDraft={setCustomDraft}
      catalogResults={catalogResults} setCatalogResults={setCatalogResults} searchCatalog={searchCatalog}
      save={save} discard={discard}
      csvErrors={csvErrors} csvDiff={csvDiff}
      setCsvImported={setCsvImported}
    />
  );
}

// Pure presentational body — path cards, all-in-one picker, holdings table,
// allocation bar, and the Discard/Save footer. Split out from HoldingsEditPanel
// so a Help figure can render the exact same markup fed local sample state
// instead of a live GET /api/portfolio/holdings-edit fetch, mirroring the
// ContributionsPanel/ContributionsPanelBody split (see save-tab.md § Combined
// portfolio edit modal). `save`/`discard`/`searchCatalog` stay props rather
// than moving in — they're the places real persistence/network happens, so
// the Help figure can swap in local-only versions ("nothing is saved") while
// every other interaction (path switching, add/remove, totals editing) is the
// real, unmodified logic.
function HoldingsEditPanelBody({
  active, data, err, setErr, busy, flash,
  rows, setRows, totals, setTotals, path, setPath, tickerMap,
  dirty, setDirty,
  delArm, setDelArm,
  tkSearch, setTkSearch, showDrop, setShowDrop,
  customDraft, setCustomDraft,
  catalogResults, setCatalogResults, searchCatalog,
  save, discard,
  csvErrors, csvDiff, setCsvImported,
}) {
  const acctCols = goalAcctColsFor(data?.accounts);

  const closeSearch = () => { setTkSearch(""); setShowDrop(false); setCatalogResults([]); };

  const mutRow = (ticker, updates) => {
    setRows(prev => prev.map(r => r.ticker === ticker ? { ...r, ...updates } : r));
    setDirty(true);
    if (delArm === ticker) setDelArm(null);
  };

  const armDel = (ticker) => {
    if (delArm === ticker) {
      setRows(prev => prev.filter(r => r.ticker !== ticker));
      setDelArm(null);
      setDirty(true);
    } else {
      setDelArm(ticker);
    }
  };

  const onTotalChange = (unitsKey, v) => {
    setTotals(prev => ({ ...prev, [unitsKey]: v }));
    setDirty(true);
  };

  const choosePath = (next) => {
    if (next === "cash") {
      setRows([]);
    } else if (next === "aio") {
      const isSingleAio = rows.length === 1 && !rows[0].custom &&
        tickerMap[rows[0].ticker]?.asset_class === "Multi-Asset" &&
        tickerMap[rows[0].ticker]?.equity_weight_pct != null;
      if (!isSingleAio) setRows([]);
    }
    setPath(next);
    setDirty(true);
    setDelArm(null);
    setCsvImported?.(false);
    closeSearch();
  };

  // No-price ⇒ dollar mode (matches the wizard's holdings-edit save invariant):
  // a ticker with no resolvable price can't be unit-valued, so it's added in
  // custom/dollar mode automatically rather than gating on a checkbox.
  const addSecurity = (t) => {
    if (rows.some(r => r.ticker === t.ticker)) { closeSearch(); return; }
    const isCustom = t.price == null;
    const row = { ticker: t.ticker, asset_class: t.asset_class || "Other", custom: isCustom, price: t.price ?? null };
    acctCols.forEach(m => { row[isCustom ? m.amount : m.units] = null; });
    setRows(prev => [...prev, row]);
    setDirty(true);
    setErr(null);
    closeSearch();
  };

  const pickAio = (t) => {
    const price = t.price;
    const isCustom = price == null;
    const row = { ticker: t.ticker, asset_class: t.asset_class || "Multi-Asset", custom: isCustom, price: price ?? null };
    acctCols.forEach(m => {
      if (isCustom) { row[m.amount] = null; }
      else { row[m.units] = Math.floor((totals[m.units] || 0) / price) || null; }
    });
    setRows([row]);
    setDirty(true);
    setErr(null);
    closeSearch();
  };

  const addCustom = (name, cls) => {
    if (rows.some(r => r.ticker.toUpperCase() === name.toUpperCase())) {
      setErr(`"${name}" is already in the list`);
      return;
    }
    // Economics defaults are the GIC case, which is the overwhelming majority of
    // manual holdings: no assumed yield (blank = "leave me out of the model's
    // average", and the row says so), no MER, interest income. A row added via
    // `addSecurity`/`pickAio` gets none of these — that ticker may well be in the
    // fund registry and only lack a price, and a default here would override
    // real reference data with a guess.
    const row = { ticker: name, asset_class: cls, custom: true, price: null,
                  custom_return_pct: null, custom_mer_pct: 0,
                  custom_income_type: cls === "Cash" ? null : "income" };
    acctCols.forEach(m => { row[m.amount] = null; });
    setRows(prev => [...prev, row]);
    setDirty(true);
    setErr(null);
    setCustomDraft(null);
    closeSearch();
  };

  const alloc = computeHoldingsAlloc(rows, totals, acctCols, tickerMap, data?.target_by_class);
  const chosenAio = (path === "aio" && rows.length === 1 && !rows[0].custom) ? rows[0].ticker : null;
  const equityPct = data?.equity_pct != null ? Number(data.equity_pct) : null;

  const showTable = path === "individual" || (path === "aio" && chosenAio);

  const top = !data ? (
    <div style={{padding:32,textAlign:"center",color:err?"#c0392b":"#647071",fontFamily:"system-ui",fontSize:13}}>
      {err || "Loading…"}
    </div>
  ) : (
    <>
      <HoldingsCsvBar errors={csvErrors} diff={csvDiff} />

      <HoldingsPathCards path={path} onChoose={choosePath} />

      {path === "cash" && (
        <div style={{marginTop:14}}>
          <div style={{padding:"11px 13px",borderRadius:4,background:"rgba(245,166,35,.09)",
                       fontSize:12.5,color:"#5a6a72",lineHeight:1.5}}>
            <i className="ti ti-check" style={{color:"#8a5709",marginRight:6}} aria-hidden="true" />
            Your full ${Math.round(alloc.total).toLocaleString()} is recorded as Cash / HISA. Add holdings anytime.
          </div>
          <div style={{display:"grid",gridTemplateColumns:`repeat(${acctCols.length},1fr)`,gap:8,marginTop:10,maxWidth:480}}>
            {acctCols.map(m => (
              <div key={m.units}>
                <div style={{...HOLDINGS_LBL_STY,marginBottom:4}}>{m.label} total</div>
                <div style={{display:"flex",border:"1px solid #8b9199",borderRadius:4,overflow:"hidden"}}>
                  <span style={{display:"flex",alignItems:"center",justifyContent:"center",width:18,flexShrink:0,
                                fontFamily:"'IBM Plex Mono',monospace",fontSize:11,color:"#647071"}}>$</span>
                  <input type="text" inputMode="numeric" value={(totals[m.units] || 0).toLocaleString("en-CA")}
                    style={{...HOLDINGS_INPUT_STY,border:"none",borderRadius:0,flex:1,minWidth:0,paddingLeft:0}}
                    onChange={e => {
                      const v = parseInt(e.target.value.replace(/[^0-9]/g, ""), 10);
                      onTotalChange(m.units, isNaN(v) ? 0 : v);
                    }} />
                </div>
              </div>
            ))}
          </div>
        </div>
      )}

      {path === "aio" && (
        <div>
          <HoldingsAioPicker tickers={data.tickers} equityPct={equityPct} climateScreen={!!data.climate_screen} chosen={chosenAio} onPick={pickAio} />
          <div style={{marginTop:10}}>
            <HoldingsTickerSearch
              placeholder="or search other fund…"
              tkSearch={tkSearch} setTkSearch={setTkSearch} showDrop={showDrop} setShowDrop={setShowDrop}
              catalogResults={catalogResults} setCatalogResults={setCatalogResults} searchCatalog={searchCatalog}
              registryTickers={data.tickers} exclude={chosenAio ? [chosenAio] : []}
              onPick={pickAio} onAddManual={q => { closeSearch(); setCustomDraft(q || ""); }}
            />
          </div>
          {customDraft !== null && (
            <HoldingsCustomForm initialName={customDraft} onAdd={addCustom} onCancel={() => setCustomDraft(null)} />
          )}
          {chosenAio && (
            <div style={{marginTop:10,fontSize:12.5,color:"#5a6a72"}}>
              <i className="ti ti-check" style={{color:"#8a5709",marginRight:6}} aria-hidden="true" />
              {chosenAio} fills {acctCols.length > 1 ? "each bucket" : "your account"} automatically — adjust shares below if your amounts differ.
            </div>
          )}
        </div>
      )}

      {path === "individual" && (
        <div style={{marginTop:14}}>
          <div style={{...HOLDINGS_LBL_STY,fontSize:10.5,letterSpacing:".1em",marginBottom:6}}>Fully customized holdings</div>
          <HoldingsTickerSearch
            placeholder="Search a ticker or fund name…"
            tkSearch={tkSearch} setTkSearch={setTkSearch} showDrop={showDrop} setShowDrop={setShowDrop}
            catalogResults={catalogResults} setCatalogResults={setCatalogResults} searchCatalog={searchCatalog}
            registryTickers={data.tickers} exclude={rows.map(r => r.ticker)}
            onPick={addSecurity} onAddManual={q => { closeSearch(); setCustomDraft(q || ""); }}
          />
          {customDraft !== null && (
            <HoldingsCustomForm initialName={customDraft} onAdd={addCustom} onCancel={() => setCustomDraft(null)} />
          )}
        </div>
      )}
    </>
  );

  const table = (data && showTable) ? (
    <div style={{maxHeight:"48vh",overflowY:"auto",marginTop:4,scrollbarGutter:"stable"}}>
      <HoldingsBucketHeader acctCols={acctCols} totals={totals} onTotalChange={onTotalChange} />
      {rows.length === 0 ? (
        <div style={{padding:"18px 0",textAlign:"center",color:"#647071",fontSize:12.5}}>
          Search a ticker above — everything you don't add stays as cash.
        </div>
      ) : rows.map(h => (
        <HoldingsRow key={h.ticker} h={h} acctCols={acctCols} isArmed={delArm === h.ticker}
                     mutRow={mutRow} armDel={armDel} />
      ))}
      <HoldingsCashRow acctCols={acctCols} cashByBucket={alloc.cashByBucket} cashTotal={alloc.cash} />
    </div>
  ) : null;

  // Single scrollable region for path cards/picker + (optional) the holdings
  // table — guarantees nothing clips against the modal's fixed height even
  // when the table isn't shown. When the table IS shown it gets its own
  // nested max-height scroll (above), so a long holdings list scrolls
  // independently without pushing the path cards off-screen.
  return (
    <div style={{display: active ? "flex" : "none", flexDirection:"column", flex:1, minHeight:0}}>
      <div style={{flexGrow:1,minHeight:0,overflowY:"auto",padding:"14px 24px 0"}}>
        {top}
        {table}
      </div>
      {data && (
        <HoldingsAllocationBar alloc={alloc} targetByClass={data.target_by_class} equityPct={equityPct} overBuckets={alloc.over} />
      )}
      <div style={{borderTop:"1px solid #d4cfc5",padding:"12px 20px",flexShrink:0,
                   display:"flex",alignItems:"center",justifyContent:"space-between"}}>
        <span style={{fontFamily:"system-ui",fontSize:11.5,minHeight:18,
                      color: err ? "#c0392b" : "#2d7a47"}}>
          {err || (flash ? "All changes saved" : "")}
        </span>
        <div style={{display:"flex",gap:8}}>
          {dirty && (
            <button onClick={discard}
              style={{background:"none",border:"1px solid #d4cfc5",borderRadius:4,
                      padding:"5px 14px",fontSize:12,fontFamily:"system-ui",
                      cursor:"pointer",color:"#5a6a72"}}>
              Discard
            </button>
          )}
          <button onClick={save} disabled={!dirty || busy || alloc.over.length > 0}
            style={{background:(dirty && !alloc.over.length)?"#1a4a6b":"#647071",color:"#fff",border:"none",
                    borderRadius:4,padding:"5px 16px",fontSize:12,fontFamily:"system-ui",
                    cursor:(dirty&&!busy&&!alloc.over.length)?"pointer":"default",opacity:busy?0.7:1}}>
            {busy ? "Saving…" : "Save"}
          </button>
        </div>
      </div>
    </div>
  );
}

// ── Contributions modal ────────────────────────────────────────────────────────

const BUCKET_COLOR = {
  rrsp:    "#5b4fa8",
  tfsa:    "#16a085",
  taxable: "#c2571a",
  srrsp:   "#8e6b9e",
  resp:    "#2d7a47",
};

function ContributionsPanel({ goal, accounts, onSaved, active, reloadTick }) {
  const [data,   setData]   = React.useState(null);
  const [err,    setErr]    = React.useState(null);
  const [busy,   setBusy]   = React.useState(false);

  // Inline edit state: {id, amount} of the row being corrected.
  const [editing, setEditing] = React.useState(null);

  // Add form state.
  const today = new Date().toISOString().slice(0, 10);
  const [fDate,   setFDate]   = React.useState(today);
  const [fBucket, setFBucket] = React.useState(accounts[0] || "");
  const [fAmount, setFAmount] = React.useState("");
  const [fNotes,  setFNotes]  = React.useState("");
  const [fErr,    setFErr]    = React.useState(null);

  const load = React.useCallback(() => {
    api(`/api/portfolio/contributions/${goal}`)
      .then(r => r.ok ? setData(r.data) : setErr(r.data?.error || "Failed to load contributions"));
  }, [goal]);

  // `load` only touches `data` — the record-contribution form fields are
  // separate state — so a reload from another tab's save can never discard a
  // half-typed entry, and needs no dirty guard (unlike the other two panels).
  React.useEffect(() => { load(); }, [load, reloadTick]);

  const handleDismissDelete = async (c) => {
    setBusy(true); setErr(null);
    const r = await api(`/api/portfolio/contributions/${goal}/${c.id}`, { method: "DELETE" });
    setBusy(false);
    if (r.ok) { load(); onSaved?.(); }
    else setErr(r.data?.error || "Action failed");
  };

  const handleCorrect = async () => {
    if (!editing) return;
    const amt = parseFloat(editing.amount);
    if (!amt || isNaN(amt)) { setErr("Enter a valid amount"); return; }
    setBusy(true); setErr(null);
    const r = await api(`/api/portfolio/contributions/${goal}/${editing.id}`,
                        { method: "PUT", body: { amount: amt } });
    setBusy(false);
    if (r.ok) { setEditing(null); load(); onSaved?.(); }
    else setErr(r.data?.error || "Correction failed");
  };

  const handleValidate = async (c) => {
    setBusy(true); setErr(null);
    const r = await api(`/api/portfolio/contributions/${goal}/${c.id}/validate`,
                        { method: "PATCH" });
    setBusy(false);
    if (r.ok) load();
    else setErr(r.data?.error || "Action failed");
  };

  const handleAdd = async () => {
    setFErr(null);
    const amt = parseFloat(fAmount);
    if (!fDate) { setFErr("Date is required"); return; }
    if (!amt || isNaN(amt)) { setFErr("Enter a valid amount"); return; }
    setBusy(true);
    const r = await api(`/api/portfolio/contributions/${goal}`, {
      method: "POST",
      body: { date: fDate, amount: amt, bucket: fBucket || null, notes: fNotes || null },
    });
    setBusy(false);
    if (r.ok) { setFAmount(""); setFNotes(""); load(); onSaved?.(); }
    else setFErr(r.data?.error || "Failed to record");
  };

  return (
    <ContributionsPanelBody
      active={active} data={data} err={err} busy={busy}
      editing={editing} setEditing={setEditing}
      fDate={fDate} setFDate={setFDate}
      fAmount={fAmount} setFAmount={setFAmount}
      fBucket={fBucket} setFBucket={setFBucket}
      fNotes={fNotes} setFNotes={setFNotes}
      fErr={fErr} accounts={accounts}
      handleCorrect={handleCorrect} handleValidate={handleValidate}
      handleDismissDelete={handleDismissDelete} handleAdd={handleAdd}
    />
  );
}

// Pure presentational body — the YTD summary, contribution list, and "Record
// contribution" form. Split out from ContributionsPanel so a Help figure can
// render the exact same markup fed local sample state instead of a live
// /api/portfolio/contributions fetch (ContributionsPanel itself always
// fetches on mount, which a Help article can't do for an anonymous reader or
// a goal that doesn't exist). No behavior change to the real modal — this is
// a pure extraction.
function ContributionsPanelBody({
  active, data, err, busy,
  editing, setEditing,
  fDate, setFDate, fAmount, setFAmount, fBucket, setFBucket, fNotes, setFNotes, fErr,
  accounts, handleCorrect, handleValidate, handleDismissDelete, handleAdd,
}) {
  const contribs = data?.contributions || [];
  const ytd      = data?.ytd_total || 0;

  const BucketChip = ({ b }) => b ? (
    <span style={{display:"inline-block",padding:"1px 7px",borderRadius:10,fontSize:10,
                  fontFamily:"'IBM Plex Mono',monospace",letterSpacing:".04em",
                  background:(BUCKET_COLOR[b]||"#8e99a4")+"22",
                  color:BUCKET_COLOR[b]||"#8e99a4",border:`1px solid ${BUCKET_COLOR[b]||"#8e99a4"}44`}}>
      {ACCT_COL_MAP[b]?.label || b.toUpperCase()}
    </span>
  ) : <span style={{color:"#647071",fontSize:11}}>All</span>;

  return (
    <div style={{display: active ? "flex" : "none", flexDirection:"column", flex:1, minHeight:0}}>

        {/* Body */}
        <div style={{overflowY:"auto",flex:1,padding:"18px 22px"}}>
          {err && <div style={{color:"#c0392b",fontSize:12,marginBottom:12}}>{err}</div>}

          {/* YTD summary */}
          {data && (
            <div style={{display:"flex",alignItems:"center",gap:16,marginBottom:18,
                         padding:"10px 14px",background:"#f5f0e8",borderRadius:8}}>
              <div>
                <div style={{fontSize:10,color:"#8e99a4",fontFamily:"'IBM Plex Mono',monospace",
                             letterSpacing:".08em",textTransform:"uppercase"}}>YTD Contributions</div>
                <div style={{fontSize:20,fontWeight:600,color:"#8a5709",fontFamily:"'IBM Plex Mono',monospace"}}>
                  {fmtDollar(ytd)}
                </div>
              </div>
              <div style={{flex:1}}/>
              <div style={{fontSize:11,color:"#647071"}}>{contribs.length} record{contribs.length !== 1 ? "s" : ""}</div>
            </div>
          )}

          {/* Contribution list */}
          {!data && !err && (
            <div style={{color:"#647071",fontSize:12,textAlign:"center",padding:"24px 0"}}>Loading…</div>
          )}
          {contribs.length === 0 && data && (
            <div style={{color:"#647071",fontSize:12,textAlign:"center",padding:"16px 0 8px"}}>
              No contributions recorded yet. Add one below or wait for auto-detection after the next report run.
            </div>
          )}
          {contribs.map(c => {
            const isAuto    = c.source === "auto";
            const isEditing = editing?.id === c.id;
            return (
              <div key={c.id} style={{padding:"9px 0",borderBottom:"1px solid #f0ede6"}}>
                <div style={{display:"flex",alignItems:"center",gap:10}}>
                  {/* Date */}
                  <div style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:11,color:"#8e99a4",
                               minWidth:82}}>{c.contrib_date}</div>
                  {/* Bucket */}
                  <div style={{minWidth:80}}><BucketChip b={c.bucket}/></div>
                  {/* Amount — static or inline editor */}
                  {isEditing ? (
                    <div style={{display:"flex",alignItems:"center",gap:6,flex:1}}>
                      <div style={{position:"relative"}}>
                        <span style={{position:"absolute",left:7,top:"50%",transform:"translateY(-50%)",
                                      fontSize:11,color:"#a1450f",fontFamily:"'IBM Plex Mono',monospace"}}>$</span>
                        <input autoFocus type="number" step="0.01"
                          value={editing.amount}
                          onChange={e => setEditing(ed => ({...ed, amount: e.target.value}))}
                          onKeyDown={e => { if (e.key === "Enter") handleCorrect(); if (e.key === "Escape") setEditing(null); }}
                          style={{width:110,padding:"4px 8px 4px 20px",border:"1px solid #5b4fa8",
                                  borderRadius:6,fontSize:12,fontFamily:"'IBM Plex Mono',monospace",
                                  background:"#fff"}}/>
                      </div>
                      <button onClick={handleCorrect} disabled={busy}
                        style={{background:"#1a4a6b",color:"#fff",border:"none",borderRadius:7,
                                padding:"4px 11px",fontSize:10,cursor:"pointer",
                                fontFamily:"'IBM Plex Mono',monospace",opacity:busy?0.6:1}}>
                        Save
                      </button>
                      <button onClick={() => setEditing(null)}
                        style={{background:"none",border:"1px solid #d4cfc5",borderRadius:7,
                                padding:"4px 11px",fontSize:10,color:"#8e99a4",cursor:"pointer",
                                fontFamily:"'IBM Plex Mono',monospace"}}>
                        Cancel
                      </button>
                    </div>
                  ) : (
                    <div style={{display:"flex",alignItems:"center",gap:6,flex:1}}>
                      <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:13,fontWeight:600,
                                   color: c.amount >= 0 ? "#2d7a47" : "#c0392b"}}>
                        {c.amount >= 0 ? "+" : "−"}{fmtDollar(Math.abs(c.amount))}
                      </span>
                      {/* Correct button — available on both auto and manual entries */}
                      <button onClick={() => setEditing({id: c.id, amount: String(Math.abs(c.amount))})}
                        title="Correct amount"
                        style={{background:"none",border:"none",padding:"0 2px",cursor:"pointer",
                                fontSize:11,color:"#b0bec5",lineHeight:1}}>✎</button>
                    </div>
                  )}
                  {/* Source badge */}
                  {isAuto && !isEditing && (
                    <span style={{fontSize:9,fontFamily:"'IBM Plex Mono',monospace",
                                  background:"#fff3cd",color:"#856404",border:"1px solid #ffc10766",
                                  borderRadius:8,padding:"1px 7px",letterSpacing:".04em"}}>auto</span>
                  )}
                  {/* Validate toggle */}
                  {!isEditing && (
                    <button onClick={() => !busy && handleValidate(c)} disabled={busy}
                      title={c.validated ? "Mark as unvalidated" : "Mark as validated"}
                      style={{background: c.validated ? "rgba(245,166,35,.16)" : "none",
                              border: `1px solid ${c.validated ? "#8a5709" : "#d4cfc5"}`,
                              borderRadius:8, padding:"2px 9px", fontSize:11,
                              color: c.validated ? "#8a5709" : "#c8d0d8",
                              cursor:"pointer", lineHeight:1,
                              fontFamily:"'IBM Plex Mono',monospace"}}>
                      ✓
                    </button>
                  )}
                  {/* Dismiss / Delete button */}
                  {!isEditing && (
                    <button onClick={() => !busy && handleDismissDelete(c)} disabled={busy}
                      style={{background:"none",border:"1px solid #d4cfc5",borderRadius:8,
                              padding:"2px 9px",fontSize:10,color:"#8e99a4",cursor:"pointer",
                              fontFamily:"'IBM Plex Mono',monospace",letterSpacing:".04em",
                              whiteSpace:"nowrap"}}>
                      {isAuto ? "Dismiss" : "Delete"}
                    </button>
                  )}
                </div>
                {/* Notes */}
                {c.notes && !isEditing && (
                  <div style={{fontSize:10,color:"#647071",marginTop:3,paddingLeft:92}}>{c.notes}</div>
                )}
              </div>
            );
          })}
          {/* Note row for auto entries */}
          {contribs.some(c => c.source === "auto") && (
            <div style={{fontSize:10,color:"#647071",marginTop:6,fontStyle:"italic"}}>
              Auto-detected entries are estimates. Use ✎ to correct the amount, or Dismiss if it was price appreciation or an internal transfer.
            </div>
          )}
        </div>

        {/* Add contribution form */}
        <div style={{borderTop:"1px solid #e3ddd1",padding:"16px 22px",flexShrink:0}}>
          <div style={{fontSize:11,color:"#8e99a4",fontFamily:"'IBM Plex Mono',monospace",
                       letterSpacing:".08em",textTransform:"uppercase",marginBottom:12}}>
            Record contribution
          </div>
          {fErr && <div style={{color:"#c0392b",fontSize:11,marginBottom:8}}>{fErr}</div>}
          <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:10,marginBottom:10}}>
            <div>
              <div style={{fontSize:10,color:"#8e99a4",marginBottom:4}}>Date</div>
              <input type="date" value={fDate} onChange={e => setFDate(e.target.value)}
                style={{width:"100%",padding:"7px 10px",border:"1px solid #8b9199",borderRadius:7,
                        fontSize:12,fontFamily:"'IBM Plex Mono',monospace",background:"#fff",
                        boxSizing:"border-box"}}/>
            </div>
            <div>
              <div style={{fontSize:10,color:"#8e99a4",marginBottom:4}}>Amount ($)</div>
              <div style={{position:"relative"}}>
                <span style={{position:"absolute",left:9,top:"50%",transform:"translateY(-50%)",
                              fontSize:12,color:"#a1450f",fontFamily:"'IBM Plex Mono',monospace"}}>$</span>
                <input type="number" min="0" step="0.01" value={fAmount}
                  onChange={e => setFAmount(e.target.value)}
                  placeholder="0.00"
                  style={{width:"100%",padding:"7px 10px 7px 22px",border:"1px solid #8b9199",
                          borderRadius:7,fontSize:12,fontFamily:"'IBM Plex Mono',monospace",
                          background:"#fff",boxSizing:"border-box"}}/>
              </div>
            </div>
          </div>
          {/* Bucket pills */}
          {accounts.length > 0 && (
            <div style={{marginBottom:10}}>
              <div style={{fontSize:10,color:"#8e99a4",marginBottom:6}}>Account</div>
              <div style={{display:"flex",gap:6,flexWrap:"wrap"}}>
                {accounts.map(b => {
                  const sel = fBucket === b;
                  const col = BUCKET_COLOR[b] || "#8e99a4";
                  return (
                    <button key={b} onClick={() => setFBucket(b)}
                      style={{padding:"4px 13px",borderRadius:12,fontSize:11,cursor:"pointer",
                              fontFamily:"'IBM Plex Mono',monospace",letterSpacing:".04em",
                              background: sel ? col : col+"18",
                              color: sel ? "#fff" : col,
                              border:`1px solid ${col}${sel?"":"44"}`,
                              fontWeight: sel ? 600 : 400}}>
                      {ACCT_COL_MAP[b]?.label || b.toUpperCase()}
                    </button>
                  );
                })}
              </div>
            </div>
          )}
          {/* Notes */}
          <div style={{marginBottom:12}}>
            <div style={{fontSize:10,color:"#8e99a4",marginBottom:4}}>Notes (optional)</div>
            <input type="text" value={fNotes} onChange={e => setFNotes(e.target.value)}
              placeholder="e.g. TFSA top-up, annual deposit…"
              style={{width:"100%",padding:"7px 10px",border:"1px solid #8b9199",borderRadius:7,
                      fontSize:12,background:"#fff",boxSizing:"border-box"}}/>
          </div>
          <button onClick={handleAdd} disabled={busy || !fAmount}
            style={{background:"#1a4a6b",color:"#fff",border:"none",borderRadius:9,
                    padding:"9px 22px",fontSize:12,fontFamily:"'IBM Plex Mono',monospace",
                    fontWeight:600,cursor:busy||!fAmount?"default":"pointer",
                    opacity:busy||!fAmount?0.6:1}}>
            {busy ? "Saving…" : "Record"}
          </button>
        </div>
    </div>
  );
}

// ── Accounts panel (Manage modal, third tab) ───────────────────────────────────
// "Which accounts does this plan cover?" — a plan-level fact, but the moment a
// user needs to change it (a year in, opening their first non-registered
// account) they are on the Save tab holding a statement. ips_documents.accounts
// gates both the Holdings editor's bucket columns (app.holdings_edit_get) and
// which buckets a manual contribution may name (app.contributions_add), so
// without this panel the only route was Plan → Accounts step → Accept Plan →
// back to Save (see plan-tab.md § "Accounts on a funded goal").
//
// Reads the SAME GET /api/portfolio/holdings-edit/<goal> response the wizard's
// Accounts step reads — no new read endpoint — and writes through
// PATCH /api/portfolio/accounts/<goal>, which deliberately does NOT bump
// version/updated_at (that would re-sign the plan behind the user's back). The
// list takes effect at once; the *signature* goes stale via needs_review_at.
// Order-insensitive: unticking an account and reticking it moves it to the end
// of `sel`, which is not a change. (`stableStringify` does not sort arrays —
// the wizard-form comparison it also backs is deliberately order-sensitive.)
// Shared by the panel's reload guard and its own dirty/footer state, so the two
// can never disagree about whether there's work to lose.
function accountsDirty(sel, savedSel) {
  return stableStringify([...(sel || [])].sort()) !== stableStringify([...(savedSel || [])].sort());
}

function AccountsEditPanel({ goal, active, onSaved, onPlanChanged, reloadTick }) {
  const [data, setData]         = React.useState(null);
  const [sel, setSel]           = React.useState([]);
  const [savedSel, setSavedSel] = React.useState([]);
  const [busy, setBusy]         = React.useState(false);
  const [err, setErr]           = React.useState(null);
  const [flash, setFlash]       = React.useState(false);

  // Read inside the reload effect without making it a dependency.
  const dirtyRef = React.useRef(false);
  React.useEffect(() => { dirtyRef.current = accountsDirty(sel, savedSel); }, [sel, savedSel]);

  // `reloadTick` bumps when another tab saves — above all the Holdings tab,
  // which is where a bucket gets emptied. The locks here are derived from those
  // balances, so without this reload a just-zeroed account stayed padlocked
  // until the modal was closed and reopened: exactly the "zero it out, then
  // remove it" flow this panel exists to serve.
  React.useEffect(() => {
    api(`/api/portfolio/holdings-edit/${goal}`).then(r => {
      if (!r.ok) { setErr(r.data?.error || "Failed to load accounts"); return; }
      const accts = Array.isArray(r.data.accounts) ? r.data.accounts : [];
      // Balances and the persisted account list are server-owned and always
      // refresh; `sel` is the user's in-progress ticking, kept when dirty.
      setData(r.data);
      setSavedSel(accts);
      if (!dirtyRef.current) setSel(accts);
    }).catch(() => setErr("Network error"));
  }, [goal, reloadTick]);

  const discard = () => { setSel(savedSel); setErr(null); };

  const save = async () => {
    setBusy(true);
    setErr(null);
    const r = await api(`/api/portfolio/accounts/${goal}`, { method: "PATCH", body: { accounts: sel } });
    setBusy(false);
    if (!r.ok) { setErr(r.data?.error || "Save failed"); return; }
    const accts = (r.data && r.data.accounts) || sel;
    setSel(accts);
    setSavedSel(accts);
    // Keep the locked/held rows honest without a second round trip — the
    // balances didn't change, only which accounts the plan names.
    setData(d => d ? { ...d, accounts: accts } : d);
    setFlash(true);
    setTimeout(() => setFlash(false), 2200);
    onSaved && onSaved();          // refetch the Save tab's overview
    onPlanChanged && onPlanChanged();  // let the Plan tab re-read /api/ips (review now due)
  };

  return (
    <AccountsEditPanelBody
      active={active} goal={goal} data={data} err={err} busy={busy} flash={flash}
      sel={sel} setSel={setSel} savedSel={savedSel} save={save} discard={discard}
    />
  );
}

// Pure presentational body — split out from AccountsEditPanel for the same
// reason ContributionsPanelBody / HoldingsEditPanelBody were: a Help figure can
// mount it against local state instead of a live fetch. `save`/`discard` stay
// props, since they're where real persistence happens.
//
// Rows mirror the wizard's Accounts step and SHARE its derivation
// (fundedAccountRows / ACCT_BUCKET_COLS) rather than growing a parallel one —
// that grouping by DB bucket column is what keeps a shared TFSA/FHSA column
// from being double-counted and the RRSP row inclusive of srrsp. Three states,
// same affordances as the wizard: padlock = held and fixed, green tick = in the
// plan and removable, empty box = not in the plan.
function AccountsEditPanelBody({ active, goal, data, err, busy, flash, sel, setSel, savedSel, save, discard }) {
  const availableAccounts = ALL_ACCOUNTS.filter(a => a.goals.includes(goal));
  // Derived from `savedSel`, NOT the in-flight `sel`: an account that shares a
  // funded column (FHSA over a funded TFSA) would otherwise jump straight from
  // "just ticked" into the locked row and become un-untickable — a mis-tick has
  // to stay undoable, exactly as it is in the wizard's addable list. It also
  // keeps the rows from re-ordering under the cursor while ticking.
  const fundedRows = React.useMemo(
    () => (data ? fundedAccountRows(data, availableAccounts, savedSel) : null),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [data, goal, savedSel]);
  // Only a column that actually HOLDS money is locked — the panel must never
  // become a way to delete holdings by unticking a box (the server refuses it
  // too; the client list is never trusted). Everything else stays live.
  const heldRows  = fundedRows ? fundedRows.filter(r => r.balance > 0) : null;
  const lockedIds = new Set(heldRows ? heldRows.flatMap(r => (r.named.length ? r.named : r.ids)) : []);
  const addable   = availableAccounts.filter(a => !lockedIds.has(a.id));

  const dirty = accountsDirty(sel, savedSel);
  const empty = sel.length === 0;

  const toggle = (id) => {
    setSel(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
  };

  return (
    <div style={{display: active ? "flex" : "none", flexDirection:"column", flex:1, minHeight:0}}>
      <div style={{flexGrow:1,minHeight:0,overflowY:"auto",padding:"18px 22px 4px"}}>
        {!data && !err && (
          <div style={{color:"#647071",fontSize:12,textAlign:"center",padding:"24px 0"}}>Loading…</div>
        )}
        {data && (
          <>
            <div className="sp-hint" style={{marginBottom:14}}>
              Adjust the accounts this savings plan covers -- unless an account is present here you can't record holdings or contributions to it. Balances live on the{" "}
              <strong>Holdings</strong> tab.
            </div>

            {heldRows && heldRows.length > 0 && (
              <>
                <div className="acct-list">
                  {heldRows.map(r => (
                    <div key={r.key} className="acct-card sel acct-locked"
                      title="Holds money — clear it on the Holdings tab before removing it from the plan">
                      <div className="acct-check">
                        <i className="ti ti-lock" aria-hidden="true"/>
                      </div>
                      <div style={{flex:1}}>
                        <div className="acct-name">{r.label}</div>
                        <div className="acct-desc">{r.desc}</div>
                      </div>
                      <div className="acct-bal">{fmt$(Math.round(r.balance))}</div>
                    </div>
                  ))}
                </div>
                <div className="sp-hint" style={{marginTop:8,marginBottom:16,fontSize:12}}>
                  An account holding money can't be removed until all it's holdings are
                  removed from the Holdings tab first.
                </div>
              </>
            )}

            {addable.length > 0 && (
              <div className="acct-list" style={{marginTop: heldRows && heldRows.length ? 0 : 4}}>
                {addable.map(a => {
                  const on = sel.includes(a.id);
                  return (
                    <div key={a.id} className={`acct-card${on ? " sel" : ""}`} onClick={() => toggle(a.id)}>
                      <div className="acct-check">
                        {on && (
                          <svg width="9" height="7" viewBox="0 0 9 7" fill="none">
                            <path d="M1 3.5L3 5.5L8 1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
                          </svg>
                        )}
                      </div>
                      <div style={{flex:1}}>
                        <div className="acct-name">{a.label}</div>
                        <div className="acct-desc">{a.desc}</div>
                      </div>
                      {/* No balance field — that's the Holdings tab's job. */}
                      <div className="acct-bal acct-bal-none">{on ? "no holdings yet" : ""}</div>
                    </div>
                  );
                })}
              </div>
            )}

            {/* Say it BEFORE the save, not after — a re-sign requirement should
                never first be discovered from a banner on another tab. */}
            <div style={{marginTop:16,padding:"9px 12px",background:"#fffbea",
                         border:"1px solid #e8d96a",borderRadius:6,
                         fontSize:12,lineHeight:1.5,color:"#5a3e00"}}>
              Changing this updates your savings portfolio immediately, but your plan document then needs a
              quick review and re-signing to confirm which accounts you hold and withdrawal order.
            </div>
          </>
        )}
      </div>

      <div style={{borderTop:"1px solid #d4cfc5",padding:"12px 20px",flexShrink:0,
                   display:"flex",alignItems:"center",justifyContent:"space-between"}}>
        <span style={{fontFamily:"system-ui",fontSize:11.5,minHeight:18,
                      color: (err || (dirty && empty)) ? "#c0392b" : "#2d7a47"}}>
          {err || (dirty && empty ? "Keep at least one account" : (flash ? "Accounts updated" : ""))}
        </span>
        <div style={{display:"flex",gap:8}}>
          {dirty && (
            <button onClick={discard}
              style={{background:"none",border:"1px solid #d4cfc5",borderRadius:4,
                      padding:"5px 14px",fontSize:12,fontFamily:"system-ui",
                      cursor:"pointer",color:"#5a6a72"}}>
              Discard
            </button>
          )}
          <button onClick={save} disabled={!dirty || busy || empty}
            style={{background:(dirty && !empty)?"#1a4a6b":"#647071",color:"#fff",border:"none",
                    borderRadius:4,padding:"5px 16px",fontSize:12,fontFamily:"system-ui",
                    cursor:(dirty&&!busy&&!empty)?"pointer":"default",opacity:busy?0.7:1}}>
            {busy ? "Saving…" : "Save"}
          </button>
        </div>
      </div>
    </div>
  );
}

// ── Combined portfolio edit modal ───────────────────────────────────────────────
// Single modal launched from the "⚙ Manage" gear in the Save-tab section head (and
// from the Contributions / Edit pills in SavingsModuleCard). Hosts the contributions
// and holdings editors as two tabs. Both panels stay mounted (display-toggled) so
// in-progress edits survive a tab switch; each panel loads its own data on mount.
// The modal chrome (backdrop + topbar + tab nav) with no data concerns of its
// own — split out from PortfolioEditModal so a Help figure can open the exact
// same modal shell around locally-stated sample content instead of the real,
// network-backed panels. No behavior change to the real modal.
function PortfolioEditModalShell({ goalLabel, tab, setTab, onClose, children, demo, wide }) {
  const tabBtn = (id, label) => {
    const on = tab === id;
    return (
      <button key={id} onClick={() => setTab(id)}
        style={{border:"none",background:"none",cursor:"pointer",
                fontFamily:"'IBM Plex Mono',monospace",fontSize:12,letterSpacing:".03em",
                padding:"10px 2px",marginBottom:-1,
                color: on ? "#1a1a1a" : "#8e99a4",
                borderBottom:`2px solid ${on ? "#e08e12" : "transparent"}`,
                fontWeight: on ? 600 : 400,transition:"color .12s"}}>
        {label}
      </button>
    );
  };

  return ReactDOM.createPortal(
    <div onClick={e => e.target === e.currentTarget && onClose()}
         style={{position:"fixed",inset:0,background:"rgba(30,28,25,.55)",display:"flex",
                 alignItems:"center",justifyContent:"center",zIndex:1200,padding:24}}>
      <div style={{background:"#faf9f7",borderRadius:14,
                   width: wide ? "min(920px,96vw)" : "min(680px,96vw)",
                   height:"min(760px,92vh)",
                   display:"flex",flexDirection:"column",boxShadow:"0 8px 32px rgba(0,0,0,.20)",
                   overflow:"hidden"}}>

        {/* Topbar: gear + title + close */}
        <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",
                     padding:"16px 22px 0",flexShrink:0}}>
          <div style={{display:"flex",alignItems:"center",gap:10}}>
            <i className="ti ti-settings" style={{fontSize:16,color:"#8a5709"}} aria-hidden="true"/>
            <div>
              {demo && (
                <div style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10,letterSpacing:".14em",
                             textTransform:"uppercase",color:"#a1450f",marginBottom:2}}>
                  Walk through
                </div>
              )}
              <div style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:14,fontWeight:600,
                           color:"#2c3e50",letterSpacing:".02em"}}>
                Manage {goalLabel} Portfolio{demo && <span style={{fontWeight:400,color:"#8e99a4"}}> — nothing is saved</span>}
              </div>
            </div>
          </div>
          <button onClick={onClose} aria-label="Close"
            style={{background:"none",border:"none",fontSize:20,color:"#8e99a4",cursor:"pointer",
                    lineHeight:1,padding:"2px 6px"}}>×</button>
        </div>

        {/* Tab nav */}
        <div style={{display:"flex",gap:22,padding:"8px 22px 0",borderBottom:"1px solid #e3ddd1",
                     flexShrink:0}}>
          {tabBtn("holdings","Holdings")}
          {tabBtn("contributions","Contributions")}
          {/* Accounts last — it's the rarest edit, and a modal opened
              generically must still land on Holdings. Hidden for the two Help
              figures, which mount their own single panel. */}
          {!demo && tabBtn("accounts","Accounts")}
        </div>

        {/* Panels — both mounted, display-toggled to preserve in-progress edits */}
        {children}
      </div>
    </div>,
    document.body
  );
}

function PortfolioEditModal({ goal, goalLabel, accounts, initialTab, onClose, onSaved, onPlanChanged, pendingCsvFile, onCsvFileConsumed }) {
  const [tab, setTab] = React.useState(initialTab || "holdings");
  // Wider shell for the Holdings tab's multi-bucket table (RRSP/TFSA/Taxable
  // and beyond) — 2+ distinct account columns need the extra room; a single-
  // account goal keeps the original, more compact width.
  const wide = goalAcctColsFor(accounts).length > 1;

  // ── Cross-tab redraw ──────────────────────────────────────────────────────
  // All three panels stay mounted and each loads on mount, so a save on one tab
  // used to leave the others showing pre-save data until the modal was closed
  // and reopened. That broke the one workflow that spans two tabs: zero a
  // bucket out on Holdings, then remove that account on Accounts — where the
  // account stayed padlocked because its lock is derived from the balances the
  // Accounts panel fetched on mount. (And the reverse: an account added here
  // grew no column on Holdings.)
  //
  // One tick PER TAB, and a save bumps every tab EXCEPT its own. Bumping the
  // saving panel too would make it reseed from the server right after it has
  // re-baselined itself — re-running the Holdings path inference over a path
  // the user may have picked by hand. Saving must NOT close the modal instead
  // (save-tab.md § "What not to change"): the user may keep editing.
  const [ticks, setTicks] = React.useState({ holdings: 0, contributions: 0, accounts: 0 });
  const savedFrom = (src) => {
    onSaved && onSaved();
    setTicks(t => Object.fromEntries(
      Object.entries(t).map(([k, v]) => [k, k === src ? v : v + 1])));
  };

  return (
    <PortfolioEditModalShell goalLabel={goalLabel} tab={tab} setTab={setTab} onClose={onClose} wide={wide}>
      <ContributionsPanel goal={goal} accounts={accounts} onSaved={() => savedFrom("contributions")}
        active={tab === "contributions"} reloadTick={ticks.contributions} />
      <HoldingsEditPanel  goal={goal} onSaved={() => savedFrom("holdings")}
        active={tab === "holdings"} reloadTick={ticks.holdings}
        pendingCsvFile={pendingCsvFile} onCsvFileConsumed={onCsvFileConsumed} />
      <AccountsEditPanel  goal={goal} onSaved={() => savedFrom("accounts")} onPlanChanged={onPlanChanged}
        active={tab === "accounts"} reloadTick={ticks.accounts} />
    </PortfolioEditModalShell>
  );
}

// Maps plan account IDs to the holdings column they share.
// FHSA shares tfsa_amount with TFSA in the DB.
const _ACCT_TO_HOLD_COL = { rrsp:"rrsp", tfsa:"tfsa", fhsa:"tfsa", taxable:"taxable", srrsp:"srrsp", resp:"resp" };

// ── Savings module: two side-by-side cards, each with its own management button.
// Left: contributions chart + Contributions modal button.
// Right: holdings table (no Total/% columns) + Edit modal button.
function SavingsModuleCard({ goal, goalLabel, holds, total, accounts, history, annualSavings, onSaved, onEdit, onUploadCsv, onNavigate }) {
  const openHelp = (articleId) => onNavigate("help", { tab: "portfolio", articleId });

  const hasRrsp    = holds.some(h => h.rrsp    > 0);
  const hasTfsa    = holds.some(h => h.tfsa    > 0);
  const hasTaxable = holds.some(h => h.taxable > 0);
  const hasSrrsp   = holds.some(h => h.srrsp   > 0);
  const hasResp    = holds.some(h => h.resp    > 0);

  const fmtA = n => n > 0 ? fmtCompact(n) : <span className="pf-hold-zero">—</span>;
  const cols = `72px 1fr${hasRrsp?" 80px":""}${hasTfsa?" 80px":""}${hasTaxable?" 80px":""}${hasSrrsp?" 80px":""}${hasResp?" 80px":""}`;

  const pillStyle = {background:"#faf9f7",border:"1px solid #d4cfc5",borderRadius:12,
                     padding:"3px 11px",fontFamily:"'IBM Plex Mono',monospace",fontSize:10,
                     color:"#5a6a72",cursor:"pointer",letterSpacing:".04em",lineHeight:1.6};

  const hasHolds   = holds.length > 0;
  const hasHistory = history?.dates?.length > 0;

  // CSV round trip's two entry points (save-tab.md § Portfolio CSV
  // round-trip) — icon buttons beside the gear, not inside the Manage modal:
  // the modal is small on purpose, and Upload opens straight into its
  // review banner rather than adding chrome to a panel that already has a
  // path picker, a table and an allocation bar.
  const [csvErr, setCsvErr] = React.useState(null);
  const csvFileRef = React.useRef(null);

  const handleDownloadCsv = async () => {
    setCsvErr(null);
    try {
      await downloadHoldingsCsv(goal);
    } catch (e) {
      setCsvErr(e.message || "Download failed");
    }
  };

  const csvIconBtnStyle = {
    display: "inline-flex", alignItems: "center", justifyContent: "center",
    width: 26, height: 26, padding: 0,
    background: "#f0ede6", border: "1px solid #d4cfc5", borderRadius: 5,
    color: "#5a6a72", cursor: "pointer", fontSize: 13,
  };

  return (
      <div style={{display:"flex",gap:12,marginTop:12,marginBottom:12,alignItems:"stretch"}}>

        {/* Left card: Contributions over time */}
        <div className="pf-proj-card" style={{flex:1,minWidth:0}}>
          <div className="pf-proj-head" style={{marginBottom:10}}>
            <span style={{display:"inline-flex",alignItems:"center"}}>
              <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12.5,fontWeight:500,color:"#1a1a1a"}}>
                Contributions over time
              </span>
              <button type="button" className="sp-inline-help"
                onClick={() => openHelp("tracking-contributions")} title="Help">?</button>
            </span>
            <button onClick={() => onEdit("contributions")} title="Manage contributions"
              style={{...pillStyle,fontSize:14,lineHeight:1,padding:"4px 10px",
                      display:"inline-flex",alignItems:"center"}}>
              <i className="ti ti-settings" aria-hidden="true"/>
            </button>
          </div>
          {hasHistory
            ? <ContribHistoryChart history={history} annualSavings={annualSavings} />
            : <div style={{display:"flex",alignItems:"center",justifyContent:"center",
                           minHeight:160,color:"#94a3b8",fontFamily:"system-ui",fontSize:11}}>
                No contribution history yet
              </div>
          }
        </div>

        {/* Right card: Holdings (no Total / % columns) */}
        {hasHolds && (
          <div className="pf-proj-card" style={{flex:1,minWidth:0,overflowX:"auto"}}>
            <div className="pf-proj-head" style={{marginBottom:10}}>
              <span style={{display:"inline-flex",alignItems:"center"}}>
                <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12.5,fontWeight:500,color:"#1a1a1a"}}>
                  Holdings
                </span>
                <button type="button" className="sp-inline-help"
                  onClick={() => openHelp("recording-holdings")} title="Help">?</button>
              </span>
              <div style={{display:"flex",gap:6,alignItems:"center"}}>
                <button onClick={handleDownloadCsv} title="Download holdings as a spreadsheet" style={csvIconBtnStyle}>
                  <i className="ti ti-download" aria-hidden="true"/>
                </button>
                <button onClick={() => csvFileRef.current?.click()} title="Re-import an edited spreadsheet" style={csvIconBtnStyle}>
                  <i className="ti ti-upload" aria-hidden="true"/>
                </button>
                <input ref={csvFileRef} type="file" accept=".csv,text/csv" style={{display:"none"}}
                  onChange={e => {
                    const f = e.target.files?.[0];
                    e.target.value = "";
                    if (f) { setCsvErr(null); onUploadCsv(f); }
                  }} />
                <button onClick={() => onEdit("holdings")} title="Manage holdings"
                  style={{...pillStyle,fontSize:14,lineHeight:1,padding:"4px 10px",
                          display:"inline-flex",alignItems:"center"}}>
                  <i className="ti ti-settings" aria-hidden="true"/>
                </button>
              </div>
            </div>
            {csvErr && (
              <div style={{marginTop:-4,marginBottom:8,fontSize:11,color:"#c0392b",fontFamily:"system-ui"}}>
                {csvErr}
              </div>
            )}
            <div className="pf-hold-table">
              <div style={{display:"grid",gridTemplateColumns:cols,gap:4,padding:"0 0 7px",
                           borderBottom:"1px solid #e3ddd1",fontFamily:"'IBM Plex Mono',monospace",
                           fontSize:"9.5px",letterSpacing:".1em",textTransform:"uppercase",color:"#647071"}}>
                <span>Ticker</span>
                <span>Asset Class</span>
                {hasRrsp    && <span style={{textAlign:"right"}}>RRSP</span>}
                {hasTfsa    && <span style={{textAlign:"right"}}>{accounts.includes("fhsa") && !accounts.includes("tfsa") ? "FHSA" : "TFSA"}</span>}
                {hasTaxable && <span style={{textAlign:"right"}}>Taxable</span>}
                {hasSrrsp   && <span style={{textAlign:"right"}}>sRRSP</span>}
                {hasResp    && <span style={{textAlign:"right"}}>RESP</span>}
              </div>
              {holds.map(h => (
                <div key={h.ticker} style={{display:"grid",gridTemplateColumns:cols,gap:4,padding:"7px 0",
                                            borderBottom:"1px solid #f0ede6",alignItems:"center"}}>
                  <span className="pf-hold-ticker">{h.ticker}</span>
                  <span className="pf-hold-cls">
                    <span className="pf-alloc-dot" style={{background:classColor(h.asset_class)}}/>
                    {AC_LABELS[h.asset_class] || h.asset_class}
                  </span>
                  {hasRrsp    && <span style={{textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:12}}>{fmtA(h.rrsp)}</span>}
                  {hasTfsa    && <span style={{textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:12}}>{fmtA(h.tfsa)}</span>}
                  {hasTaxable && <span style={{textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:12}}>{fmtA(h.taxable)}</span>}
                  {hasSrrsp   && <span style={{textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:12}}>{fmtA(h.srrsp)}</span>}
                  {hasResp    && <span style={{textAlign:"right",fontFamily:"'IBM Plex Mono',monospace",fontSize:12}}>{fmtA(h.resp)}</span>}
                </div>
              ))}
            </div>
          </div>
        )}

      </div>
  );
}

// ── Optimizer (Save tab, own section below Projections) ───────────────────────
//
// Mirrors `app._OPT_UNIVERSES` / `_OPT_UNIVERSES_FREE`. `premium: false` marks the
// free subset; the two lists are cross-checked by dev/test_optimizer.py, because a
// mode only one side knows about is either a dead pill or a 402 on click.
//
// These pills are a SHOP WINDOW, not the gate. `GET /api/portfolio/optimize`
// refuses a premium mode with 402 whatever the browser sends — see
// `plan.md § Premium tier`, "Do not gate a premium feature in the frontend only".
const OPT_UNIVERSES = [
  // Three SOURCES, not three widths of one list. Each is its own registry query
  // plus the funds this goal holds or targets (those are always in, or the two
  // overlay markers would be drawing a portfolio the user does not have).
  { id: "core",      premium: false, label: "Core + your funds",
    blurb: "The core list — about a dozen low-cost ETFs that between them reach most "
         + "of the world's investable assets, several with a climate mandate — plus "
         + "everything this goal holds or targets. Short on purpose: a few broad funds "
         + "cover the same market as a long list, without the overlap." },
  { id: "watchlist", premium: true,  label: "Watchlist",
    blurb: "Only your Track watchlist — the core funds are NOT included." },
  { id: "holdings",  premium: true,  label: "My portfolio only",
    blurb: "Restricts the universe to what you already hold and target." },
];

// The three named allocations, in the order they are offered. Colours are
// semantic, not categorical: navy is the interaction/low-risk end, gold-with-
// bronze is the recommended one (style.md rule 2 — a gold fill carrying state
// always gets a bronze boundary), clay is the caution/high-risk end.
const OPT_PICKS = [
  { id: "max_sharpe", label: "Best risk-adjusted", color: "#f5a623",
    edge: "#8a5709", note: "Highest return per unit of risk" },
  { id: "min_vol",    label: "Lowest volatility",  color: "#1a4a6b",
    edge: "#1a4a6b", note: "The calmest ride on the frontier" },
  { id: "max_return", label: "Highest return",     color: "#c2571a",
    edge: "#c2571a", note: "The most growth the constraints allow" },
];

// The user's OWN two portfolios, selectable exactly like the three suggestions
// above — click either diamond (or its card) and the allocation table below
// switches to it. Ink, not a new hue: the suggestions own the colour on this
// chart, and these two are drawn as diamonds so shape alone tells them apart.
// `key` indexes `data.current`, which is what the API calls this block.
const OPT_OVERLAYS = [
  { id: "current", label: "What you hold today", color: "#1a1a1a", edge: "#1a1a1a",
    filled: true,  note: "Your holdings for this goal, priced on this universe" },
  { id: "plan",    label: "Your plan's target",  color: "#ffffff", edge: "#1a1a1a",
    filled: false, note: "The mix your plan says to hold" },
];

// The Save tab renders ONE mode. The response still carries both `forward` and
// `blend` — `portfolio-report` offers the choice, and the engine has to compute
// both to pick either — but a toggle here asked the user to arbitrate between two
// forecasts, which is not a judgement they have any basis to make. We take the
// house position (50/50) and state it in the footer instead. If a second mode ever
// belongs in this UI it should be a documented product decision, not a pill.
const OPT_MODE = "blend";

// The floor for a row in the allocation table. A 0.6% sliver is sampling residue,
// not a recommendation — and fourteen of them buried the four weights that
// actually matter. Applied to `max(selection, baseline)`, never to the selection
// alone: a fund the plan holds at 33% and the suggestion drops to 0.6% is the most
// important row on the table, and a one-sided filter would hide it.
const OPT_MIN_SHOWN = 0.01;

// `asset_registry.notes` is the fund's descriptive name, and some screened funds
// carry a trailing "ESG" token in it ("…Global Selection Equity Index ETF ESG") —
// which is also the keyword `allocation._is_esg` reads, so it cannot be edited out
// of the data without changing which funds count as screened. Strip it for display
// only, and only where the pill beside it already says the same thing. Anchored to
// a trailing standalone token: "ESG Corporate Bond" and "ESG Aware Emerging
// Markets Index" keep theirs, because there it is the name.
const optFundName = (name, esg) =>
  (esg && name ? name.replace(/\s+ESG$/, "") : name) || "";

// A scatter, not a time series, so it does NOT use the Track tab's 3:1 line-chart
// frame (style.md § SVG line charts) — both axes carry a quantity here, which is
// also why it has vertical gridlines where a line chart has none. Everything else
// follows that family: same gridline strokes, same mono axis labels in #647071,
// same hand-rolled SVG (no Chart.js — the no-build constraint).
// `markerLegend` names the five selectable markers in the legend. The Save tab
// turns it OFF because its selection cards sit directly below the chart carrying
// the same dot and the same label — six legend items above five identical cards
// is a wrapped row that teaches nothing. The Help figure has no cards, so it
// keeps the full legend and it is the default.
function FrontierChart({ data, mode, pick, onPick, markerLegend = true }) {
  const m     = data[mode] || {};
  const front = m.front || [];
  const cloud = m.cloud || [];
  const picks = m.picks || {};
  const cur   = (data.current || {}).current;
  const plan  = (data.current || {}).plan;

  const W = 600, H = 320, PL = 46, PR = 18, PT = 18, PB = 40;
  const cW = W - PL - PR, cH = H - PT - PB;

  // Domain over EVERY mark, overlays included. Clipping the user's own portfolio
  // off the edge because it sits outside the sampled cloud would hide the single
  // most useful comparison on the chart.
  const marks = [
    ...cloud.map(c => ({ vol: c[0], cagr: c[1] })),
    ...front,
    ...Object.values(picks),
    ...(cur  ? [cur[mode]]  : []),
    ...(plan ? [plan[mode]] : []),
  ].filter(p => p && isFinite(p.vol) && isFinite(p.cagr));
  if (!marks.length) return null;

  const xs = marks.map(p => p.vol), ys = marks.map(p => p.cagr);
  const pad = (lo, hi) => { const d = (hi - lo) || 1; return [lo - d * 0.1, hi + d * 0.1]; };
  const [x0, x1] = pad(Math.min(...xs), Math.max(...xs));
  const [y0, y1] = pad(Math.min(...ys), Math.max(...ys));
  const X = v => PL + ((v - x0) / (x1 - x0)) * cW;
  const Y = v => PT + cH - ((v - y0) / (y1 - y0)) * cH;

  const ticks = (lo, hi, n = 4) =>
    Array.from({ length: n + 1 }, (_, i) => lo + ((hi - lo) * i) / n);
  const yTicks = ticks(y0, y1), xTicks = ticks(x0, x1);

  const diamond = (x, y, r) => `${x},${y - r} ${x + r},${y} ${x},${y + r} ${x - r},${y}`;

  return (
    <>
      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", display: "block" }}>
        {yTicks.map((t, i) => (
          <g key={"y" + i}>
            <line x1={PL} x2={W - PR} y1={Y(t)} y2={Y(t)}
                  stroke="#ede9e0" strokeWidth={0.75} strokeDasharray="2 4"/>
            <text x={PL - 7} y={Y(t) + 3} textAnchor="end" fontSize={8.5}
                  fill="#647071" fontFamily="'IBM Plex Mono',monospace">
              {t.toFixed(1)}%
            </text>
          </g>
        ))}
        {xTicks.map((t, i) => (
          <g key={"x" + i}>
            <line y1={PT} y2={PT + cH} x1={X(t)} x2={X(t)}
                  stroke="#ede9e0" strokeWidth={0.75} strokeDasharray="2 4"/>
            <text x={X(t)} y={PT + cH + 14} textAnchor="middle" fontSize={9}
                  fill="#647071" fontFamily="'IBM Plex Mono',monospace">
              {t.toFixed(1)}
            </text>
          </g>
        ))}
        <text x={4} y={11} fontSize={9} fill="#647071"
              fontFamily="'IBM Plex Mono',monospace" letterSpacing=".08em">
          EXPECTED RETURN
        </text>
        <text x={PL + cW / 2} y={H - 6} textAnchor="middle" fontSize={9} fill="#647071"
              fontFamily="'IBM Plex Mono',monospace" letterSpacing=".08em">
          VOLATILITY (ANNUALISED %)
        </text>

        {/* Sampled interior portfolios — the haze the frontier is the edge of */}
        {cloud.map((c, i) => (
          <circle key={"c" + i} cx={X(c[0])} cy={Y(c[1])} r={1.8}
                  fill="#d4cfc5" opacity={0.5}/>
        ))}

        {/* The frontier itself — POINTS, NO CONNECTING LINE. A line through a
            sampled front is a lie about the shape: the points are the best of
            ~3000 random draws, so the gaps between them are sampling luck, and
            joining them draws a jagged staircase that reads as structure in the
            data. portfolio-report drew one, then removed it for exactly this
            reason; do not re-add it here. */}
        {front.map((p, i) => (
          <circle key={"f" + i} cx={X(p.vol)} cy={Y(p.cagr)} r={2.8}
                  fill="#1a4a6b" opacity={0.8}/>
        ))}

        {/* The three named picks, clickable. Two of them landing on the same
            portfolio is a REAL outcome, not a bug — on a near-linear frontier the
            tangency point IS the top end, which is exactly what the RA cash rate
            against these equity premia produces today. Draw the selected one last
            so it is never buried under a coincident twin. */}
        {OPT_PICKS.filter(pk => picks[pk.id])
          .sort((a, b) => (a.id === pick ? 1 : 0) - (b.id === pick ? 1 : 0))
          .map(pk => {
            const p  = picks[pk.id];
            const on = pick === pk.id;
            return (
              <circle key={pk.id} cx={X(p.vol)} cy={Y(p.cagr)} r={on ? 7 : 5}
                      fill={pk.color} stroke={on ? pk.edge : "#fff"}
                      strokeWidth={on ? 2 : 1.5} style={{ cursor: "pointer" }}
                      onClick={() => onPick(pk.id)}>
                <title>{`${pk.label} — ${p.cagr.toFixed(1)}% return, ${p.vol.toFixed(1)}% vol`}</title>
              </circle>
            );
          })}

        {/* "You are here" — and both are SELECTABLE, same as the three picks
            above: this is the comparison the chart exists for, so the user must
            be able to pull either one into the allocation table rather than only
            reading its position. Plan first so a coincident pair leaves the
            filled "today" diamond on top. */}
        {OPT_OVERLAYS.slice().reverse().map(ov => {
          const o = (data.current || {})[ov.id];
          const p = o && o[mode];
          if (!p || !isFinite(p.vol) || !isFinite(p.cagr)) return null;
          const on = pick === ov.id;
          return (
            <polygon key={ov.id} points={diamond(X(p.vol), Y(p.cagr), on ? 7.5 : 5.5)}
                     fill={ov.filled ? ov.color : "#fff"} stroke={ov.edge}
                     strokeWidth={on ? 2.4 : 1.6} style={{ cursor: "pointer" }}
                     onClick={() => onPick(ov.id)}>
              <title>{`${ov.label} — ${p.cagr.toFixed(1)}% return, ${p.vol.toFixed(1)}% vol`}</title>
            </polygon>
          );
        })}
      </svg>

      <div className="mb-legend" style={{ marginTop: 12, gap: "8px 16px", justifyContent: "center" }}>
        {markerLegend && OPT_PICKS.filter(p => picks[p.id]).map(p => (
          <span key={p.id}>
            <span className="mb-swatch" style={{ background: p.color, width: 9, height: 9, borderRadius: "50%" }}/>
            {p.label}
          </span>
        ))}
        {markerLegend && cur && <span><span className="mb-swatch" style={{ background: "#1a1a1a", width: 9, height: 9, transform: "rotate(45deg)" }}/>You hold today</span>}
        {markerLegend && plan && <span><span className="mb-swatch" style={{ background: "#fff", border: "1.5px solid #1a1a1a", width: 9, height: 9, transform: "rotate(45deg)" }}/>Your plan's target</span>}
        <span><span className="mb-swatch" style={{ background: "#1a4a6b", width: 9, height: 9, borderRadius: "50%" }}/>Efficient frontier</span>
        <span><span className="mb-swatch" style={{ background: "#d4cfc5", width: 9, height: 9, borderRadius: "50%" }}/>Sampled mixes</span>
      </div>
    </>
  );
}

// `dataKey` is the Save tab's two refresh counters joined. Both of this card's
// overlays are derived from user data the tab can edit — "what you hold today"
// from `ips_holdings`, "your plan's target" from the plan — so a holdings save or
// a plan save must move the diamonds. Without it the card keeps serving its own
// memo and the user's just-saved edit appears not to have applied. The refetch is
// cheap by construction: the server's frontier memo still hits, and only the
// ~1 ms overlay half is recomputed.
function OptimizerCard({ goal, goalLabel, user, onNavigate, dataKey }) {
  const [uni,  setUni]  = useState("core");
  const [pick, setPick] = useState("max_sharpe");
  const [data, setData] = useState(null);
  const [busy, setBusy] = useState(false);
  const [err,  setErr]  = useState(null);
  const [locked, setLocked] = useState(null);
  const cache = React.useRef({});
  // Cosmetics only, same contract as AfterTaxIncomeCard — `_user_is_premium` on
  // /api/portfolio/optimize is the boundary and answers 402 whatever the browser
  // sends. A premium user gets the two gated universes as ordinary, selectable
  // pills; the tag stays on them so it still reads as a premium feature.
  const isPrem = (user?.tier || "free") === "premium";

  const openHelp = () =>
    onNavigate && onNavigate("help", { tab: "portfolio", articleId: "the-efficient-frontier" });

  // Loads with the section — there is no "Run" button, and re-adding one would
  // not bound anything: nothing stopped a user clicking it in a loop, so it cost
  // a click and bought nothing. The real bound is server-side, where the sampled
  // frontier is memoised per universe (`app._optimizer_frontier`) and only the
  // per-user overlays are computed per request. The sampler is seeded, so a
  // cached frontier IS the frontier this call would have produced.
  //
  // The per-`goal|universe` memo below is the second half of that: flipping
  // between goals re-renders from memory rather than making the round trip at
  // all. A frontier belongs to the universe and the overlays it was built from,
  // so the two are cached together and never mixed.
  useEffect(() => {
    const key = goal + "|" + uni + "|" + dataKey;
    if (cache.current[key]) {
      setData(cache.current[key]); setErr(null); setLocked(null); setBusy(false);
      return;
    }
    let dead = false;
    setBusy(true); setErr(null); setLocked(null); setData(null);
    api(`/api/portfolio/optimize/${goal}?universe=${encodeURIComponent(uni)}`).then(r => {
      if (dead) return;
      setBusy(false);
      if (r.status === 402) { setLocked(uni); return; }
      if (!r.ok || !r.data) {
        setErr((r.data && r.data.error) || "Could not run the optimizer just now.");
        return;
      }
      cache.current[key] = r.data;
      setData(r.data);
    });
    return () => { dead = true; };
  }, [goal, uni, dataKey]);

  const ok       = !!(data && data.ok);
  const picks    = ok ? (data[OPT_MODE] || {}).picks || {} : {};
  const overlays = (data || {}).current || {};

  // Every selectable portfolio on this chart, in render order: the three
  // suggestions, then the user's own two. Deriving `activePick` from it (rather
  // than resetting `pick` in an effect) is what makes a stale selection
  // impossible — switching goal or universe can drop the selected marker, and a
  // `pick` naming a marker that is no longer there would render an empty table
  // with no way back.
  const available = React.useMemo(() => {
    if (!ok) return [];
    return [...OPT_PICKS.filter(p => picks[p.id]).map(p => p.id),
            ...OPT_OVERLAYS.filter(o => overlays[o.id]).map(o => o.id)];
  }, [ok, picks, overlays]);
  const activePick = available.includes(pick) ? pick : (available[0] || null);

  const isOverlay = OPT_OVERLAYS.some(o => o.id === activePick);
  const selM      = isOverlay ? (overlays[activePick] || {})[OPT_MODE] : picks[activePick];
  const selW      = isOverlay ? (overlays[activePick] || {}).weights : (selM || {}).w;

  // The comparison column. The plan's target is the anchor everything is read
  // against — except when the plan IS the selection, where comparing it with
  // itself would be a column of zeros; then it flips to what the user holds.
  const baseId    = activePick === "plan" ? "current" : "plan";
  const baseW     = (overlays[baseId] || {}).weights || null;
  const shortName = id => id === "current" ? "Today" : id === "plan" ? "Plan" : "Suggested";

  // Union of the selection and the baseline, so a fund the baseline holds and the
  // selection drops still shows — as a row going to zero, which is the whole
  // point of the comparison. Split at OPT_MIN_SHOWN rather than truncated: the
  // slivers are still SUMMARISED below the table, because a list of weights that
  // silently stops adding to 100% is worse than a long list.
  const { rows, rest } = React.useMemo(() => {
    const w = selW || {}, bw = baseW || {};
    const all = Object.keys({ ...w, ...bw })
      .map(t => ({ t, sug: w[t] || 0, plan: bw[t] || 0 }))
      .sort((a, b) => (b.sug - a.sug) || (b.plan - a.plan));
    const keep = all.filter(r => Math.max(r.sug, r.plan) >= OPT_MIN_SHOWN);
    const drop = all.filter(r => Math.max(r.sug, r.plan) <  OPT_MIN_SHOWN);
    return { rows: keep, rest: {
      n:    drop.length,
      sug:  drop.reduce((s, r) => s + r.sug, 0),
      plan: drop.reduce((s, r) => s + r.plan, 0),
    } };
  }, [selW, baseW]);

  const maxW = rows.reduce((mx, r) => Math.max(mx, r.sug, r.plan), 0) || 1;

  // A card for one selectable portfolio — the three suggestions and the user's
  // own two render through the same function so they stay in lockstep.
  const pickCard = (spec, metrics, diam) => (
    <button key={spec.id} className={`op-pick${activePick === spec.id ? " sel" : ""}`}
            title={spec.note} onClick={() => setPick(spec.id)}>
      <div className="op-pick-hd">
        <span className={"op-pick-dot" + (diam ? " diam" : "")}
              style={{ background: spec.color, boxShadow: `0 0 0 1px ${spec.edge}` }}/>
        <span className="op-pick-name">{spec.label}</span>
      </div>
      <div className="op-pick-val">{metrics.cagr.toFixed(1)}%</div>
      <div className="op-pick-sub">
        {metrics.vol.toFixed(1)}% vol
        {metrics.max_dd != null && <> · {Math.abs(metrics.max_dd).toFixed(0)}% worst drop</>}
      </div>
    </button>
  );

  return (
    <div className="pf-proj-card">
      <div className="op-head">
        <span style={{display:"inline-flex",alignItems:"center"}}>
          <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12.5,fontWeight:500,color:"#1a1a1a"}}>
            Efficient frontier
          </span>
          <span style={{fontSize:11.5,color:"#647071",marginLeft:8}}>
            {goalLabel}
          </span>
          {/* The panel's ONLY explanation. Everything this card used to say in
              footnotes — the constraints, the window, why a suggestion can miss
              a climate screen, why two picks can coincide — lives in the help
              article behind this chip. Do not grow the prose back into the
              panel; add it to `the-efficient-frontier` instead. */}
          <button type="button" className="sp-inline-help"
                  onClick={openHelp} title="How the frontier is built">?</button>
        </span>
        <div className="op-controls">
          <div className="op-uni">
            {OPT_UNIVERSES.map(u => {
              // A premium universe the user is entitled to is NOT locked — it
              // keeps the tag (it is still a premium feature) and loses the
              // dashed, unselectable treatment.
              const isLocked = u.premium && !isPrem;
              return (
                <button key={u.id}
                  className={`op-uni-pill${uni === u.id && !isLocked ? " active" : ""}${isLocked ? " locked" : ""}`}
                  title={u.blurb}
                  onClick={() => { if (isLocked) { setLocked(u.id); return; }
                                   setUni(u.id); }}>
                  {u.label}
                  {u.premium && <PremiumTag premium={isPrem}/>}
                </button>
              );
            })}
          </div>
        </div>
      </div>

      {locked && (
        <div className="op-note" style={{color:"#8a5709"}}>
          <i className="ti ti-lock" aria-hidden="true" style={{marginRight:5}}/>
          {(OPT_UNIVERSES.find(u => u.id === locked) || {}).label} is part of the
          premium plan. The core universe below is always available.
        </div>
      )}
      {err && <div className="pf-error" style={{marginTop:10}}>{err}</div>}

      {busy && !data && (
        <div style={{display:"flex",alignItems:"center",gap:12,padding:"26px 0"}}>
          <span className="pf-spinner"/>
          <span style={{fontSize:13,color:"#5a6a72"}}>
            Sampling portfolios and tracing the frontier…
          </span>
        </div>
      )}

      {data && !data.ok && (
        <p className="op-intro" style={{marginTop:4}}>
          {data.reason === "universe_too_small"
            ? "There aren't enough eligible funds with price history to build a frontier yet."
            : data.reason === "insufficient_common_history"
            ? "These funds don't share enough overlapping price history yet."
            : "No price history is available for this universe yet."}
        </p>
      )}

      {data && data.ok && (
        <>
          <FrontierChart data={data} mode={OPT_MODE} pick={activePick} onPick={setPick}
                         markerLegend={false}/>

          {/* Five selectable portfolios in one row: the three the optimizer
              suggests, then the user's own two. Same card, same click target —
              being able to pull your OWN mix into the table below is the point
              of drawing it on these axes at all. */}
          <div className="op-picks">
            {OPT_PICKS.map(p => picks[p.id] && pickCard(p, picks[p.id], false))}
            {OPT_OVERLAYS.map(o => {
              const m = (overlays[o.id] || {})[OPT_MODE];
              return m ? pickCard(o, m, true) : null;
            })}
          </div>

          <div className="op-alloc">
            <div className="op-alloc-hdr">
              <span>Fund</span><span/><span>{shortName(activePick)}</span>
              <span>{baseW ? shortName(baseId) : "—"}</span><span>Δ</span>
            </div>
            {rows.map(r => {
              const cls  = data.class_map[r.t] || "";
              const col  = classColor(cls);
              const d    = (r.sug - r.plan) * 100;
              const tk   = data.bare[r.t] || r.t;
              const esg  = !!(data.esg && data.esg[r.t]);
              return (
                <div className="op-alloc-row" key={r.t}>
                  {/* Name leads, ticker follows. Most people do not carry a
                      mapping from `DRMC` to "Net-zero pathway ESG Canadian
                      Equity" in their head, and a table of five-letter codes is
                      unreadable to everyone who doesn't. */}
                  <div className="op-alloc-name">
                    <span className="op-alloc-dot" style={{background:col}}/>
                    <div style={{minWidth:0}}>
                      <div className="op-alloc-fund">
                        {optFundName(data.names[r.t], esg) || tk}
                        {/* The app's ESG pill, not a red asterisk. Same markup as
                            the plan document's holdings table — a POSITIVE mark on
                            a screened fund reads as information; the asterisk it
                            replaced marked the absence of one and read as an
                            error. */}
                        {esg && <span className="hold-esg">ESG</span>}
                      </div>
                      <div className="op-alloc-sub">
                        {tk} · {AC_LABELS[cls] || cls}
                      </div>
                    </div>
                  </div>
                  <div className="op-alloc-bar">
                    <div className="op-alloc-fill"
                         style={{width:`${(r.sug / maxW) * 100}%`,background:col}}/>
                  </div>
                  <span className="op-num">{(r.sug * 100).toFixed(1)}%</span>
                  <span className="op-num muted">
                    {r.plan > 0 ? `${(r.plan * 100).toFixed(1)}%` : "—"}
                  </span>
                  <span className={"op-num " + (d >= 0 ? "op-delta-up" : "op-delta-dn")}>
                    {baseW ? `${d >= 0 ? "+" : ""}${d.toFixed(0)}` : "—"}
                  </span>
                </div>
              );
            })}
            {/* Everything under OPT_MIN_SHOWN, as one line. The rows are not worth
                reading individually, but their total is: without it the column
                stops adding to 100% and the table looks wrong rather than
                filtered. */}
            {rest.n > 0 && (
              <div className="op-alloc-row op-alloc-rest">
                <div className="op-alloc-name">
                  <span className="op-alloc-dot" style={{background:"#d4cfc5"}}/>
                  <div className="op-alloc-fund">
                    {rest.n} smaller {rest.n === 1 ? "position" : "positions"},
                    each under {Math.round(OPT_MIN_SHOWN * 100)}%
                  </div>
                </div>
                <div/>
                <span className="op-num">{(rest.sug * 100).toFixed(1)}%</span>
                <span className="op-num muted">
                  {rest.plan > 0 ? `${(rest.plan * 100).toFixed(1)}%` : "—"}
                </span>
                <span className="op-num muted">—</span>
              </div>
            )}
          </div>

          {/* The one caveat that qualifies the numbers directly above it, so it
              cannot be static help text. There is no mode toggle any more: this
              states the house position rather than asking the user to arbitrate
              between two forecasts. Everything else this card used to footnote is
              in the help article behind the `?` chip — resist re-adding an essay.

              The fund-count sentence is SANCTIONED, not creep: this table is the
              one screen in the app that sets a user's own holdings beside a longer
              suggestion, so "more rows = better diversified" is a misreading the
              panel itself invites. It belongs next to the numbers that invite it
              (`CLAUDE.md § What it is FOR`). Keep it to one sentence; the
              reasoning lives in `the-efficient-frontier`. */}
          <p className="op-note">
            Expected returns are a <b>50/50 blend</b>: half our forward-looking
            capital-market assumptions — the same ones behind your projections —
            and half what these funds actually returned over the window below. Not
            a backtest; the history tempers the forecast rather than replacing it.
            {" "}A suggestion holding <b>more funds is not a better-diversified one</b> —
            two funds covering the same market add cost and overlap, not breadth.
            {data.climate_screen && <> Your plan applies a climate screen, and the
              search covers every core fund — so a suggestion without the{" "}
              <span className="hold-esg" style={{marginLeft:0}}>ESG</span> tag does
              not meet it.</>}
          </p>

          {/* Run provenance, not explanation: which funds, over which days.
              Both are per-run facts a help article cannot carry. Constraints,
              sample size and the climate-screen policy moved out. */}
          <div className="op-meta">
            {data.universe.length} funds · {data.common_window.n_days} days
            ({data.common_window.start} → {data.common_window.end})
            {(data.excluded || []).length > 0 && <>
              {" · "}{data.excluded.map(e => data.bare?.[e.ticker]
                || e.ticker.replace(".TO","")).join(", ")} left out (no price history)</>}
            {/* Which universe these funds came from. Worth one word because the
                three modes are three different SOURCES, not three widths of the
                same list — `watchlist` does not include the core funds, so a
                suggestion here can legitimately look nothing like core's. */}
            {" · "}{(OPT_UNIVERSES.find(u => u.id === data.universe_mode) || {})
                     .label || data.universe_mode}
            {/* Both overlays on screen, not just the baseline: a partly-covered
                overlay is a marker for a DIFFERENT portfolio than the user
                holds, and that matters most when it IS the selection. */}
            {OPT_OVERLAYS.filter(o => (o.id === activePick || o.id === baseId)
                                      && (overlays[o.id] || {}).covered < 0.995)
              .map(o => (
                <React.Fragment key={o.id}> · {shortName(o.id)} covers{" "}
                  {Math.round(overlays[o.id].covered * 100)}% of its weight
                  (the rest is outside this universe)</React.Fragment>
              ))}
          </div>
        </>
      )}
    </div>
  );
}

function PortfolioTab({ user, onSwitchTab, onLogout, onUserUpdated, refreshKey, onNavigate, draftGoalInfo, onPlanChanged }) {
  const [overview, setOverview]             = useState(null);
  const [loadErr, setLoadErr]               = useState(null);
  const [allocView, setAllocView]           = useState("target");
  const [activeGoalId, setActiveGoalId]     = useState(null);
  const [holdingsRefresh, setHoldingsRefresh] = useState(0);
  // null = closed; "contributions" | "holdings" = open combined edit modal on that tab.
  const [editTab, setEditTab]               = useState(null);
  // A file picked from SavingsModuleCard's Upload icon, before the modal (and
  // HoldingsEditPanel) has even mounted — see save-tab.md § Portfolio CSV
  // round-trip. HoldingsEditPanel consumes it once its own data load
  // finishes, then calls onCsvFileConsumed to clear it here.
  const [pendingCsvFile, setPendingCsvFile] = useState(null);
  const handleUploadCsv = (file) => { setPendingCsvFile(file); setEditTab("holdings"); };

  // Full IPS refresh resets selected goal; holdings save does not.
  useEffect(() => { setActiveGoalId(null); }, [refreshKey]);

  // Single fetch effect keyed on both counters. A refreshKey change (or first
  // mount) is a "full load" — blank to the spinner. A holdingsRefresh-only bump
  // (a holdings/contribution save) refetches silently, so the open edit modal and
  // its in-progress state survive the save instead of unmounting under a spinner.
  const _prevRefreshKey = React.useRef(null);
  useEffect(() => {
    const fullLoad = _prevRefreshKey.current !== refreshKey;
    _prevRefreshKey.current = refreshKey;
    if (fullLoad) setOverview(null);
    setLoadErr(null);
    (async () => {
      const r = await api("/api/portfolio/overview");
      if (r.ok && r.data) setOverview(r.data);
      else setLoadErr((r.data && r.data.error) || "Failed to load portfolio data.");
    })();
  }, [refreshKey, holdingsRefresh]);

  const effectiveGoalId = activeGoalId || overview?.goals?.[0]?.goal;
  const selectedGoal    = overview?.goals?.find(g => g.goal === effectiveGoalId);

  // ── Lifecycle baseline overlay (lazy, after the overview has painted) ──
  // A retirement baseline costs a full decumulation solve on the server, so it
  // is deliberately NOT a field on /api/portfolio/overview: the Save tab's
  // first paint would wait on a line most sessions never scroll to. It arrives
  // late and the chart adds a line; nothing else on the page depends on it, and
  // a failed fetch is silent by design — the chart simply draws what it always
  // drew. Memoised per goal + data version, like OptimizerCard, so flipping
  // between goal cards does not re-solve what was already solved.
  const [baseline, setBaseline] = useState(null);
  const _blCache = React.useRef({});
  useEffect(() => {
    if (!effectiveGoalId || !selectedGoal?.projection?.ok) { setBaseline(null); return; }
    const key = `${effectiveGoalId}|${refreshKey}|${holdingsRefresh}`;
    if (_blCache.current[key]) { setBaseline(_blCache.current[key]); return; }
    let dead = false;
    setBaseline(null);
    api(`/api/portfolio/baseline/${effectiveGoalId}`).then(r => {
      if (dead || !r.ok || !r.data) return;
      _blCache.current[key] = r.data;
      setBaseline(r.data);
    });
    return () => { dead = true; };
  }, [effectiveGoalId, refreshKey, holdingsRefresh, selectedGoal?.projection?.ok]);
  // Guard the render against a response for the goal the user just left: the
  // effect's own `dead` flag covers the in-flight case, the cache does not.
  const goalBaseline = baseline?.goal === effectiveGoalId ? baseline : null;

  // Same shape as SavingsModuleCard's and OptimizerCard's own openHelp — the
  // tab's cards each carry one because the chip belongs to the card, not to a
  // shared header. `onNavigate` is optional on some mount paths.
  const openHelp = (articleId) =>
    onNavigate && onNavigate("help", { tab: "portfolio", articleId });

  const mc       = selectedGoal?.projection?.accumulation;
  const total    = selectedGoal?.current_value || 0;
  const retireYr = selectedGoal?.target_year;

  // Contribution bucket pills: accounts that hold money, falling back to all plan
  // accounts before any holdings exist. Mirrors SavingsModuleCard's own derivation.
  const _gAccounts = selectedGoal?.accounts || [];
  const _gHolds    = selectedGoal?.holdings_detail || [];
  const _anyHold   = _gHolds.some(h => h.rrsp>0||h.tfsa>0||h.taxable>0||h.srrsp>0||h.resp>0);
  const editBuckets = !_anyHold ? _gAccounts
    : _gAccounts.filter(a => _gHolds.some(h => (h[_ACCT_TO_HOLD_COL[a]] || 0) > 0));

  // Headline projected value — straight from the shared backend projection, so the
  // Projections card, the goal card and the Plan tab all show the same figure.
  const termLabel = selectedGoal?.projection_view?.projected_value != null
    ? fmtCompact(selectedGoal.projection_view.projected_value)
    : null;

  // A never-yet-saved wizard draft for a goal not already in the ribbon gets a
  // ghost card too — so a plan in progress shows up alongside the committed
  // ones, not just on the Plan hub. See plan-tab.md § Wizard draft persistence.
  const showDraftGhost = !!(draftGoalInfo && overview?.goals &&
    !overview.goals.some(g => g.goal === draftGoalInfo.goalId));

  return (
    <div className="sp-root">
      <PageNav activeTab="portfolio" onSwitchTab={onSwitchTab} user={user} onLogout={onLogout} onUserUpdated={onUserUpdated} onNavigate={onNavigate} />
      <div className="pf-page">

        {/* Loading */}
        {!overview && !loadErr && (
          <div className="pf-card" style={{padding:"28px 24px",display:"flex",alignItems:"center",gap:12}}>
            <span className="pf-spinner"/>
            <span style={{fontSize:13,color:"#5a6a72"}}>Computing your portfolio overview…</span>
          </div>
        )}
        {loadErr && <div className="pf-error">{loadErr}</div>}

        {/* No data */}
        {overview && !overview.has_data && (
          <div className="pf-card">
            <div className="sp-eyebrow" style={{marginBottom:8}}>No portfolio data yet</div>
            <p style={{fontSize:13.5,color:"#5a6a72",lineHeight:1.6,marginBottom:20}}>
              Save a plan in the Plan tab to set up your savings view.
            </p>
            <button className="sp-btn sp-btn-primary" onClick={() => onSwitchTab("plan", { toHub: true })}>
              Go to Plans →
            </button>
          </div>
        )}

        {overview?.has_data && (
          <>
            <h1 className="pf-h1">{user.name}'s Savings</h1>

            <SavingsReviewBand user={user} hasSignedPlan={overview.goals.length > 0}
              refreshKey={`${refreshKey}|${holdingsRefresh}`} />

            {/* ── Portfolio goal cards ── */}
            {(() => { return (
            <div className="pf-gcards">
              {overview.goals.map(g => {
                // Everything below comes from the one shared backend projection
                // (today's dollars) — same numbers as the Plan hub card / plan doc.
                const pv         = g.projection_view || {};
                const sigTextCol = SIGNAL_TEXT[g.signal] || SIGNAL_TEXT.red;
                const sigLineCol = SIGNAL_DOT[g.signal]  || SIGNAL_DOT.red;
                const sigLabel   = SIGNAL_LABEL[g.signal] || SIGNAL_LABEL.red;
                const target     = pv.target;
                const projected  = pv.projected_value;
                const delta      = (projected != null && target) ? projected - target : null;
                return (
                  <button key={g.goal}
                    className={`pf-gcard${effectiveGoalId === g.goal ? " active" : ""}`}
                    onClick={() => setActiveGoalId(g.goal)}>
                    <div className="pf-gcard-hd">
                      <div className="pf-gcard-id">
                        <i className={"ti " + goalTiIcon(g.goal, {goalPreset: g.goal_preset})} aria-hidden="true"/>
                        <span className="pf-gcard-name">{g.label}</span>
                      </div>
                      <span className={"pf-signal pf-signal-" + g.signal}>
                        <SignalDot signal={g.signal}/>{sigLabel}
                      </span>
                    </div>
                    <div className="pf-gcard-line">
                      <span className="pf-gcard-today">{fmtCompact(g.current_value)} now</span>
                      <span className="pf-gcard-sep">·</span>
                      <span className="pf-gcard-val">{projected != null ? fmtCompact(projected) : "—"}</span>
                      {g.target_year && <span className="pf-gcard-by">by {g.target_year}</span>}
                    </div>
                    <GoalTrajChart uid={g.goal} path={pv.p50} target={target} color={sigLineCol}/>
                    {delta != null && (
                      <div className="pf-gcard-foot" style={{color: sigTextCol}}>
                        {(delta >= 0 ? "+" : "-") + fmtCompact(Math.abs(delta))} {delta >= 0 ? "above" : "below"} {fmtCompact(target)} target
                      </div>
                    )}
                  </button>
                );
              })}
              {showDraftGhost && (
                <button className="pf-gcard pf-gcard-draft" onClick={() => onSwitchTab("plan")}>
                  <div className="pf-gcard-hd">
                    <div className="pf-gcard-id">
                      <i className={"ti " + draftGoalInfo.icon} aria-hidden="true"/>
                      <span className="pf-gcard-name">{draftGoalInfo.label}</span>
                    </div>
                    <span className="pf-gcard-draft-tag">Draft</span>
                  </div>
                  <div className="pf-gcard-draft-body">Draft in progress — click to continue.</div>
                </button>
              )}
              {overview.goals.length + (showDraftGhost ? 1 : 0) < 4 && (
                <button className="pf-gcard pf-gcard-add" onClick={() => onSwitchTab("plan", { toHub: true })}>
                  <span className="pf-gcard-add-inner"><i className="ti ti-plus" aria-hidden="true"/>Add a plan</span>
                </button>
              )}
            </div>
            ); })()}

            <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",
                         borderBottom:"1px solid #e3ddd1",paddingBottom:12,marginBottom:16}}>
              <h2 className="pf-section-head" style={{border:"none",padding:0,margin:0}}>
                {selectedGoal.label} Portfolio Overview
              </h2>
              <button onClick={() => setEditTab("holdings")}
                title="Manage holdings, contributions & accounts"
                style={{display:"inline-flex",alignItems:"center",gap:6,background:"#faf9f7",
                        border:"1px solid #b7d4c0",borderRadius:20,padding:"6px 14px",cursor:"pointer",
                        fontFamily:"'IBM Plex Mono',monospace",fontSize:14,color:"#8a5709",
                        letterSpacing:".04em",flexShrink:0}}>
                <i className="ti ti-settings" style={{fontSize:16}} aria-hidden="true"/>
                Manage
              </button>
            </div>

            {/* ── Unified allocation + plan status panel ── */}
            <div className="pf-alloc-panel">

              {/* Allocation */}
              <div className="pf-alloc-left">
                <div className="pf-card-head">
                  <span className="pf-card-title">Allocation</span>
                  <div className="pf-alloc-tabs">
                    {[["target","vs Plan"],["mix","Mix"],["account","By account"]].map(([id,label]) => (
                      <button key={id}
                        className={`pf-alloc-tab${allocView===id?" active":""}`}
                        onClick={() => setAllocView(id)}>
                        {label}
                      </button>
                    ))}
                  </div>
                </div>


                {allocView === "target" && (
                  <AllocVsPlanView allocation={selectedGoal?.allocation || []} />
                )}
                {allocView === "mix" && (
                  <AllocMixView allocation={selectedGoal?.allocation || []} />
                )}
                {allocView === "account" && (
                  <AllocByAccountView allocation={selectedGoal?.allocation || []} goalTotal={selectedGoal?.current_value} />
                )}
              </div>

              {/* Plan status rail */}
              <div className="pf-alloc-right">
                <div className="pf-metric-item">
                  <div className="pf-metric-label">Total Value</div>
                  <div className="pf-metric-val">{fmtDollar(selectedGoal?.current_value || 0)}</div>
                </div>
                <div className="pf-metric-item">
                  <div className="pf-metric-label">Annual Savings</div>
                  <div className="pf-metric-val">
                    {(selectedGoal?.annual_savings || 0) > 0 ? fmtDollar(selectedGoal.annual_savings) : "—"}
                  </div>
                </div>
                {selectedGoal?.target_year && (() => {
                  const currentYear      = new Date().getFullYear();
                  const horizon          = selectedGoal.target_year - currentYear;
                  const plannedRetireAge = user.birth_year ? selectedGoal.target_year - user.birth_year : null;
                  const retGoalFiAge     = overview?.goals?.find(g => g.goal === "retirement")?.projection?.report_card?.accumulation?.fi_age;
                  const earlyRetirement  = retGoalFiAge != null && plannedRetireAge != null
                    && retGoalFiAge < plannedRetireAge && selectedGoal.goal === "retirement";
                  return (
                    <div className="pf-metric-item">
                      <div className="pf-metric-label">Savings Horizon</div>
                      <div className="pf-metric-val">{horizon} Yrs</div>
                      {earlyRetirement && (
                        <div style={{fontSize:11,color:"#8a5709",marginTop:4,fontFamily:"'IBM Plex Mono',monospace",letterSpacing:"0.03em"}}>
                          Early retirement at {retGoalFiAge} possible
                        </div>
                      )}
                    </div>
                  );
                })()}
                {(() => {
                  if (selectedGoal?.goal !== "retirement") return null;
                  const decumRC   = selectedGoal?.projection?.report_card?.decumulation;
                  const decumFull = selectedGoal?.projection?.decumulation;
                  if (!decumFull) return null;
                  const planTarget = decumFull.annual_need || 0;
                  const canSustain = decumFull.smile_peak  || 0;
                  if (!planTarget || !canSustain) return null;
                  // Verdict + $ amount are judged on a flat-spend basis (decum.flat_equivalent,
                  // via report_card.income_gap/income_surplus) — the same level-spending
                  // convention fi_number uses, so this box agrees in direction with the
                  // accumulation "on track" signal. "Can sustain" below still shows the
                  // smile's peak (a real, useful number), with a decline note since that
                  // peak is the START of a schedule, not what's sustained throughout.
                  const incomeGap     = decumRC?.income_gap || 0;
                  const incomeSurplus = decumRC?.income_surplus || 0;
                  if (incomeGap <= 0 && incomeSurplus <= 0) return null;
                  const isShortfall = incomeGap > 0;
                  const accent = isShortfall ? "#a1450f" : "#2d7a47";
                  const border = isShortfall ? "#e8b44b" : "#7bcfa0";
                  const lateAmt   = decumFull.smile_late;
                  const lateAge   = decumFull.smile_late_age;
                  const declines  = lateAmt && lateAge && lateAmt < canSustain * 0.97;
                  return (
                    <div className="pf-metric-item" style={{borderTop:`2px solid ${border}`}}>
                      <div className="pf-metric-label" style={{color:accent}}>
                        {isShortfall ? "Income Shortfall" : "Income Surplus"}
                      </div>
                      <div className="pf-metric-val" style={{color:accent}}>
                        {isShortfall ? "−" : "+"}{fmtDollar(isShortfall ? incomeGap : incomeSurplus)}/yr
                      </div>
                      <div style={{fontSize:10.5,color:"#647071",marginTop:5,fontFamily:"'IBM Plex Mono',monospace",lineHeight:1.6}}>
                        <span>Plan {fmtCompact(planTarget)}</span>
                        <span style={{margin:"0 5px",color:"#d4cfc5"}}>·</span>
                        <span>Can sustain {fmtCompact(canSustain)}</span>
                        {declines && <><br/><span>declining to {fmtCompact(lateAmt)} by age {lateAge}</span></>}
                      </div>
                    </div>
                  );
                })()}
                {selectedGoal?.goal === "retirement" && (
                  <BenefitDeferralMetric deferral={goalBaseline?.deferral} />
                )}
              </div>
            </div>

            {/* ── Savings module: contributions chart + holdings (Pattern B) ── */}
            {selectedGoal?.holdings_detail?.length > 0 && (
              <SavingsModuleCard
                goal={selectedGoal.goal}
                goalLabel={selectedGoal.label}
                holds={selectedGoal.holdings_detail}
                total={total}
                accounts={selectedGoal.accounts || []}
                history={selectedGoal.history || {}}
                annualSavings={selectedGoal.annual_savings}
                onSaved={() => setHoldingsRefresh(c => c + 1)}
                onEdit={setEditTab}
                onUploadCsv={handleUploadCsv}
                onNavigate={onNavigate}
              />
            )}

            {/* ── Combined edit modal (gear + pills entry point) ── */}
            {editTab && selectedGoal && (
              <PortfolioEditModal
                goal={selectedGoal.goal}
                goalLabel={selectedGoal.label}
                accounts={editBuckets}
                initialTab={editTab}
                onClose={() => { setEditTab(null); setPendingCsvFile(null); }}
                onSaved={() => setHoldingsRefresh(c => c + 1)}
                onPlanChanged={onPlanChanged}
                pendingCsvFile={pendingCsvFile}
                onCsvFileConsumed={() => setPendingCsvFile(null)}
              />
            )}

            {/* ── Projection chart ── */}
            {mc && selectedGoal?.projection?.ok && (
              <h2 className="pf-section-head" style={{marginTop:24}}>Projections</h2>
            )}
            {mc && selectedGoal?.projection?.ok && (() => {
              const isRetirementWithDecum = selectedGoal.goal === "retirement"
                && selectedGoal.projection.decumulation;
              const thisYear = new Date().getFullYear();
              if (isRetirementWithDecum) {
                const decum = selectedGoal.projection.decumulation;
                return (
                  <>
                    <div className="pf-proj-card">
                      <div className="pf-proj-head" style={{marginBottom:10}}>
                        <span style={{display:"inline-flex",alignItems:"center"}}>
                          <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12.5,fontWeight:500,color:"#1a1a1a"}}>
                            Portfolio Lifecycle Projection
                          </span>
                          {/* `sp-inline-help`, NOT `sp-help-btn` — the latter is
                              the 28px NAV chip and dwarfs a card title. This one
                              carries the definition the footnote used to repeat
                              under every chart: what "Optimized" means, and what
                              a typical portfolio is being held to. */}
                          <button type="button" className="sp-inline-help"
                            onClick={() => openHelp("what-optimized-means")}
                            title="What 'Optimized' means here">?</button>
                          <span style={{fontSize:11.5,color:"#647071",marginLeft:8}}>
                            {selectedGoal.annual_savings > 0
                              ? `saving ${fmtCompact(selectedGoal.annual_savings)} / year until retirement`
                              : "if you keep contributing"}
                          </span>
                        </span>
                      </div>
                      <LifecycleChart proj={selectedGoal.projection} total={total} currentYear={thisYear}
                        baseline={goalBaseline?.benchmark} />
                    </div>
                    <div className="pf-proj-card" style={{marginTop:12}}>
                      <div className="pf-proj-head" style={{marginBottom:10}}>
                        <span>
                          <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12.5,fontWeight:500,color:"#1a1a1a"}}>
                            Retirement Withdrawals
                          </span>
                          {decum.decum_strategy && (
                            <span style={{fontSize:11,color:"#647071",marginLeft:8}}>
                              {(() => {
                                const s = (decum.decum_strategy||"").toLowerCase();
                                if (s === "meltdown") return "Tax-aware RRSP Meltdown";
                                if (s === "proportional_no_tfsa") return "Proportional (RRSP / Taxable first)";
                                return "Proportional";
                              })()}
                            </span>
                          )}
                        </span>
                      </div>
                      <AccountBalancesChart decum={decum} />
                    </div>
                    {decum.tax_paid && <AfterTaxIncomeCard decum={decum} goal={selectedGoal.goal}
                      user={user} onUpgrade={() => openHelp("what-optimized-means")} />}
                  </>
                );
              }
              return (
                <div className="pf-proj-card">
                  <div className="pf-proj-head">
                    <span>
                      <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:12.5,fontWeight:500,color:"#1a1a1a"}}>
                        Projected to {selectedGoal.label.toLowerCase()}
                      </span>
                      <span style={{fontSize:11.5,color:"#647071",marginLeft:8}}>
                        {selectedGoal.annual_savings > 0
                          ? `saving ${fmtDollar(selectedGoal.annual_savings)}/yr`
                          : "if you keep contributing"}
                      </span>
                    </span>
                    {termLabel && retireYr && (
                      <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:11.5,color:"#8a5709"}}>
                        {termLabel} by {retireYr}
                      </span>
                    )}
                  </div>
                  <ProjectionChart view={selectedGoal.projection_view} />
                </div>
              );
            })()}

            {/* ── Optimizer ── */}
            {/* Its own section, below Projections. Unlike the sections above it,
                this one is NOT gated on a projection: the frontier is built from
                the fund universe and price history, so it works for a goal whose
                projection failed. It fetches on mount — see OptimizerCard for why
                that is affordable (a memoised server-side frontier), and why the
                "Run optimizer" button that used to guard it did not guard it. */}
            {selectedGoal && (
              <>
                <h2 className="pf-section-head" style={{marginTop:24}}>Optimizer</h2>
                <OptimizerCard goal={selectedGoal.goal} goalLabel={selectedGoal.label}
                               user={user} onNavigate={onNavigate}
                               dataKey={`${refreshKey}.${holdingsRefresh}`}/>
              </>
            )}
          </>
        )}

      </div>
      <SiteFooter />
    </div>
  );
}

// ── Dashboard tab ─────────────────────────────────────────────────────────────

function MiniRocBar({ roc5, maxAbs }) {
  const label = roc5 == null ? "—" : `${roc5 > 0 ? "+" : ""}${roc5.toFixed(2)}%`;
  if (roc5 == null) return (
    <div style={{display:"flex",justifyContent:"flex-end"}} title={label}>
      <div style={{position:"relative",width:52,height:7,background:"#f5f0e8",borderRadius:2}}>
        <div style={{position:"absolute",top:0,left:"50%",width:1,height:"100%",background:"#d4cfc5"}}/>
      </div>
    </div>
  );
  const abs   = Math.abs(roc5);
  const pct   = maxAbs > 0 ? (abs / maxAbs) * 50 : 0;
  const pos   = roc5 >= 0;
  const color = pos ? "#2d7a47" : "#b34030";
  return (
    <div style={{display:"flex",justifyContent:"flex-end"}} title={label}>
      <div style={{position:"relative",width:52,height:7,background:"#f5f0e8",borderRadius:2,flexShrink:0}}>
        <div style={{position:"absolute",height:"100%",borderRadius:2,
          width:`${pct}%`,left:pos?`50%`:`${50-pct}%`,background:color}}/>
        <div style={{position:"absolute",top:0,left:"50%",width:1,height:"100%",background:"#d4cfc5"}}/>
      </div>
    </div>
  );
}

// ── Same-class ticker shades ─────────────────────────────────────────────────
// Two watchlist funds in one asset class draw the SAME class colour, so at eight
// holdings "Price over time" had two indistinguishable gold lines (KILO and ZRE,
// both Alternatives). Within a class the 2nd..nth ticker steps its HSL lightness
// by ±5% per pair — darker, then lighter — so the pair still reads as that
// class's hue, a shade apart rather than a different colour.
//
// This does NOT weaken CLAUDE.md § "Asset-class colours are DATA": the first
// ticker in each class renders the admin's fill_hex EXACTLY, so every class still
// shows its own colour on the chart, and the common case — one holding in a class
// — is untouched. The steps disambiguate WITHIN a hue; they are never a substitute
// for it. Never widen the step to make a colour "work": edit it in admin.html.
//
// Darker first, because lightening a pale class (Alternatives' #ffd700 gold, at
// 1.40:1 on white already) fades it into the card, while a step down stays legible.
const SHADE_STEP = 0.05; // HSL lightness, per pair of same-class tickers

function _hexToHsl(hex) {
  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex || "").trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
  const r = parseInt(h.slice(0, 2), 16) / 255,
        g = parseInt(h.slice(2, 4), 16) / 255,
        b = parseInt(h.slice(4, 6), 16) / 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
  const l = (mx + mn) / 2;
  let hue = 0, s = 0;
  if (d) {
    s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
    hue = mx === r ? (g - b) / d + (g < b ? 6 : 0)
        : mx === g ? (b - r) / d + 2
        :            (r - g) / d + 4;
    hue /= 6;
  }
  return { h: hue, s, l };
}

function _hslToHex(hsl) {
  const f = n => {
    const k = (n + hsl.h * 12) % 12;
    const a = hsl.s * Math.min(hsl.l, 1 - hsl.l);
    const v = hsl.l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
    return Math.round(v * 255).toString(16).padStart(2, "0");
  };
  return `#${f(0)}${f(8)}${f(4)}`;
}

// step 0 → the class colour untouched; 1 → −5% L, 2 → +5%, 3 → −10%, 4 → +10%…
function shadeStep(hex, step) {
  if (!step) return hex;
  const hsl = _hexToHsl(hex);
  if (!hsl) return hex; // not a hex this understands — leave the admin's value alone
  const delta = Math.ceil(step / 2) * SHADE_STEP * (step % 2 ? -1 : 1);
  return _hslToHex({ h: hsl.h, s: hsl.s, l: Math.max(0.12, Math.min(0.88, hsl.l + delta)) });
}

// ticker → colour for one price-history payload. The backend orders the series by
// ticker (`app.dashboard_price_history`), so which fund in a class keeps the base
// colour is stable across reloads; and every chart fed the same payload —
// PriceIndexChart's lines, AssetMovementChart's bubbles — agrees on the shade.
function seriesShades(series) {
  const seen = {}, out = {};
  (series || []).forEach(s => {
    const n = (seen[s.asset_class] = (seen[s.asset_class] || 0) + 1);
    out[s.ticker] = shadeStep(classColor(s.asset_class), n - 1);
  });
  return out;
}

function PriceIndexChart({ series }) {
  if (!series || !series.length) return null;

  const allDates = [...new Set(series.flatMap(s => s.dates))].sort();
  const nDates = allDates.length;
  if (nDates < 2) return null;

  const dateIndex = {};
  allDates.forEach((d, i) => { dateIndex[d] = i; });

  const allVals = series.flatMap(s => s.values);
  const minY = Math.min(90, ...allVals);
  const maxY = Math.max(110, ...allVals);
  const pad  = (maxY - minY) * 0.06;
  const lo = minY - pad, hi = maxY + pad;

  // Past a handful of funds the right-gutter end labels stop naming their own
  // lines: eight labels needing 14 units of clearance each do not fit a 166-unit
  // plot, so the nudge pushes them so far from their line ends that the label
  // beside a line belongs to a different one. From LEGEND_AT up, the labels move
  // to a wrapping legend under the chart, each line ends in the dot marker
  // style.md sanctions as the alternative end marker, and the gutter that held
  // the labels goes back to the plot.
  const LEGEND_AT = 5;
  const legend = series.length >= LEGEND_AT;
  const shade  = seriesShades(series);

  const W = 600, H = 200, PT = 10, PB = 24, PL = 36, PR = legend ? 16 : 58;
  const cW = W - PL - PR, cH = H - PT - PB;
  const xp = i => PL + (i / (nDates - 1)) * cW;
  const yp = v => PT + cH * (1 - (v - lo) / (hi - lo));

  // Y-axis: pick ~4 round gridlines that span the data range
  const rawStep = (hi - lo) / 4;
  const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
  const niceStep  = Math.ceil(rawStep / magnitude) * magnitude;
  const yTicks = [];
  const firstTick = Math.ceil(lo / niceStep) * niceStep;
  for (let v = firstTick; v <= hi + 0.01; v += niceStep) {
    yTicks.push(Math.round(v * 10) / 10);
  }

  // X-axis: one label per quarter (first trading day of each quarter month)
  const xLabels = [];
  const seenQuarters = new Set();
  allDates.forEach((d, i) => {
    const [yr, mo] = d.split("-").map(Number);
    const qKey = `${yr}-Q${Math.ceil(mo / 3)}`;
    if (!seenQuarters.has(qKey) && (mo === 1 || mo === 4 || mo === 7 || mo === 10)) {
      seenQuarters.add(qKey);
      const label = new Date(d + "T12:00:00").toLocaleDateString("en-CA", { month: "short", year: "2-digit" });
      xLabels.push({ i, label });
    }
  });

  const chart = (
    <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
      {/* Y gridlines + labels */}
      {yTicks.map(v => {
        const y = yp(v).toFixed(1);
        const is100 = Math.abs(v - 100) < 0.05;
        return (
          <React.Fragment key={v}>
            <line x1={PL} x2={PL + cW} y1={y} y2={y}
                  stroke={is100 ? "#d4cfc5" : "#ede9e0"}
                  strokeWidth={is100 ? 1 : 0.75}
                  strokeDasharray={is100 ? "3 4" : "2 4"}/>
            <text x={PL - 4} y={+y + 3.5} textAnchor="end"
                  fontSize="8.5" fill={is100 ? "#647071" : "#647071"}
                  fontFamily="'IBM Plex Mono',monospace"
                  fontWeight={is100 ? "500" : "400"}>
              {v % 1 === 0 ? v : v.toFixed(1)}
            </text>
          </React.Fragment>
        );
      })}

      {/* Lines + end markers. In label mode two funds can close out within a few
          SVG units of each other — at this font size that reads as one smeared
          label, worse the narrower the chart renders (e.g. the Help Center's
          figure column vs. the Track tab's full-width card). Labels are nudged
          apart vertically (order preserved) to guarantee a minimum gap; the lines
          themselves are drawn at their true, un-nudged values. In legend mode
          there are no labels to nudge — each line just gets its end dot. */}
      {(() => {
        const labelY = {};
        if (!legend) {
          series.forEach(s => { labelY[s.ticker] = yp(s.values[s.values.length - 1]); });
          const MIN_LABEL_GAP = 14; // SVG units — clears the 9.5px ticker label even at ~478px render width
          [...series].sort((a, b) => labelY[a.ticker] - labelY[b.ticker])
            .forEach((s, i, sorted) => {
              if (i === 0) return;
              const prevT = sorted[i - 1].ticker;
              if (labelY[s.ticker] - labelY[prevT] < MIN_LABEL_GAP) {
                labelY[s.ticker] = labelY[prevT] + MIN_LABEL_GAP;
              }
            });
        }
        return series.map(s => {
          // The class's own colour, exactly as set in admin.html — only stepped a
          // shade when another fund in the same class is on the chart (see
          // seriesShades). This briefly used a darker auto-derived step so thin
          // lines cleared a contrast floor, which was wrong: it meant a deliberate
          // palette choice did not render, and the only symptom was "my edit did
          // not take". The admin picks these to separate the classes from each
          // other on this very chart — that judgement wins. The panel reports each
          // fill's contrast so the tradeoff is visible when it is chosen, not
          // silently resolved here.
          const color = shade[s.ticker];
          const pts = s.dates.map((d, i) => ({
            x: xp(dateIndex[d]).toFixed(1),
            y: yp(s.values[i]).toFixed(1),
          }));
          const path = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
          const last = pts[pts.length - 1];
          return (
            <React.Fragment key={s.ticker}>
              <path d={path} fill="none" stroke={color} strokeWidth="1.6"
                    strokeLinejoin="round" strokeLinecap="round">
                <title>{s.ticker}</title>
              </path>
              {legend ? (
                <circle cx={last.x} cy={last.y} r="2.6" fill={color}/>
              ) : (
                <text x={+last.x + 5} y={labelY[s.ticker] + 4}
                      fontSize="9.5" fill={color} fontFamily="'IBM Plex Mono',monospace"
                      fontWeight="500">
                  {s.ticker}
                </text>
              )}
            </React.Fragment>
          );
        });
      })()}

      {/* X-axis labels */}
      {xLabels.map(({ i, label }) => (
        <text key={i} x={xp(i)} y={H - 6}
              textAnchor="middle" fontSize="9" fill="#647071"
              fontFamily="'IBM Plex Mono',monospace">
          {label}
        </text>
      ))}
    </svg>
  );

  if (!legend) return chart;

  // Legend order follows the lines' finishing height, top-first, so the reading
  // order of the swatches matches the order the lines stack up at the right edge
  // — the only handle left once the per-line labels are gone. Shares Market
  // Backdrop's legend style (`.mb-legend` / `.mb-swatch`): one line-chart legend
  // treatment on this tab, not two. The ticker stays neutral #647071 and the
  // swatch carries the colour — that is what makes a legend readable for the
  // pale classes whose own hue is 1.4:1 as 12px type.
  const legendItems = [...series].sort(
    (a, b) => b.values[b.values.length - 1] - a.values[a.values.length - 1]);
  return (
    <>
      {chart}
      <div className="mb-legend" style={{marginTop:12,gap:"8px 16px",justifyContent:"center"}}>
        {legendItems.map(s => (
          <span key={s.ticker} title={s.asset_class}>
            <span className="mb-swatch" style={{background:shade[s.ticker]}}/>{s.ticker}
          </span>
        ))}
      </div>
    </>
  );
}

// ── Rolling annualised vol helper (shared by both dashboard charts) ───────────
function computeRollingVol(values, w) {
  w = w || 21;
  const ANNUALISE = Math.sqrt(252);
  const n = values.length;
  const result = new Array(n).fill(null);
  for (let i = 1; i < n; i++) {
    const start = Math.max(1, i - w + 1);
    const rets = [];
    for (let j = start; j <= i; j++) {
      if (values[j] != null && values[j - 1] != null && values[j - 1] > 0)
        rets.push(Math.log(values[j] / values[j - 1]));
    }
    if (rets.length < 5) continue;
    const mean = rets.reduce((s, r) => s + r, 0) / rets.length;
    const variance = rets.reduce((s, r) => s + (r - mean) ** 2, 0) / (rets.length - 1);
    result[i] = Math.sqrt(variance) * ANNUALISE * 100;
  }
  return result;
}

// ── Asset Movement bubble chart: 1yr return vs realized vol ──────────────────
function AssetMovementChart({ series }) {
  if (!series || !series.length) return null;

  const items = series.map(s => {
    const vals = s.values;
    if (vals.length < 5) return null;
    const pctChange = vals[vals.length - 1] - 100;
    const rets = [];
    for (let i = 1; i < vals.length; i++) {
      if (vals[i] != null && vals[i - 1] > 0)
        rets.push(Math.log(vals[i] / vals[i - 1]));
    }
    if (rets.length < 5) return null;
    const mean = rets.reduce((s, r) => s + r, 0) / rets.length;
    const variance = rets.reduce((s, r) => s + (r - mean) ** 2, 0) / (rets.length - 1);
    return {
      ticker: s.ticker,
      asset_class: s.asset_class,
      pctChange: Math.round(pctChange * 100) / 100,
      realizedVol: Math.round(Math.sqrt(variance) * Math.sqrt(252) * 100 * 100) / 100,
    };
  }).filter(Boolean);

  if (!items.length) return null;

  // Same map as the line chart above it — derived from the full payload, not from
  // `items`, so dropping a too-short series here cannot shift anyone's shade.
  const shade = seriesShades(series);

  const W = 380, H = 210, PT = 12, PB = 34, PL = 34, PR = 10;
  const cW = W - PL - PR, cH = H - PT - PB;

  const xMax = Math.ceil(Math.max(...items.map(t => t.realizedVol)) + 4);

  // Data-centered Y bounds — generous top pad, small bottom pad
  const yDataMin = Math.min(...items.map(t => t.pctChange));
  const yDataMax = Math.max(...items.map(t => t.pctChange));
  const yRange   = Math.max(yDataMax - yDataMin, 8);
  const yMin = Math.floor((yDataMin - Math.max(yRange * 0.12, 6)) / 10) * 10;
  const yMax = Math.ceil( (yDataMax + Math.max(yRange * 0.22, 10)) / 10) * 10;

  const xp = v => PL + (v / xMax) * cW;
  const yp = v => PT + cH * (1 - (v - yMin) / (yMax - yMin));

  // ~5 ticks spaced every 10%
  const yTicks = [];
  for (let v = yMin; v <= yMax + 0.01; v += 10) yTicks.push(v);

  const xStep = xMax <= 16 ? 4 : xMax <= 32 ? 8 : 10;
  const xTicks = [];
  for (let v = xStep; v <= xMax; v += xStep) xTicks.push(v);

  return (
    <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
      {yTicks.map(v => (
        <React.Fragment key={v}>
          <line x1={PL} x2={PL + cW} y1={yp(v).toFixed(1)} y2={yp(v).toFixed(1)}
                stroke={v === 0 ? "#d4cfc5" : "#ede9e0"}
                strokeWidth={v === 0 ? 1 : 0.75}
                strokeDasharray={v === 0 ? "" : "2 4"}/>
          <text x={PL - 3} y={+yp(v).toFixed(1) + 3.5} textAnchor="end"
                fontSize="8.5" fill={v === 0 ? "#647071" : "#647071"}
                fontFamily="'IBM Plex Mono',monospace">
            {v > 0 ? "+" : ""}{v}%
          </text>
        </React.Fragment>
      ))}
      {xTicks.map(v => (
        <React.Fragment key={v}>
          <line x1={xp(v).toFixed(1)} x2={xp(v).toFixed(1)} y1={PT} y2={PT + cH}
                stroke="#ede9e0" strokeWidth={0.75} strokeDasharray="2 4"/>
          <text x={xp(v)} y={H - 16} textAnchor="middle"
                fontSize="8.5" fill="#647071" fontFamily="'IBM Plex Mono',monospace">
            {v}%
          </text>
        </React.Fragment>
      ))}
      <text x={PL + cW / 2} y={H - 4} textAnchor="middle"
            fontSize="8" fill="#647071" fontFamily="'IBM Plex Mono',monospace"
            letterSpacing=".06em">
        REALIZED VOL
      </text>
      {items.map(t => {
        const cx = xp(t.realizedVol), cy = yp(t.pctChange);
        const color = shade[t.ticker];
        return (
          <React.Fragment key={t.ticker}>
            {/* The label sits ON the bubble, so its contrast is against the COMPOSITE
                (class fill over the white card), not against the fill. It used to be
                #f5f0e8 on an "bb" fill, which cleared 4.5:1 on none of the ten classes
                and bottomed out at 1.14:1 on Alternatives' gold — light type on a light
                wash. Dark type on a 99 fill is 5.52:1 at worst (Emerging Markets) and
                needs no per-class branching; an adaptive dark/white rule actually scores
                lower (4.34) because the mid-luminance purple is bad for both.
                The full-opacity stroke still carries the class colour crisply at the rim.
                If you change the alpha, re-check the worst case — 'bb' fails. */}
            <circle cx={cx.toFixed(1)} cy={cy.toFixed(1)} r={17}
                    fill={color + "99"} stroke={color} strokeWidth={1}>
              <title>{t.ticker}: {t.pctChange > 0 ? "+" : ""}{t.pctChange.toFixed(2)}% return, {t.realizedVol.toFixed(1)}% vol</title>
            </circle>
            <text x={cx.toFixed(1)} y={(cy + 3.5).toFixed(1)} textAnchor="middle"
                  fontSize="8.5" fill="#1a1a1a"
                  fontFamily="'IBM Plex Mono',monospace" fontWeight="500"
                  style={{pointerEvents:"none"}}>
              {t.ticker}
            </text>
          </React.Fragment>
        );
      })}
    </svg>
  );
}

// ── Realized vol band + VIX overlay ──────────────────────────────────────────
function VolVixChart({ series, vixData }) {
  if (!series || !series.length) return null;

  // Full date union (TSX + VIX) for warmup computation — ~400 days from backend
  const vixDates = (vixData || []).map(r => r.d);
  const fullDates = [...new Set([...series.flatMap(s => s.dates), ...vixDates])].sort();
  if (fullDates.length < 22) return null;

  // Rolling vol per ticker over the full range, mapped to fullDates
  const volByTicker = {};
  series.forEach(s => {
    const raw = computeRollingVol(s.values, 21);
    const dm = {};
    s.dates.forEach((d, i) => { dm[d] = raw[i]; });
    const arr = fullDates.map(d => dm[d] ?? null);
    // Forward-fill to bridge calendar gaps (US/CA holiday mismatches, data holes)
    let last = null;
    for (let i = 0; i < arr.length; i++) {
      if (arr[i] != null) { last = arr[i]; }
      else if (last != null) { arr[i] = last; }
    }
    volByTicker[s.ticker] = arr;
  });

  // Portfolio mean ± 1 SD band over full range
  const fullAvg = [], fullUpper = [], fullLower = [];
  fullDates.forEach((_, i) => {
    const vals = Object.values(volByTicker).map(v => v[i]).filter(v => v != null && isFinite(v));
    if (vals.length < 1) { fullAvg.push(null); fullUpper.push(null); fullLower.push(null); return; }
    const mean = vals.reduce((s, v) => s + v, 0) / vals.length;
    const sd = vals.length > 1
      ? Math.sqrt(vals.reduce((s, v) => s + (v - mean) ** 2, 0) / (vals.length - 1))
      : 0;
    fullAvg.push(mean);
    fullUpper.push(mean + sd);
    fullLower.push(Math.max(0, mean - sd));
  });

  // VIX over full range
  const vixByDate = {};
  if (vixData) vixData.forEach(r => { vixByDate[r.d] = r.v; });
  const fullVix = fullDates.map(d => vixByDate[d] ?? null);

  // Trim to last 252 display days (mirrors dashboard.html VOL_DISPLAY_DAYS)
  const DISPLAY = 252;
  const start = Math.max(0, fullDates.length - DISPLAY);
  const dates    = fullDates.slice(start);
  const avgVol   = fullAvg.slice(start);
  const upperBand = fullUpper.slice(start);
  const lowerBand = fullLower.slice(start);
  const vixAligned = fullVix.slice(start);

  const n = dates.length;
  const allVals = [...avgVol, ...upperBand, ...vixAligned].filter(v => v != null && isFinite(v));
  if (!allVals.length) return null;
  const maxVal = Math.ceil(Math.max(...allVals) * 1.1 / 5) * 5;

  const W = 380, H = 210, PT = 18, PB = 24, PL = 28, PR = 28;
  const cW = W - PL - PR, cH = H - PT - PB;
  const xp = i => PL + (i / (n - 1)) * cW;
  const yp = v => v == null ? null : PT + cH * (1 - v / maxVal);

  const linePath = arr => {
    let d = "", prevNull = true;
    arr.forEach((v, i) => {
      if (v == null) { prevNull = true; return; }
      d += `${prevNull ? "M" : "L"}${xp(i).toFixed(1)},${yp(v).toFixed(1)} `;
      prevNull = false;
    });
    return d.trim();
  };

  const bandPath = (() => {
    const fwd = upperBand.map((v, i) => v != null ? [xp(i), yp(v)] : null).filter(Boolean);
    const rev = lowerBand.map((v, i) => v != null ? [xp(i), yp(v)] : null).filter(Boolean).reverse();
    if (fwd.length < 2) return "";
    return fwd.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ")
      + rev.map(p => ` L${p[0].toFixed(1)},${p[1].toFixed(1)}`).join("") + " Z";
  })();

  const yStep = maxVal <= 20 ? 5 : 10;
  const yTicks = [];
  for (let v = 0; v <= maxVal; v += yStep) yTicks.push(v);

  const xLabels = [];
  const seenQ = new Set();
  dates.forEach((d, i) => {
    const [yr, mo] = d.split("-").map(Number);
    const qKey = `${yr}-Q${Math.ceil(mo / 3)}`;
    if (!seenQ.has(qKey) && (mo === 1 || mo === 4 || mo === 7 || mo === 10)) {
      seenQ.add(qKey);
      xLabels.push({ i, label: new Date(d + "T12:00:00").toLocaleDateString("en-CA", { month: "short", year: "2-digit" }) });
    }
  });

  return (
    <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
      {yTicks.map(v => (
        <React.Fragment key={v}>
          <line x1={PL} x2={PL + cW} y1={yp(v).toFixed(1)} y2={yp(v).toFixed(1)}
                stroke="#ede9e0" strokeWidth={0.75} strokeDasharray="2 4"/>
          <text x={PL - 3} y={+yp(v).toFixed(1) + 3.5} textAnchor="end"
                fontSize="8.5" fill="#647071" fontFamily="'IBM Plex Mono',monospace">
            {v}
          </text>
          <text x={PL + cW + 3} y={+yp(v).toFixed(1) + 3.5} textAnchor="start"
                fontSize="8.5" fill="rgba(230,126,34,.55)"
                fontFamily="'IBM Plex Mono',monospace">
            {v}
          </text>
        </React.Fragment>
      ))}
      {bandPath && <path d={bandPath} fill="rgba(41,128,185,0.08)" stroke="none"/>}
      <path d={linePath(avgVol)} fill="none" stroke="#2980b9" strokeWidth="1.8"
            strokeLinejoin="round" strokeLinecap="round"/>
      <path d={linePath(vixAligned)} fill="none" stroke="#e67e22" strokeWidth="1.4"
            strokeLinejoin="round" strokeLinecap="round" strokeDasharray="3 2"/>
      <text x={PL} y={PT - 5} textAnchor="start" fontSize="8" fill="#2073aa"
            fontFamily="'IBM Plex Mono',monospace" letterSpacing=".05em">VOL %</text>
      <text x={PL + cW} y={PT - 5} textAnchor="end" fontSize="8" fill="#e67e22"
            fontFamily="'IBM Plex Mono',monospace" letterSpacing=".05em">VIX</text>
      {xLabels.map(({ i, label }) => (
        <text key={i} x={xp(i)} y={H - 6} textAnchor="middle"
              fontSize="9" fill="#647071" fontFamily="'IBM Plex Mono',monospace">
          {label}
        </text>
      ))}
    </svg>
  );
}

function WatchlistSearchModal({ universe, watchedSet, onClose, onPick }) {
  const [query, setQuery] = useState("");
  const [hi,    setHi]    = useState(0);
  const inputRef = useRef(null);

  useEffect(() => { if (inputRef.current) inputRef.current.focus(); }, []);

  function score(t, term) {
    if (!term) return t.is_core ? 5 : 10;
    const tk  = (t.ticker || "").toLowerCase();
    const nm  = (t.notes  || "").toLowerCase();
    const cat = (t.asset_class || "").toLowerCase();
    if (tk === term)         return 100;
    if (tk.startsWith(term)) return 80;
    if (tk.includes(term))   return 60;
    if (nm.startsWith(term)) return 50;
    if (nm.includes(term))   return 30;
    if (cat.includes(term))  return 15;
    return -1;
  }

  const term    = query.trim().toLowerCase();
  const results = universe
    .map(t => ({ t, s: score(t, term) }))
    .filter(x => !term || x.s > 0)
    .sort((a, b) => a.s !== b.s ? b.s - a.s : (a.t.ticker < b.t.ticker ? -1 : 1))
    .slice(0, 12);

  function handleKey(e) {
    if (e.key === "Escape")    { e.preventDefault(); onClose(); return; }
    if (e.key === "ArrowDown") { e.preventDefault(); setHi(h => Math.min(h + 1, results.length - 1)); return; }
    if (e.key === "ArrowUp")   { e.preventDefault(); setHi(h => Math.max(h - 1, 0)); return; }
    if (e.key === "Enter")     { e.preventDefault(); if (results[hi]) { if (onPick) { onPick(results[hi].t.ticker); onClose(); } else { window.location.href = "/asset/" + results[hi].t.ticker; } } return; }
  }

  const s = {
    backdrop:  { position:"fixed", inset:0, zIndex:10000, background:"rgba(20,18,14,.32)", backdropFilter:"blur(2px)", display:"flex", justifyContent:"center", alignItems:"flex-start", paddingTop:"14vh" },
    modal:     { width:"min(640px,92vw)", background:"#ffffff", border:"1px solid #d4cfc5", borderRadius:6, boxShadow:"0 18px 60px rgba(0,0,0,.18)", overflow:"hidden" },
    inputRow:  { display:"flex", alignItems:"center", gap:10, padding:"14px 16px", borderBottom:"1px solid #d4cfc5" },
    input:     { flex:1, border:"none", outline:"none", background:"transparent", fontFamily:"'Inter Tight',system-ui,sans-serif", fontSize:16, color:"#1a1a1a" },
    kbdHint:   { fontFamily:"'IBM Plex Mono',monospace", fontSize:10, color:"#647071", border:"1px solid #d4cfc5", borderRadius:3, padding:"2px 6px", textTransform:"uppercase", letterSpacing:".08em" },
    results:   { maxHeight:"50vh", overflowY:"auto", padding:"6px 0" },
    row: (i)   => ({ display:"grid", gridTemplateColumns:"14px 60px 1fr auto 18px", gap:12, alignItems:"center", padding:"9px 16px", cursor:"pointer", background: i === hi ? "rgba(41,128,185,.07)" : "transparent", transition:"background .08s" }),
    dot: (on)  => ({ width:8, height:8, borderRadius:"50%", display:"inline-block", flexShrink:0, background: on ? "#8a5709" : "transparent", border: on ? "none" : "1.5px solid #a0aab4", boxSizing:"border-box" }),
    tk:        { fontFamily:"'IBM Plex Mono',monospace", fontSize:13, fontWeight:500, letterSpacing:".03em", color:"#1a1a1a" },
    name:      { fontSize:12.5, color:"#5a6a72", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis" },
    cat:       { fontFamily:"'IBM Plex Mono',monospace", fontSize:10, color:"#647071", letterSpacing:".06em", textTransform:"uppercase" },
    arrow: (i) => ({ color:"#647071", fontFamily:"'IBM Plex Mono',monospace", fontSize:11, opacity: i === hi ? 1 : 0 }),
    foot:      { display:"flex", gap:14, alignItems:"center", padding:"9px 16px", borderTop:"1px solid #d4cfc5", background:"#faf9f7", fontFamily:"'IBM Plex Mono',monospace", fontSize:10.5, letterSpacing:".04em", color:"#647071" },
    footDot: (on) => ({ width:8, height:8, borderRadius:"50%", display:"inline-block", background: on ? "#8a5709" : "transparent", border: on ? "none" : "1.5px solid #a0aab4", boxSizing:"border-box", marginRight:5, verticalAlign:"middle" }),
  };

  return ReactDOM.createPortal(
    <div style={s.backdrop} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div style={s.modal}>
        <div style={s.inputRow}>
          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#a0aab4" strokeWidth="1.6">
            <circle cx="7" cy="7" r="5"/><line x1="11" y1="11" x2="14" y2="14"/>
          </svg>
          <input ref={inputRef} style={s.input}
                 placeholder="Search any ETF — ticker, name, or category..."
                 value={query}
                 onChange={e => { setQuery(e.target.value); setHi(0); }}
                 onKeyDown={handleKey}
          />
          <span style={s.kbdHint}>esc</span>
        </div>
        <div style={s.results}>
          {results.map((x, i) => {
            const tk  = (x.t.ticker || "").replace(/\.TO$/, "");
            const onWl = watchedSet.has(tk);
            return (
              <div key={tk} style={s.row(i)} onMouseEnter={() => setHi(i)} onClick={() => { if (onPick) { onPick(x.t.ticker); onClose(); } else { window.location.href = "/asset/" + x.t.ticker; } }}>
                <span style={s.dot(onWl)}/>
                <span style={s.tk}>{tk}</span>
                <span style={s.name}>{x.t.notes || ""}</span>
                <span style={s.cat}>{x.t.asset_class || ""}</span>
                <span style={s.arrow(i)}>↵</span>
              </div>
            );
          })}
          {!results.length && (
            <div style={{ padding:"20px 18px", color:"#647071", fontSize:12 }}>No matches.</div>
          )}
        </div>
        <div style={s.foot}>
          <span><span style={s.footDot(true)}/>On watchlist</span>
          <span><span style={s.footDot(false)}/>Universe</span>
          <span style={{ marginLeft:"auto" }}>↑↓ navigate · ↵ {onPick ? "select" : "open"} · esc close</span>
        </div>
      </div>
    </div>,
    document.body
  );
}

// ── Observed Correlations — Watchlist heatmap ──────────────────────────────────
// Renders as a styled <table>, not inline SVG: a correlation matrix is tabular
// data, same category as the Watchlist table below it, not a chart with axes —
// the "hand-rolled inline SVG, no Chart.js" rule (see the Market backdrop banner
// below) targets the line/area charts, not this. Data from
// GET /api/dashboard/correlations, which delegates to correlation.py — the same
// function portfolio-report's GET /api/tracker/correlations calls, so the two
// surfaces can never silently disagree.
//
// A close port of portfolio-report's corrHeatmap (its Holdings tab, `.corr-unified`
// CSS + the corrHeatmap IIFE in portfolio_report.py): same dimensions
// (labelW/cellSize/barW/valW formula), same border colours/opacities, same
// class-boundary "gap" row, same avg dashed marker, same class key + colour-scale
// legend below the table. Every cell is a plain <td> — NEVER <th> for the row
// labels, which is what silently bolded them in the first version of this
// component (a browser's UA stylesheet bolds <th> by default; portfolio-report's
// source uses <td> throughout with an explicit font-weight:500, never relying on
// the browser default). Two deliberate departures from the port:
//   1. Column headers render HORIZONTALLY, not portfolio-report's
//      writing-mode:vertical-lr rotated text — rotated 4-char tickers are hard to
//      read, and the whole point of this pass was legibility.
//   2. The row label's left border uses seriesShades() (ISP's one sanctioned
//      per-ticker derivation off classColor(), CLAUDE.md § "Asset-class colours
//      are DATA"), not portfolio-report's raw class colour — same-class tickers
//      stay tellable apart, which portfolio-report's own reference screenshot
//      doesn't attempt.
// "--rule"/"--soft"/"--muted"/"--text"/"--border" (portfolio-report's CSS custom
// properties) map onto ISP's own equivalents already used elsewhere on this tab
// (#f0ece4 / #5a6a72 / #647071 / #1a1a1a / #d4cfc5) rather than importing a
// second, slightly-different grey scale.
const CORR_CLASS_ORDER = [
  "Canada", "Canadian Equity", "US Equity", "Developed Markets", "Developed ex-NA",
  "Emerging Markets", "Emerging Mkts", "Fixed Income", "Bond",
  "Preferred", "Preferred Shares", "Alternatives", "LDI", "Multi-Asset", "Cash",
];

function corrColor(v) {
  if (v >= 0) {
    const t = Math.min(v, 1);
    return `rgb(${Math.round(255 - t * 130)},${Math.round(255 - t * 90)},255)`;
  }
  const t = Math.min(-v, 1);
  return `rgb(255,${Math.round(255 - t * 130)},${Math.round(255 - t * 90)})`;
}
function corrTextColor(v) { return Math.abs(v) > 0.6 ? "#fff" : "#1e293b"; }

function CorrelationHeatmap({ data }) {
  if (!data || !data.tickers || data.tickers.length < 2) return null;

  const { tickers, classes, weights, vols, matrix, dateRange, nObs } = data;

  // Shade computed from the ORIGINAL (alphabetical, as received) order, matching
  // how PriceIndexChart/AssetMovementChart derive theirs from priceHistory — same
  // ticker set, same alpha order out of the backend, so a fund keeps one colour
  // across every Track tab chart.
  const shade = seriesShades(tickers.map((t, i) => ({ ticker: t, asset_class: classes[i] })));

  // Re-sort by class order (display only — the matrix stays keyed by ticker
  // string, never by position) so same-class funds group into a contiguous,
  // colour-banded block, matching the portfolio-report layout this mirrors.
  const order = tickers.map((_, i) => i);
  order.sort((a, b) => {
    const ca = CORR_CLASS_ORDER.indexOf(classes[a]); const ca2 = ca < 0 ? 999 : ca;
    const cb = CORR_CLASS_ORDER.indexOf(classes[b]); const cb2 = cb < 0 ? 999 : cb;
    if (ca2 !== cb2) return ca2 - cb2;
    return tickers[a].localeCompare(tickers[b]);
  });
  const T = order.map(i => tickers[i]);
  const C = order.map(i => classes[i]);
  const W = order.map(i => weights[i]);
  const V = order.map(i => (vols && vols[i]) || 0);
  const M = order.map(oi => order.map(oj => matrix[oi][oj]));
  const n = T.length;
  const clsList = [...new Set(C)];

  const wTotal = W.reduce((a, b) => a + b, 0);
  const hasWeights = wTotal > 0;

  // "Corr to portfolio": the ACTUAL correlation of ticker i's returns to the
  // portfolio's own return series R_p = sum_k W[k]*r_k — INCLUDING k=i, since a
  // holding is part of its own portfolio. This is why it's computed from vols,
  // not just the matrix: corr(i,P) = Cov(r_i,R_p) / (sigma_i * sigma_P), and
  // Cov(r_i,R_p) = sum_k W[k] * sigma_i * sigma_k * M[i][k], so the sigma_i
  // cancels and corr(i,P) = sum_k(W[k]*V[k]*M[i][k]) / sigma_P.
  //
  // An earlier version excluded k=i and returned a weighted AVERAGE of ticker
  // i's correlation to every OTHER holding — a different, weaker statistic that
  // degenerates to a meaningless 0.00 for a ticker that IS the whole portfolio
  // (nothing else to average against), when the honest answer is 1.00: a fund
  // that constitutes your whole portfolio is, by definition, perfectly
  // correlated with it.
  let portVar = 0;
  for (let j = 0; j < n; j++)
    for (let k = 0; k < n; k++)
      portVar += W[j] * W[k] * V[j] * V[k] * M[j][k];
  const portSigma = Math.sqrt(Math.max(portVar, 0));
  const wCorr = T.map((_, i) => {
    if (portSigma <= 0) return 0;
    let num = 0;
    for (let k = 0; k < n; k++) num += W[k] * V[k] * M[i][k];
    return num / portSigma;
  });
  let pairSum = 0, pairCnt = 0;
  for (let i = 0; i < n; i++)
    for (let j = i + 1; j < n; j++) { pairSum += M[i][j]; pairCnt++; }
  const avgCorr = pairCnt > 0 ? pairSum / pairCnt : 0;
  const avgLeft = (avgCorr / 0.7) * 100;
  const maxV = 0.7;

  // Dimensions — same formula as portfolio-report's corrHeatmap.
  const cellSize = Math.max(40, Math.min(56, Math.floor(560 / n)));
  const labelW   = 84;
  const barW     = 160;
  const valW     = 42;
  const headerH  = 30;
  const tableW   = labelW + cellSize * (n - 1) + 1 + (hasWeights ? barW + valW : 0);

  const isBlockEnd = i => i < n - 1 && C[i] !== C[i + 1];
  // heavyB reads as a GAP, not a drawn line — its colour is the page background
  // (#f5f0e8) showing through, not the card's own white, exactly like
  // portfolio-report's trick against its own matching page bg.
  const heavyB     = "2px solid #f5f0e8";
  const lightRowB  = "1px solid rgba(212,207,197,.18)";
  const lightCellB = "1px solid rgba(212,207,197,.35)";

  return (
    <div className="db-card">
      <div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline",marginBottom:14,flexWrap:"wrap",gap:6}}>
        <div className="pf-card-title">Observed Correlations</div>
        {dateRange && (
          <div style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:11,color:"#647071",letterSpacing:".06em"}}>
            {dateRange} · {nObs} OBS
          </div>
        )}
      </div>

      <div style={{overflowX:"auto",padding:"8px 12px 0"}}>
        <table className="db-corr-tbl" style={{width:tableW}}>
          <tbody>
            {/* Row 1: 4px class-colour strip across the matrix columns */}
            <tr>
              <td style={{width:labelW,height:4,padding:0}} />
              {T.slice(0, n - 1).map((t, j) => (
                <td key={t} style={{width:cellSize,height:4,padding:0,
                                     background:classColor(C[j]),
                                     borderRight: isBlockEnd(j) ? heavyB : "none"}} />
              ))}
              <td className="db-corr-gutter" style={{height:4,padding:0}} />
              {hasWeights && <td colSpan={2} style={{height:4,padding:0}} />}
            </tr>

            {/* Row 2: column labels (horizontal — see the file-header note on why
                this departs from portfolio-report's rotated text) + axis label */}
            <tr>
              <td style={{width:labelW,height:headerH,padding:0}} />
              {T.slice(0, n - 1).map((t, j) => (
                <td key={t} style={{width:cellSize,height:headerH,textAlign:"center",
                                     padding:"4px 2px",fontWeight:500,color:"#5a6a72",
                                     fontSize:11,fontFamily:"'IBM Plex Mono',monospace",
                                     borderRight: isBlockEnd(j) ? heavyB : "none"}}>
                  {t}
                </td>
              ))}
              <td className="db-corr-gutter" />
              {hasWeights && (
                <td colSpan={2} className="db-corr-bar-axis">
                  <span style={{textTransform:"uppercase",letterSpacing:".08em"}}>corr to portfolio</span>
                </td>
              )}
            </tr>

            {/* Data rows: all n tickers (row 0 has no matrix cells, just a bar) */}
            {T.map((ti, i) => {
              const rowB = isBlockEnd(i) ? heavyB : lightRowB;
              return (
                <tr key={ti}>
                  <td className="db-corr-lbl"
                      style={{borderLeft:`3px solid ${shade[ti]}`, borderBottom:rowB, height:cellSize}}>
                    {ti}
                  </td>
                  {T.slice(0, n - 1).map((tj, j) => {
                    if (j >= i) {
                      return <td key={tj} className="db-corr-empty"
                                  style={{width:cellSize,height:cellSize,borderBottom:rowB}} />;
                    }
                    const v = M[i][j];
                    return (
                      <td key={tj} className="db-corr-cell"
                          style={{
                            width:cellSize, height:cellSize,
                            background: corrColor(v), color: corrTextColor(v),
                            borderRight: isBlockEnd(j) ? heavyB : lightCellB,
                            borderBottom: isBlockEnd(i) ? heavyB : lightCellB,
                          }}>
                        {v.toFixed(2)}
                      </td>
                    );
                  })}
                  <td className="db-corr-gutter" style={{height:cellSize,borderBottom:rowB}} />
                  {hasWeights && (
                    <React.Fragment>
                      <td className="db-corr-bar-cell" style={{width:barW,height:cellSize,borderBottom:rowB}}>
                        <div style={{position:"relative",height:14,background:"rgba(0,0,0,.025)",
                                     borderRadius:2,width:barW - 14}}>
                          <div style={{position:"absolute",top:0,left:0,bottom:0,borderRadius:2,
                                       opacity:.88,width:`${Math.min((wCorr[i] / maxV) * 100, 100)}%`,
                                       background:classColor(C[i])}} />
                          {i === 0 && (
                            <div style={{position:"absolute",top:-16,left:`${avgLeft}%`,
                                         transform:"translateX(-50%)",fontSize:9,color:"#5a6a72",
                                         whiteSpace:"nowrap"}}>
                              avg {avgCorr.toFixed(2)}
                            </div>
                          )}
                          <div style={{position:"absolute",top:-3,bottom:-3,width:1,left:`${avgLeft}%`,
                                       borderLeft:"1px dashed #5a6a72"}} />
                        </div>
                      </td>
                      <td className="db-corr-bar-val" style={{width:valW,height:cellSize,borderBottom:rowB}}>
                        {wCorr[i].toFixed(2)}
                      </td>
                    </React.Fragment>
                  )}
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      {/* Class key */}
      <div style={{display:"flex",flexWrap:"wrap",justifyContent:"center",gap:"10px 18px",
                   marginTop:14,fontFamily:"system-ui",fontSize:11,color:"#5a6a72"}}>
        {clsList.map(c => (
          <span key={c} style={{display:"inline-flex",alignItems:"center",gap:6}}>
            <span style={{display:"inline-block",width:9,height:9,borderRadius:2,background:classColor(c)}} />
            {c}
          </span>
        ))}
      </div>

      {/* Colour-scale legend */}
      <div style={{display:"flex",alignItems:"center",justifyContent:"center",gap:4,marginTop:10}}>
        <span style={{fontWeight:600,fontSize:11,color:"#5a6a72",fontFamily:"system-ui"}}>&minus;1</span>
        {[-1, -0.5, 0, 0.5, 1].map(v => (
          <span key={v} style={{display:"inline-block",width:22,height:12,
                                 background:corrColor(v),border:"1px solid #d4cfc5",borderRadius:2}} />
        ))}
        <span style={{fontWeight:600,fontSize:11,color:"#5a6a72",fontFamily:"system-ui"}}>+1</span>
      </div>
    </div>
  );
}

// ── Market backdrop ("Economic Indicators" section on the Track tab) ──────────
// Simplified re-home of the legacy macro dashboard: one shared multi-series hero
// chart + a per-indicator "Portfolio Impact" table that ties each economic signal
// to the user's own asset-class exposure. Data from GET /api/dashboard/macro.
// All charts are hand-rolled inline SVG (no-build convention — no Chart.js).

// Shared lookback windows (label → months). MAX uses a large N so slice() takes all.
const MB_PERIODS = [["1Y", 12], ["3Y", 36], ["5Y", 60], ["MAX", 9999]];

// The 5 asset-class buckets the exposure bars segment into (+ Other fallback).
// Colours are READ from the live palette via classColor (/asset-classes.js, falling
// back to CLASS_COLORS_FALLBACK) — never pasted in as literals. This list used to
// hold its own copy and silently drifted a full palette behind the rest of the app,
// which is exactly the sixth-copy problem the asset_class_style table exists to end.
// "Other" is not a class, so it keeps its own neutral. Module-level constant.
const MB_BUCKETS = [
  { k: "ca",    label: "Canada",       c: classColor("Canadian Equity")  },
  { k: "dev",   label: "Developed",    c: classColor("Developed Markets") },
  { k: "em",    label: "Emerging",     c: classColor("Emerging Markets")  },
  { k: "fi",    label: "Fixed income", c: classColor("Fixed Income")      },
  { k: "cash",  label: "Cash",         c: classColor("Cash")              },
  { k: "other", label: "Other",        c: "#b3ac9e" },
];

// Per-indicator config: identity + copy + display formatting + editorial touch-map.
// `series` key matches the backend response; `color` is the mini-sparkline colour
// (the hero chart uses its own fixed CPI/HY/curve colours). `tone` colour-codes the
// current reading semantically; `touch` lists which buckets the exposure bar lights.
// Module-level constant — do not move inside a component.
// y-axis label formatters for the mini charts (compact — a few chars).
const _mbY = {
  pct1:  v => v.toFixed(1) + "%",
  cad:   v => v.toFixed(2),
  idx0:  v => v.toFixed(0),
  sgn1:  v => v.toFixed(1),
  thous: v => (v / 1000).toFixed(0) + "k",
  tril:  v => "$" + (v / 1e6).toFixed(1) + "T",
};

// Full indicator catalog (every non-vix macro series). `key` matches the backend
// series id. `touch` / `touchCopy` are editorial (which book buckets an indicator
// lights up); `tone` colour-codes the current reading; `fmtY` labels the sparkline
// y-axis; `fmtVal` overrides the big current-reading format for non-% series.
// Module-level constant — do not move inside a component.
const MB_CATALOG = [
  { key: "boc10y", name: "Canada 10-year yield", def: "The rate the Canadian government pays to borrow for 10 years",
    dp: 2, unit: "%", deltaMode: "pts", color: "#1f6a9a", fmtY: _mbY.pct1, tone: () => "",
    touch: ["fi"], touchCopy: <><b>Fixed income</b> — bond prices move inverse to yields</> },
  { key: "boc5y", name: "Canada 5-year yield", def: "The rate the Canadian government pays to borrow for 5 years",
    dp: 2, unit: "%", deltaMode: "pts", color: "#2980b9", fmtY: _mbY.pct1, tone: () => "",
    touch: ["fi"], touchCopy: <><b>Fixed income</b> — mid-term bond prices move inverse to yields</> },
  { key: "boc2y", name: "Canada 2-year yield", def: "The rate the Canadian government pays to borrow for 2 years",
    dp: 2, unit: "%", deltaMode: "pts", color: "#5b9bd5", fmtY: _mbY.pct1, tone: () => "",
    touch: ["fi"], touchCopy: <><b>Fixed income</b> — short-end bond prices move inverse to yields</> },
  { key: "curve", name: "Yield curve · 10Y − 2Y", def: "Long-term rates minus short-term rates — negative has historically flagged recession risk",
    dp: 2, unit: "%", deltaMode: "pts", color: "#b34030", zero: true, fmtY: _mbY.pct1, tone: (v) => (v < 0 ? "red" : ""),
    touch: ["ca", "dev", "em"], touchCopy: <><b>All equities</b> — a widely-watched signal for late-cycle caution</> },
  { key: "uscurve", name: "US yield curve · 10Y − 2Y", def: "US long minus short rates — the recession bellwether markets watch most",
    dp: 2, unit: "%", deltaMode: "pts", color: "#d97362", zero: true, fmtY: _mbY.pct1, tone: (v) => (v < 0 ? "red" : ""),
    touch: ["ca", "dev", "em"], touchCopy: <><b>All equities</b> — the US signal for late-cycle caution</> },
  { key: "cpi", name: "Core inflation · CPI-median", def: "How fast prices are rising · Bank of Canada targets 1–3%",
    dp: 1, unit: "%", deltaMode: "pts", color: "#a1450f", band: [1, 3], fmtY: _mbY.pct1, tone: (v) => (v >= 1 && v <= 3 ? "green" : "amber"),
    touch: ["fi", "cash"], touchCopy: <><b>Fixed income &amp; cash</b> — reduces the real buying power of these holdings over time</> },
  { key: "cpicommon", name: "Core inflation · CPI-common", def: "BoC core inflation, common component · target 1–3%",
    dp: 1, unit: "%", deltaMode: "pts", color: "#c2571a", band: [1, 3], fmtY: _mbY.pct1, tone: (v) => (v >= 1 && v <= 3 ? "green" : "amber"),
    touch: ["fi", "cash"], touchCopy: <><b>Fixed income &amp; cash</b> — reduces the real buying power of these holdings over time</> },
  { key: "cpitrim", name: "Core inflation · CPI-trim", def: "BoC core inflation, trimmed mean · target 1–3%",
    dp: 1, unit: "%", deltaMode: "pts", color: "#e0673a", band: [1, 3], fmtY: _mbY.pct1, tone: (v) => (v >= 1 && v <= 3 ? "green" : "amber"),
    touch: ["fi", "cash"], touchCopy: <><b>Fixed income &amp; cash</b> — reduces the real buying power of these holdings over time</> },
  { key: "hy", name: "High-yield credit spread", def: "Extra yield investors demand to hold riskier corporate debt over safe government debt",
    dp: 2, unit: "%", deltaMode: "pts", color: "#8e44ad", fmtY: _mbY.pct1, tone: () => "",
    touch: ["em", "fi"], touchCopy: <><b>Emerging &amp; credit-sensitive bonds</b> — a narrowing spread is a good sign for these holdings</> },
  { key: "ig", name: "Investment-grade credit spread", def: "Extra yield on investment-grade corporate debt over government debt",
    dp: 2, unit: "%", deltaMode: "pts", color: "#a56cc1", fmtY: _mbY.pct1, tone: () => "",
    touch: ["fi"], touchCopy: <><b>Fixed income</b> — wider spreads pressure investment-grade bond prices</> },
  { key: "cad", name: "CAD / USD", def: "The Canadian dollar's value against the US dollar",
    dp: 3, unit: "", deltaMode: "pct", color: "#0e7a68", fmtY: _mbY.cad, tone: () => "",
    touch: ["dev", "em"], touchCopy: <><b>Unhedged foreign</b> — Developed &amp; Emerging market holdings</> },
  { key: "dxy", name: "US dollar index · DXY", def: "The US dollar's strength against a basket of major currencies",
    dp: 1, unit: "", deltaMode: "pct", color: "#16a085", fmtY: _mbY.idx0, tone: () => "",
    touch: ["dev", "em"], touchCopy: <><b>Unhedged foreign</b> — a strong USD lifts your foreign holdings in CAD terms</> },
  { key: "ceer", name: "CAD effective exchange rate", def: "The loonie's trade-weighted value against Canada's trading partners",
    dp: 1, unit: "", deltaMode: "pct", color: "#4bbfa8", fmtY: _mbY.idx0, tone: () => "",
    touch: ["dev", "em"], touchCopy: <><b>Unhedged foreign</b> — the loonie's broad trade-weighted value</> },
  { key: "cfnai", name: "US activity · CFNAI", def: "A broad monthly gauge of US economic activity · 0 = trend growth",
    dp: 2, unit: "", deltaMode: "pts", color: "#5b4fa8", zero: true, fmtY: _mbY.sgn1,
    tone: (v) => (v >= 0 ? "green" : (v < -0.7 ? "red" : "amber")),
    touch: ["dev", "em"], touchCopy: <><b>Foreign equities</b> — a broad read on US economic momentum</> },
  { key: "jobless", name: "US initial jobless claims", def: "Weekly count of new US unemployment filings",
    dp: 0, unit: "", deltaMode: "pct", color: "#c2185b", fmtY: _mbY.thous, fmtVal: v => (v / 1000).toFixed(0) + "k", tone: () => "",
    touch: ["dev", "em"], touchCopy: <><b>Foreign equities</b> — an early read on the US labour market</> },
  { key: "fedres", name: "Fed balance · reserves", def: "Bank reserves at the US Federal Reserve — a proxy for system liquidity",
    dp: 0, unit: "", deltaMode: "pct", color: "#5a6a72", fmtY: _mbY.tril, fmtVal: v => "$" + (v / 1e6).toFixed(2) + "T", tone: () => "",
    touch: ["ca", "dev", "em"], touchCopy: <><b>All equities</b> — central-bank liquidity that ripples into risk assets</> },
];
const MB_BY_ID = Object.fromEntries(MB_CATALOG.map(c => [c.key, c]));
// Default Portfolio Impact selection (until the user customises). Mirrors app.py _MB_DEFAULT_IDS.
const MB_DEFAULT_IDS = ["boc10y", "curve", "cpi", "hy", "cad"];

function mbFmtVal(cur, ind) {
  if (!cur || cur.v == null) return "—";
  if (ind.fmtVal) return ind.fmtVal(cur.v);
  const neg = cur.v < 0;
  return (neg ? "−" : "") + Math.abs(cur.v).toFixed(ind.dp) + ind.unit;
}

function mbFmtDelta(cur, ind) {
  if (!cur || cur.prior1y == null) return null;
  const raw = cur.v - cur.prior1y;
  const up = raw >= 0;
  let text;
  if (ind.deltaMode === "pct") {
    const pct = cur.prior1y !== 0 ? (raw / cur.prior1y) * 100 : 0;
    text = (up ? "+" : "−") + Math.abs(pct).toFixed(1) + "% / yr";
  } else {
    text = (up ? "+" : "−") + Math.abs(raw).toFixed(ind.dp) + " / yr";
  }
  return { up, text };
}

// Mini trend chart with real orientation. `points` is the full series; `domain` is the
// shared sorted month-key axis (YYYY-MM) for the section's current period, so every chart
// uses the SAME time axis — a shorter series (e.g. HY) just starts partway across, and the
// year ticks line up identically across rows. Carries y-axis value labels (min/mid/max via
// `fmtY`) for magnitude and January-boundary year ticks (thinned) with faint vertical
// gridlines so a spike can be placed in time. Extra headroom above the top so nothing chops.
function MacroSparkline({ points, color, zero, band, fmtY, domain }) {
  const W = 240, H = 116, pl = 40, pr = 10, pt = 14, pb = 20;
  const blank = <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} preserveAspectRatio="none" style={{display:"block"}}/>;
  const months = (domain && domain.length >= 2) ? domain : (points || []).map(p => p.d.slice(0, 7));
  const N = months.length;
  if (N < 2 || !points || points.length < 2) return blank;
  const fy = fmtY || (v => (Math.round(v * 100) / 100).toString());
  const valByMonth = {}; points.forEach(p => { valByMonth[p.d.slice(0, 7)] = p.v; });
  const mapped = months.map(mk => (mk in valByMonth ? valByMonth[mk] : null));
  const present = mapped.filter(v => v != null);
  if (present.length < 2) return blank;
  const dataLo = Math.min(...present), dataHi = Math.max(...present);
  let lo = dataLo, hi = dataHi;
  if (zero) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); }
  if (band) { lo = Math.min(lo, band[0]); hi = Math.max(hi, band[1]); }
  const R = (hi - lo) || Math.abs(hi) || 1;
  const loP = lo - R * 0.10, hiP = hi + R * 0.16;   // asymmetric — extra headroom at the top
  const x0 = pl, x1 = W - pr, yT = pt, yB = H - pb;
  const sx = i => x0 + (x1 - x0) * (i / (N - 1));
  const sy = v => yB - (yB - yT) * ((v - loP) / (hiP - loP));
  let d = "", brk = true;
  mapped.forEach((v, i) => { if (v == null) { brk = true; return; } d += (brk ? "M" : "L") + sx(i).toFixed(1) + " " + sy(v).toFixed(1) + " "; brk = false; });
  let lastI = -1; for (let i = mapped.length - 1; i >= 0; i--) if (mapped[i] != null) { lastI = i; break; }
  const yGrid = dataHi - dataLo > 1e-9 ? [dataHi, (dataHi + dataLo) / 2, dataLo] : [dataHi];
  // Year ticks at January boundaries in the shared domain, thinned to <=6 labels — evenly
  // spaced (12 months apart) and identical across every chart in the section.
  const yearTicks = []; months.forEach((mk, i) => { if (mk.slice(5, 7) === "01") yearTicks.push({ i, y: mk.slice(0, 4) }); });
  const step = Math.max(1, Math.ceil(yearTicks.length / 6));
  const shown = yearTicks.filter((_, k) => k % step === 0);
  return (
    <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} preserveAspectRatio="none" style={{display:"block"}}>
      {band && <rect x={x0} y={sy(band[1])} width={x1 - x0} height={Math.max(0, sy(band[0]) - sy(band[1]))} fill="rgba(45,122,71,.10)"/>}
      {shown.map((t, k) => (
        <line key={"vx" + k} x1={+sx(t.i).toFixed(1)} y1={yT} x2={+sx(t.i).toFixed(1)} y2={yB} stroke="#f2ede3" strokeWidth={1}/>
      ))}
      {yGrid.map((v, k) => (
        <React.Fragment key={"y" + k}>
          <line x1={x0} y1={+sy(v).toFixed(1)} x2={x1} y2={+sy(v).toFixed(1)} stroke="#eee6d8" strokeWidth={1}/>
          <text x={x0 - 4} y={+sy(v).toFixed(1) + 3} textAnchor="end" fill="#647071" fontFamily="'IBM Plex Mono',monospace" fontSize={8.5}>{fy(v)}</text>
        </React.Fragment>
      ))}
      {zero && <line x1={x0} y1={sy(0)} x2={x1} y2={sy(0)} stroke="#c9c2b4" strokeWidth={1} strokeDasharray="2 2"/>}
      <path d={d.trim()} fill="none" stroke={color} strokeWidth={1.6} strokeLinejoin="round" strokeLinecap="round"/>
      {lastI >= 0 && <circle cx={sx(lastI)} cy={sy(mapped[lastI])} r={2.6} fill={color}/>}
      {shown.map((t, k) => (
        <text key={"xl" + k} x={+sx(t.i).toFixed(1)} y={H - 6}
              textAnchor={k === 0 ? "start" : (k === shown.length - 1 ? "end" : "middle")}
              fill="#647071" fontFamily="'IBM Plex Mono',monospace" fontSize={8.5}>{t.y}</text>
      ))}
    </svg>
  );
}

// Hero context chart — CPI + HY share the left axis; the 10Y−2Y curve is on the
// right axis with a dashed zero line. Series are aligned on a shared monthly axis
// so a shorter series (HY) simply starts partway across. All arrays pre-sliced.
function MacroHeroChart({ cpi, hy, curve }) {
  // Align on month key (YYYY-MM), NOT the full observation date: CPI is stamped the 1st of
  // the month while the yield/FX series are stamped month-end, so keying on the full date
  // would never line them up — each series would be non-null only at its own dates, breaking
  // every line into disconnected single-point subpaths. Month-key alignment connects them.
  const months = [...new Set([...cpi, ...hy, ...curve].map(p => p.d.slice(0, 7)))].sort();
  const N = months.length;
  if (N < 2) return null;
  const mapv = arr => { const m = {}; arr.forEach(p => { m[p.d.slice(0, 7)] = p.v; }); return months.map(k => (k in m ? m[k] : null)); };
  const cpiV = mapv(cpi), hyV = mapv(hy), curveV = mapv(curve);
  const leftVals = [...cpiV, ...hyV].filter(v => v != null);
  const curveVals = curveV.filter(v => v != null);
  if (!leftVals.length || !curveVals.length) return null;
  const leftLo = Math.min(0, ...leftVals);
  const leftHi = Math.max(...leftVals) * 1.12;
  const cLo = Math.min(Math.min(...curveVals) * 1.15, 0);
  const cHi = Math.max(Math.max(...curveVals) * 1.15, 0.1);

  // Same frame/aspect (3:1, preserveAspectRatio meet) and axis styling as PriceIndexChart, so
  // the two Track-tab line charts read as one family instead of two different draw styles.
  const W = 600, H = 200, PT = 10, PB = 24, PL = 38, PR = 46;
  const cW = W - PL - PR, cH = H - PT - PB;
  const sx = i => PL + (i / (N - 1)) * cW;
  const syL = v => PT + cH * (1 - (v - leftLo) / (leftHi - leftLo));
  const syR = v => PT + cH * (1 - (v - cLo) / (cHi - cLo));
  const path = (vals, sy) => {
    let d = "", brk = true;
    vals.forEach((v, i) => {
      if (v == null) { brk = true; return; }
      d += (brk ? "M" : "L") + sx(i).toFixed(1) + "," + sy(v).toFixed(1) + " ";
      brk = false;
    });
    return d.trim();
  };
  const leftTicks = [], rightTicks = [];
  for (let k = 0; k <= 4; k++) { leftTicks.push(leftLo + ((leftHi - leftLo) / 4) * k); rightTicks.push(cLo + ((cHi - cLo) / 4) * k); }
  // One label per calendar year, at that year's first month, centred like PriceIndexChart.
  const yl = []; const seen = {};
  months.forEach((d, i) => { const yr = d.slice(0, 4); if (!seen[yr]) { seen[yr] = true; yl.push({ i, yr }); } });
  const lastNN = vals => { for (let i = vals.length - 1; i >= 0; i--) if (vals[i] != null) return i; return -1; };
  const cvi = lastNN(curveV), ci = lastNN(cpiV), hyi = lastNN(hyV);
  return (
    <svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",display:"block"}}>
      {/* Left-axis dashed gridlines + labels (CPI / HY, %) */}
      {leftTicks.map((v, i) => {
        const y = syL(v).toFixed(1);
        return (
          <React.Fragment key={"l" + i}>
            <line x1={PL} x2={PL + cW} y1={y} y2={y} stroke="#ede9e0" strokeWidth={0.75} strokeDasharray="2 4"/>
            <text x={PL - 6} y={+y + 3} textAnchor="end" fill="#647071"
                  fontFamily="'IBM Plex Mono',monospace" fontSize={8.5}>{v.toFixed(1)}%</text>
          </React.Fragment>
        );
      })}
      {/* Right-axis labels (yield curve, %) */}
      {rightTicks.map((v, i) => (
        <text key={"r" + i} x={PL + cW + 6} y={+syR(v).toFixed(1) + 3} textAnchor="start" fill="#647071"
              fontFamily="'IBM Plex Mono',monospace" fontSize={8.5}>{v.toFixed(1)}%</text>
      ))}
      {/* Curve zero reference line — styled like PriceIndexChart's "100" line */}
      <line x1={PL} x2={PL + cW} y1={+syR(0).toFixed(1)} y2={+syR(0).toFixed(1)}
            stroke="#d4cfc5" strokeWidth={1} strokeDasharray="3 4"/>
      {/* Lines — HY keeps its MB_CATALOG hue (#8e44ad) so it matches its sparkline in the
          Portfolio Impact table below. It was #b34030, which is the *yield curve's* catalog
          colour, so red meant two things on one screen — and it sat at ΔE 4.7 against the
          CPI clay line, indistinguishable. Do not repaint HY red. */}
      <path d={path(curveV, syR)} fill="none" stroke="#5a6a72" strokeWidth={1.6} strokeLinejoin="round" strokeLinecap="round"/>
      <path d={path(cpiV, syL)}  fill="none" stroke="#a1450f" strokeWidth={1.6} strokeLinejoin="round" strokeLinecap="round"/>
      <path d={path(hyV, syL)}   fill="none" stroke="#8e44ad" strokeWidth={1.6} strokeLinejoin="round" strokeLinecap="round"/>
      {/* Current-value dots */}
      {cvi >= 0 && <circle cx={sx(cvi)} cy={syR(curveV[cvi])} r={2.6} fill="#5a6a72"/>}
      {ci  >= 0 && <circle cx={sx(ci)}  cy={syL(cpiV[ci])}   r={2.6} fill="#a1450f"/>}
      {hyi >= 0 && <circle cx={sx(hyi)} cy={syL(hyV[hyi])}   r={2.6} fill="#8e44ad"/>}
      {/* X-axis year labels (centred) */}
      {yl.map(o => (
        <text key={o.yr} x={+sx(o.i).toFixed(1)} y={H - 6} textAnchor="middle" fill="#647071"
              fontFamily="'IBM Plex Mono',monospace" fontSize={9}>{o.yr}</text>
      ))}
    </svg>
  );
}

// Segmented allocation bar: full opacity on the buckets this indicator touches,
// dimmed on the rest — the same book allocation re-highlighted per indicator.
function ExposureBar({ buckets, touch }) {
  return (
    <div className="mb-bar">
      {MB_BUCKETS.map(b => {
        const pct = (buckets[b.k] || {}).pct || 0;
        if (pct <= 0) return null;
        return <div key={b.k} className="mb-seg" style={{width: pct + "%", background: b.c, opacity: touch.includes(b.k) ? 1 : 0.14}}/>;
      })}
    </div>
  );
}

function MarketBackdrop({ macro }) {
  const [period, setPeriod]     = useState("5Y");
  const [selected, setSelected] = useState(Array.isArray(macro.selected) ? macro.selected : MB_DEFAULT_IDS);
  const [showAdd, setShowAdd]   = useState(false);
  const series  = macro.series  || {};
  const current = macro.current || {};
  const book    = macro.book    || { total: 0, buckets: {} };
  const nMonths = (MB_PERIODS.find(p => p[0] === period) || MB_PERIODS[2])[1];
  const sl    = arr => (arr || []).slice(Math.max(0, (arr || []).length - nMonths));
  const slice = id => sl(series[id]);
  // Shared month-key axis for the current period: the union of every series' months,
  // trimmed to the last nMonths. Every sparkline maps onto this one axis so their year
  // ticks line up identically (a shorter series just starts partway across).
  const windowMonths = (() => {
    const set = new Set();
    Object.values(series).forEach(arr => (arr || []).forEach(p => set.add(p.d.slice(0, 7))));
    const all = [...set].sort();
    return all.slice(Math.max(0, all.length - nMonths));
  })();

  // Persist the chosen Portfolio Impact indicators (optimistic; server dedupes/validates).
  function persist(next) {
    setSelected(next);
    api("/api/dashboard/macro-indicators", { method: "PATCH", body: { indicators: next } });
  }
  const removeInd = id => persist(selected.filter(x => x !== id));
  const addInd    = id => { persist([...selected, id]); setShowAdd(false); };
  const available = MB_CATALOG.filter(c => !selected.includes(c.key));

  return (
    <>
      <div className="mb-toolbar" style={{alignItems:"flex-end"}}>
        <div>
          <h2 className="pf-section-head" style={{marginBottom:4}}>Economic Indicators</h2>
          <p className="sp-lead" style={{margin:0,maxWidth:640}}>
            Economic indicators move slowly compared to equity markets, but often impact equity
            returns over time — this section shows economic changes over longer periods of time.
          </p>
        </div>
        <div className="mb-period">
          {MB_PERIODS.map(p => (
            <button key={p[0]} className={"mb-pill" + (period === p[0] ? " active" : "")}
                    onClick={() => setPeriod(p[0])}>{p[0]}</button>
          ))}
        </div>
      </div>

      <div className="mb-toolbar" style={{marginTop:22,marginBottom:10}}>
        <div className="sp-section-label">Key economic indicators</div>
      </div>
      <div className="db-card" style={{padding:"20px 22px 16px"}}>
        <div className="mb-legend" style={{marginBottom:14}}>
          <span><span className="mb-swatch" style={{background:"#a1450f"}}/>Inflation, core CPI (left axis)</span>
          <span><span className="mb-swatch" style={{background:"#8e44ad"}}/>High-yield credit spread (left axis)</span>
          <span><span className="mb-swatch" style={{background:"#5a6a72"}}/>Yield curve, 10Y − 2Y (right axis)</span>
        </div>
        <MacroHeroChart cpi={slice("cpi")} hy={slice("hy")} curve={slice("curve")}/>
        <p className="mb-cap">
          Inflation drives central-bank rate decisions, which show up in credit spreads and the
          yield curve — and all three eventually ripple into equity markets, given enough time.
          (The timing and size of that ripple can still surprise everyone, including the experts.)
        </p>
      </div>

      {book.total > 0 && (
        <>
          <div className="mb-toolbar" style={{marginTop:26}}>
            <div className="sp-section-label" style={{marginBottom:0}}>Portfolio Impact</div>
            <div style={{marginLeft:"auto",position:"relative"}}>
              <button className="mb-add-btn" onClick={() => setShowAdd(s => !s)}>
                <span style={{fontSize:13,lineHeight:1}}>+</span> Add indicator
              </button>
              {showAdd && (
                <>
                  <div style={{position:"fixed",inset:0,zIndex:15}} onClick={() => setShowAdd(false)}/>
                  <div className="mb-add-menu">
                    {available.length ? available.map(c => (
                      <button key={c.key} className="mb-add-item" onClick={() => addInd(c.key)}>{c.name}</button>
                    )) : <div className="mb-add-empty">All indicators added</div>}
                  </div>
                </>
              )}
            </div>
          </div>
          <div className="db-card" style={{padding:"6px 22px 20px"}}>
            <div className="mb-colhdr" style={{paddingTop:16}}>
              <span>Indicator</span>
              <span>Trend, selected period</span>
              <span>What it touches in your portfolio</span>
            </div>
            <div className="mb-rows">
              {selected.map(id => {
                const ind = MB_BY_ID[id];
                if (!ind) return null;
                const cur     = current[id];
                const toneCls = cur && cur.v != null ? ind.tone(cur.v) : "";
                const delta   = mbFmtDelta(cur, ind);
                const amt     = ind.touch.reduce((s, k) => s + (((book.buckets[k] || {}).amt) || 0), 0);
                const pct     = book.total > 0 ? Math.round((amt / book.total) * 100) : 0;
                return (
                  <div className="mb-row" key={id}>
                    <button className="mb-row-x" title={`Remove ${ind.name}`} aria-label={`Remove ${ind.name}`} onClick={() => removeInd(id)}>×</button>
                    <div>
                      <div className="mb-name">{ind.name}</div>
                      <div className="mb-def">{ind.def}</div>
                      <div className="mb-read">
                        <span className={"mb-val" + (toneCls ? " " + toneCls : "")}>{mbFmtVal(cur, ind)}</span>
                        {delta && (
                          <span className={"mb-delta " + (delta.up ? "up" : "down")}>
                            <i className={"ti ti-arrow-" + (delta.up ? "up-right" : "down-right")}/>{delta.text}
                          </span>
                        )}
                      </div>
                    </div>
                    <div className="mb-spark">
                      <MacroSparkline points={series[id]} color={ind.color} zero={ind.zero} band={ind.band} fmtY={ind.fmtY} domain={windowMonths}/>
                    </div>
                    <div className="mb-expo">
                      <i className="ti ti-arrow-right mb-arrow"/>
                      <div className="mb-expo-main">
                        <ExposureBar buckets={book.buckets} touch={ind.touch}/>
                        <div className="mb-expo-meta">
                          <div className="mb-touch">{ind.touchCopy}</div>
                          <div className="mb-amt">{fmtDollar(amt)}<small>{pct}% OF BOOK</small></div>
                        </div>
                      </div>
                    </div>
                  </div>
                );
              })}
              {selected.length === 0 && (
                <div style={{padding:"22px 0",fontSize:12.5,color:"#647071"}}>
                  No indicators selected — use “+ Add indicator” to choose which economic signals to track against your portfolio.
                </div>
              )}
            </div>
            <div className="mb-key">
              {MB_BUCKETS.filter(b => (((book.buckets[b.k] || {}).pct) || 0) > 0).map(b => (
                <span key={b.k}><i style={{background:b.c}}/>{b.label} {Math.round(((book.buckets[b.k] || {}).pct) || 0)}%</span>
              ))}
            </div>
          </div>
        </>
      )}
    </>
  );
}

function DashboardTab({ user, onSwitchTab, onLogout, onUserUpdated, onNavigate }) {
  const [overview,       setOverview]       = useState(null);
  const [loadErr,        setLoadErr]        = useState(null);
  const [refreshKey,     setRefreshKey]     = useState(0);
  const [tickerUniverse, setTickerUniverse] = useState([]);
  const [showSearch,     setShowSearch]     = useState(false);
  const [priceHistory,   setPriceHistory]   = useState(null);
  const [vixData,        setVixData]        = useState(null);
  const [macroData,      setMacroData]      = useState(null);
  const [corrData,       setCorrData]       = useState(null);

  useEffect(() => {
    setOverview(null);
    setLoadErr(null);
    setPriceHistory(null);
    setCorrData(null);
    (async () => {
      const [r, ph, vx, cr] = await Promise.all([
        api("/api/dashboard/overview"),
        api("/api/dashboard/price-history"),
        api("/api/dashboard/vix"),
        api("/api/dashboard/correlations"),
      ]);
      if (r.ok && r.data) setOverview(r.data);
      else setLoadErr((r.data && r.data.error) || "Failed to load dashboard data.");
      if (ph.ok && ph.data) setPriceHistory(ph.data.series || []);
      if (vx.ok && vx.data) setVixData(vx.data.series || []);
      if (cr.ok && cr.data) setCorrData(cr.data);
    })();
  }, [refreshKey]);

  useEffect(() => {
    (async () => {
      const r = await api("/compare/tickers");
      if (r.ok && r.data && r.data.tickers) setTickerUniverse(r.data.tickers);
    })();
  }, []);

  // Market backdrop (macro) data is global, not user-specific — fetch once,
  // independently of the watchlist refresh cycle, and render the section only
  // once it arrives (never blocks the rest of the tab).
  useEffect(() => {
    (async () => {
      const r = await api("/api/dashboard/macro");
      if (r.ok && r.data && r.data.ok) setMacroData(r.data);
    })();
  }, []);

  async function removeFromWatchlist(ticker) {
    await api(`/api/watchlist/${ticker}`, { method: "DELETE" });
    setRefreshKey(k => k + 1);
  }

  const ov = overview;
  const maxAbsRoc = ov
    ? Math.max(...(ov.holdings || []).map(h => Math.abs(h.roc5 || 0)), 0.1)
    : 0.1;

  return (
    <div className="sp-root">
      <PageNav activeTab="dashboard" onSwitchTab={onSwitchTab} user={user} onLogout={onLogout} onUserUpdated={onUserUpdated} onNavigate={onNavigate} />
      <div className="df-page">

        {/* Header */}
        <div className="db-header">
          <h1 className="db-h1">Track your portfolio performance.</h1>
        </div>

        {/* Loading */}
        {!ov && !loadErr && (
          <div className="pf-card" style={{padding:"28px 24px",display:"flex",alignItems:"center",gap:12}}>
            <span className="pf-spinner"/>
            <span style={{fontSize:13,color:"#5a6a72"}}>Loading market signals…</span>
          </div>
        )}
        {loadErr && <div className="pf-error">{loadErr}</div>}

        {ov && !ov.has_data && (
          <div className="pf-card">
            <div className="sp-eyebrow" style={{marginBottom:8}}>No holdings yet</div>
            <p style={{fontSize:13.5,color:"#5a6a72",lineHeight:1.6,marginBottom:20}}>
              Save your plan on the Plan tab to populate your tracking view.
            </p>
            <button className="sp-btn sp-btn-primary" onClick={() => onSwitchTab("plan", { toHub: true })}>
              Go to Plans →
            </button>
          </div>
        )}

        {ov?.has_data && (
          <>
            {/* Stat strip: 3 chips */}
            <div className="db-stat-strip">
              <div className="db-stat">
                <div className="db-stat-label">Tracked Tickers</div>
                <div className="db-stat-val">{ov.funds_held}</div>
              </div>
              <div className="db-stat">
                <div className="db-stat-label">Biggest 5D Drop</div>
                {ov.biggest_drop ? (
                  <div style={{display:"flex",alignItems:"baseline",gap:7}}>
                    <span className={`db-stat-val${ov.biggest_drop.roc5 < 0 ? " red" : ""}`}>
                      {ov.biggest_drop.ticker}
                    </span>
                    <span style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:13,
                      color: ov.biggest_drop.roc5 < 0 ? "#b34030" : "#647071",
                      letterSpacing:"-.01em"}}>
                      {ov.biggest_drop.roc5 != null
                        ? `${ov.biggest_drop.roc5 > 0 ? "+" : ""}${ov.biggest_drop.roc5.toFixed(2)}%`
                        : ""}
                    </span>
                  </div>
                ) : (
                  <div className="db-stat-val">—</div>
                )}
              </div>
              <div className="db-stat">
                <div className="db-stat-label">Portfolio Vol</div>
                <div className="db-stat-val">
                  {ov.portfolio_vol != null ? `${ov.portfolio_vol.toFixed(1)}%` : "—"}
                </div>
              </div>
            </div>

            {/* Price over time chart */}
            {priceHistory && priceHistory.length > 0 && (
              <div className="db-card">
                <div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline",marginBottom:14}}>
                  <div className="pf-card-title">Price over time</div>
                  <div style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10,color:"#647071",letterSpacing:".06em"}}>
                    INDEXED TO 100 · 1-YEAR
                  </div>
                </div>
                <PriceIndexChart series={priceHistory} />
              </div>
            )}

            {/* Asset Movement + Vol vs VIX */}
            {priceHistory && priceHistory.length > 0 && (
              <div className="db-charts-row">
                <div className="db-card" style={{marginBottom:0}}>
                  <div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline",marginBottom:14}}>
                    <div className="pf-card-title">Asset Movement</div>
                    <div style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10,color:"#647071",letterSpacing:".06em"}}>
                      RETURN VS REALIZED VOL · 1-YR
                    </div>
                  </div>
                  <AssetMovementChart series={priceHistory} />
                </div>
                <div className="db-card" style={{marginBottom:0}}>
                  <div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline",marginBottom:14}}>
                    <div className="pf-card-title">Watchlist Volatility vs Broad Market</div>
                    <div style={{fontFamily:"'IBM Plex Mono',monospace",fontSize:10,color:"#647071",letterSpacing:".06em"}}>
                      1-YR
                    </div>
                  </div>
                  <VolVixChart series={priceHistory} vixData={vixData} />
                </div>
              </div>
            )}

            {/* Observed correlations — hidden below the 2-ticker / statistical floor,
                same as portfolio-report's equivalent panel (empty is a normal state).
                Equity section (price/vol/correlations/watchlist) comes before the macro
                section (Market backdrop, below the Watchlist table) — see that comment. */}
            {corrData && <CorrelationHeatmap data={corrData} />}

            {/* Holdings table */}
            <div className="db-card">
              <div style={{marginBottom:14}}>
                <div className="pf-card-title">Watchlist</div>
                {/* States what this tab is FOR, at the top of the one surface that
                    could be mistaken for a screener. The app is built around a few
                    low-cost index funds held on purpose (`CLAUDE.md § What it is
                    FOR`), and the Track tab is how you follow THOSE — the search box
                    at the foot of this table is the affordance that most invites the
                    opposite reading, so the framing sits above it rather than in a
                    help article nobody opens first. Keep it to one sentence: the
                    reasoning belongs in `the-watchlist`, behind the chip. */}
                <div style={{fontSize:12.5,color:"#5a6a72",lineHeight:1.55,marginTop:5,maxWidth:660}}>
                  For following the funds you've chosen — not for finding new ones. A few
                  broad, low-cost ETFs already hold thousands of companies; more overlapping
                  funds add cost and complexity, not diversification.
                </div>
              </div>
              <table className="db-tbl">
                <thead>
                  <tr>
                    <th>Fund</th>
                    <th>Name</th>
                    <th>Asset Class</th>
                    <th className="r">Price</th>
                    <th className="r">Value</th>
                    <th className="r">5D ROC</th>
                    <th className="r">Vol</th>
                    <th></th>
                  </tr>
                </thead>
                <tbody>
                  {ov.holdings.map(h => (
                    <tr key={h.ticker} style={{cursor:"pointer"}}
                        onClick={() => { window.location.href = "/asset/" + h.ticker + ".TO"; }}>
                      <td className="mono"><strong>{h.ticker}</strong></td>
                      <td style={{color:"#5a6a72",fontSize:12.5}}>{h.name}</td>
                      <td>
                        <div className="db-tbl-cls">
                          <div className="db-cls-dot" style={{background: classColor(h.asset_class)}}/>
                          {/* The full class name, not AC_LABELS' short form — this
                              column is not width-constrained the way the allocation
                              tables are, so abbreviating only made it look untidy. */}
                          {h.asset_class}
                        </div>
                      </td>
                      <td className="r">{h.close != null ? `$${h.close.toFixed(2)}` : "—"}</td>
                      <td className="r">{h.value > 0 ? fmtDollar(h.value) : "—"}</td>
                      <td className="r">
                        <MiniRocBar roc5={h.roc5} maxAbs={maxAbsRoc} />
                      </td>
                      <td className="r">
                        {h.vol != null ? `${h.vol.toFixed(1)}%` : "—"}
                      </td>
                      <td style={{textAlign:"center"}}>
                        <button
                          className="db-btn-remove"
                          title={`Remove ${h.ticker} from watchlist`}
                          aria-label={`Remove ${h.ticker} from watchlist`}
                          onClick={e => { e.stopPropagation(); removeFromWatchlist(h.ticker); }}
                        >×</button>
                      </td>
                    </tr>
                  ))}
                  <tr className="db-add-row">
                    <td colSpan={8}>
                      <button
                        className="db-add-submit"
                        style={{display:"flex",alignItems:"center",gap:6,width:"100%",justifyContent:"flex-start",padding:"0 10px",height:32,fontSize:12,color:"#647071",background:"#fff",border:"1px solid #d4cfc5"}}
                        onClick={() => setShowSearch(true)}
                      >
                        <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6"><circle cx="7" cy="7" r="5"/><line x1="11" y1="11" x2="14" y2="14"/></svg>
                        Search ETF…
                      </button>
                    </td>
                  </tr>
                </tbody>
              </table>
            </div>

            {/* Market backdrop — economic indicators. Deliberately last: the page reads
                equity first (price/vol/correlations/watchlist), then macro second. */}
            {macroData && <MarketBackdrop macro={macroData} />}
          </>
        )}

        {showSearch && (
          <WatchlistSearchModal
            universe={tickerUniverse}
            watchedSet={new Set((ov && ov.holdings ? ov.holdings.map(h => h.ticker) : []))}
            onClose={() => setShowSearch(false)}
          />
        )}

      </div>
      <SiteFooter />
    </div>
  );
}

// Help Center hash scheme: #help (index, category defaults to "plan"),
// #help/<cat> (that category's index), #help/<cat>/<article-id> (the
// article) — layered on top of the existing bare #plan/#portfolio/#dashboard
// convention, which is untouched. parseHelpHash returns null for any hash
// that isn't a #help/... one, so callers can tell "not a help link" apart
// from "the help index" (empty-but-present cat/article).
function parseHelpHash() {
  const h = window.location.hash.replace(/^#/, "");
  if (h !== "help" && !h.startsWith("help/")) return null;
  const parts = h.split("/").filter(Boolean); // ["help"] | ["help",cat] | ["help",cat,id]
  return { tab: parts[1] || "plan", articleId: parts[2] || null };
}
function buildHelpHash(tab, articleId) {
  return "#help/" + (tab || "plan") + (articleId ? "/" + articleId : "");
}

// ── App shell — lazy-mount tabs, single <style> injection ─────────────────────
function App() {
  const [authState, setAuthState]         = useState("loading"); // loading | out | in
  const [user, setUser]                   = useState(null);
  const [activeTab, setActiveTab]         = useState("plan");
  const [activated, setActivated]         = useState({ plan: true, portfolio: false, dashboard: false });
  const [pfRefreshKey, setPfRefreshKey]   = useState(0);
  const [planHubKey, setPlanHubKey]       = useState(0);    // bumped to force IPSBuilder back to the hub
  // Bumped when a Save-tab edit changed the plan itself (today: the Accounts
  // panel's PATCH) — the mirror image of pfRefreshKey. IPSBuilder re-reads
  // /api/ips so its savedByGoal (and therefore DocStatusChip / the review-due
  // banner) reflects the change without a page reload.
  const [planRefreshKey, setPlanRefreshKey] = useState(0);
  const [activePage, setActivePage]       = useState(null); // null | "profile" | "alerts" | "help"
  const [helpTarget, setHelpTarget]       = useState(null); // {tab, articleId} | null — which Help Center category/article to land on
  const [verifyDismissed, setVerifyDismissed] = useState(false);
  const [googleNotice, setGoogleNotice]   = useState(null); // {type:'ok'|'err', msg}
  const [deleteVerified, setDeleteVerified] = useState(false); // fresh Google re-auth for account deletion
  const [draftPending, setDraftPendingState] = useState(false); // IPSBuilder has an unsaved wizard draft in progress
  const [draftGoalInfo, setDraftGoalInfo]     = useState(null); // {goalId, label, icon} | null — feeds the Save-tab ghost card
  const [draftBannerDismissed, setDraftBannerDismissed] = useState(false);
  const [stalePlans, setStalePlans] = useState([]);       // [{goalId, label, updatedAt}] stalest-first, from IPSBuilder
  const [staleDismissTick, setStaleDismissTick] = useState(0); // bump after a dismissal write so the memo re-reads localStorage
  // First stale plan the user hasn't dismissed (dismissals are per goal +
  // updated_at in localStorage — see StalePlanBanner); dismissing one reveals
  // the next, if any.
  const visibleStalePlan = React.useMemo(() => {
    if (!user || !stalePlans.length) return null;
    const dism = loadStaleDismissals(user.slug);
    return stalePlans.find(p => dism[p.goalId] !== p.updatedAt) || null;
  }, [user, stalePlans, staleDismissTick]);
  // Only re-arm the dismissed banner on a false→true transition (a new draft
  // starting), not on every step change within the same still-pending draft —
  // otherwise dismissing the banner on the Save tab would get silently undone
  // the moment the user goes back and advances a step in the wizard.
  const _draftPendingRef = React.useRef(false);
  const setDraftPending = (pending) => {
    if (pending && !_draftPendingRef.current) setDraftBannerDismissed(false);
    _draftPendingRef.current = pending;
    setDraftPendingState(pending);
  };

  // {goalId, label} | null — a previously-saved goal whose parked wizard
  // draft has changed (any field, not just allocation) since the last save
  // (see FormDirtyBanner / plan-tab.md § "Plan changes can't silently go
  // unsaved"). Dismissal re-arms on a null→non-null transition, same
  // reasoning as draftBannerDismissed above: a re-save (or leaving the field
  // alone) clears it via IPSBuilder's own effect, so this only needs to
  // reset the dismiss flag when a *new* drift starts, not on every
  // incidental re-render.
  const [formDirtyGoal, setFormDirtyGoalState] = useState(null);
  const [formDirtyBannerDismissed, setFormDirtyBannerDismissed] = useState(false);
  const _formDirtyRef = React.useRef(null);
  const setFormDirtyGoal = (goal) => {
    if (goal && (!_formDirtyRef.current || _formDirtyRef.current.goalId !== goal.goalId)) {
      setFormDirtyBannerDismissed(false);
    }
    _formDirtyRef.current = goal;
    setFormDirtyGoalState(goal);
  };

  // {goalId, label} | null — a saved goal whose signature has gone stale
  // because a Save-side edit changed the plan (needs_review_at > updated_at).
  // Same dismissal semantics as formDirtyGoal above, and it feeds the SAME
  // banner: a plan can be form-dirty and review-due at once, and two stacked
  // warnings about one plan is noise. See plan.md § "The edit applies
  // immediately; the signature goes stale".
  const [reviewDueGoal, setReviewDueGoalState] = useState(null);
  const [reviewBannerDismissed, setReviewBannerDismissed] = useState(false);
  const _reviewDueRef = React.useRef(null);
  const setReviewDueGoal = (goal) => {
    if (goal && (!_reviewDueRef.current || _reviewDueRef.current.goalId !== goal.goalId)) {
      setReviewBannerDismissed(false);
    }
    _reviewDueRef.current = goal;
    setReviewDueGoalState(goal);
  };
  // One banner, stronger message first: "unsaved changes" outranks "needs
  // re-signing" for the same reason DocStatusChip prefers it over the edit
  // count — it means the numbers on this very screen are stale, which is a
  // stronger claim than a signature being out of date.
  const planBanner = (formDirtyGoal && !formDirtyBannerDismissed)
    ? { goal: formDirtyGoal, reason: "dirty" }
    : ((reviewDueGoal && !reviewBannerDismissed) ? { goal: reviewDueGoal, reason: "review" } : null);

  // Surface the Google OAuth result the callback appended to the URL. Sign-in
  // failures land on the logged-out Auth screen (handled there); link / re-auth
  // results land here while logged in.
  useEffect(() => {
    const status = readGoogleParam("google_status");
    const errc   = readGoogleParam("google_error");
    let deleteIntent = false;
    try { deleteIntent = sessionStorage.getItem("spDeleteIntent") === "1"; } catch (e) { /* no-op */ }
    const clearIntent = () => { try { sessionStorage.removeItem("spDeleteIntent"); } catch (e) { /* no-op */ } };

    if (status === "reauth") {
      clearGoogleParams();
      if (deleteIntent) {
        clearIntent();
        setDeleteVerified(true);
        setActivePage("profile");   // return to the delete section, now verified
      } else {
        setGoogleNotice({ type: "ok", msg: "Re-verified with Google." });
      }
    } else if (status && GOOGLE_STATUS_MSGS[status]) {
      setGoogleNotice({ type: "ok", msg: GOOGLE_STATUS_MSGS[status] });
      clearGoogleParams();
    } else if (errc && GOOGLE_INAPP_ERRORS.has(errc)) {
      if (deleteIntent) { clearIntent(); setActivePage("profile"); }
      setGoogleNotice({ type: "err", msg: GOOGLE_MSGS[errc] || "Google didn't complete." });
      clearGoogleParams();
    }
  }, []);

  useEffect(() => {
    (async () => {
      const r = await api("/api/auth/me");
      if (r.ok && r.data && r.data.user) {
        setUser(r.data.user);
        setAuthState("in");
        const hash = window.location.hash.replace("#", "");
        if (hash === "portfolio" || hash === "dashboard") {
          setActiveTab(hash);
          setActivated(a => ({ ...a, [hash]: true }));
        } else {
          const helpState = parseHelpHash();
          if (helpState) {
            setActivePage("help");
            setHelpTarget(helpState);
          }
        }
      } else {
        // Logged out: send bare-root visitors to the marketing landing page.
        // Visitors arriving via an explicit app hash (e.g. /#plan from a
        // landing CTA) get the sign-in screen so login stays reachable.
        // #help/... counts as an explicit app hash here too — falls through
        // to sign-in, same as #plan; it is not re-applied after interactive
        // login below, matching the pre-existing #portfolio/#dashboard gap.
        if (!window.location.hash.replace("#", "")) {
          window.location.replace("/landing");
          return;
        }
        setAuthState("out");
      }
    })();
  }, []);

  // Back/forward through our own Help pushState entries fires `popstate`
  // (never `hashchange`, since we never assign `location.hash =` directly);
  // a pasted link or a plain <a href="#help/..."> click fires `hashchange`
  // instead — both are listened for, both just re-derive state from the
  // current hash. Re-registered on authState change so the closure below
  // sees the current value (this effect intentionally does not depend on
  // anything else — it must not re-run on every render).
  useEffect(() => {
    const applyHelpHash = () => {
      if (authState !== "in") return;
      const helpState = parseHelpHash();
      if (helpState) {
        setActivePage("help");
        setHelpTarget(helpState);
      } else {
        // The hash landed back on a non-help entry (e.g. Back out of a
        // deep-linked article opened mid-wizard, where there was no help
        // hash to begin with, so this is the entry that exits Help). Close
        // Help back to whatever's underneath — the lazy-mount tabs / wizard
        // step were never touched, so this alone restores the prior view.
        // Guarded so a stray non-help hashchange can't clobber profile/alerts.
        setActivePage(p => (p === "help" ? null : p));
      }
    };
    window.addEventListener("hashchange", applyHelpHash);
    window.addEventListener("popstate", applyHelpHash);
    return () => {
      window.removeEventListener("hashchange", applyHelpHash);
      window.removeEventListener("popstate", applyHelpHash);
    };
  }, [authState]);

  // Leaving Help for a normal tab must not leave a stale #help/... URL behind
  // — a later refresh would otherwise reopen Help instead of the tab you're
  // looking at. Scoped to clearing only; normal tabs don't get their own
  // hash written on every switchTab (that one-way-input convention is
  // unchanged — see ui.md § Hash routing).
  const clearHelpHashIfPresent = () => {
    if (window.location.hash.replace(/^#/, "").indexOf("help") === 0) {
      window.history.pushState(null, "", window.location.pathname + window.location.search);
    }
  };

  // `opts.toHub` — the caller is asking for the Plan *landing hub* (the goal cards),
  // not wherever the wizard was last left. IPSBuilder is lazy-mounted and keeps its
  // own `step`, so a plain tab switch would reopen the last plan; bumping planHubKey
  // tells it to reset to step 0. Used by the "Add a plan" / "Go to Plans" buttons.
  const switchTab = (tab, opts) => {
    setActivePage(null);
    setDeleteVerified(false);
    setActiveTab(tab);
    setActivated(a => ({ ...a, [tab]: true }));
    if (tab === "plan" && opts && opts.toHub) setPlanHubKey(k => k + 1);
    clearHelpHashIfPresent();
  };

  // Reaching profile via the menu is a fresh visit — not a just-completed re-auth
  // (that path sets deleteVerified + activePage directly), so clear the flag here.
  // `opts` is only meaningful for "help" today ({tab, articleId} — which Help
  // Center category/article to land on); every other caller passes no second arg.
  const navigate = (page, opts) => {
    setDeleteVerified(false);
    setActivePage(page);
    if (page === "help") {
      const target = { tab: (opts && opts.tab) || "plan", articleId: (opts && opts.articleId) || null };
      setHelpTarget(target);
      const h = buildHelpHash(target.tab, target.articleId);
      if (window.location.hash !== h) window.history.pushState(null, "", h);
    } else {
      clearHelpHashIfPresent();
    }
  };

  const logout = async () => {
    await api("/api/auth/logout", { method: "POST" });
    // Full navigation, not just SPA state resets. Clearing local state (setUser(null),
    // …) declared the UI "signed out" even when the server session was still alive —
    // a failed/partial logout, or a session living on the other of www/apex — and the
    // next full page load restored the user. A hard nav drops all in-memory auth and
    // re-reads identity from the server on load; replace() also keeps the
    // authenticated app out of back/forward (bfcache) history. Matches the
    // delete-account flow, which already does window.location.replace("/landing").
    window.location.replace("/landing");
  };

  if (authState === "loading") {
    return (<><style>{css}</style><div className="sp-splash">Loading…</div></>);
  }
  if (authState === "out") {
    return (<><style>{css}</style><Auth onAuth={u => { setUser(u); setAuthState("in"); }} /></>);
  }

  return (
    <>
      <style>{css}</style>
      {googleNotice && (
        <div style={{
          display:"flex", alignItems:"center", justifyContent:"center", gap:10,
          padding:"9px 16px", fontSize:13,
          background: googleNotice.type === "ok" ? "#eaf5ee" : "rgba(179,64,48,.08)",
          color: googleNotice.type === "ok" ? "#2d7a47" : "#b34030",
          borderBottom: "1px solid " + (googleNotice.type === "ok" ? "#cfe6d8" : "rgba(179,64,48,.25)")
        }}>
          <span>{googleNotice.msg}</span>
          <button onClick={() => setGoogleNotice(null)} title="Dismiss" aria-label="Dismiss"
            style={{background:"none",border:"none",cursor:"pointer",color:"inherit",fontSize:16,lineHeight:1,padding:"0 4px"}}>×</button>
        </div>
      )}
      {activePage === "profile" && (
        <ProfilePage user={user} onSwitchTab={switchTab} onLogout={logout}
          onUserUpdated={setUser} onNavigate={navigate} onBack={() => setActivePage(null)}
          deleteVerified={deleteVerified} />
      )}
      {activePage === "alerts" && (
        <AlertsPage user={user} onSwitchTab={switchTab} onLogout={logout} onNavigate={navigate} />
      )}
      {activePage === "help" && (
        <HelpPage user={user} onSwitchTab={switchTab} onLogout={logout} onUserUpdated={setUser}
          onNavigate={navigate} defaultTab={(helpTarget && helpTarget.tab) || "plan"}
          defaultArticleId={helpTarget && helpTarget.articleId}
          onViewChange={(tab, articleId) => {
            setHelpTarget({ tab, articleId });
            const h = buildHelpHash(tab, articleId);
            if (window.location.hash !== h) window.history.pushState(null, "", h);
          }} />
      )}
      {!activePage && user && !user.email_verified && !verifyDismissed && (
        <VerificationBanner onDismiss={() => setVerifyDismissed(true)} />
      )}
      {!activePage && draftPending && activeTab !== "plan" && !draftBannerDismissed && (
        <DraftPlanBanner onResume={() => switchTab("plan")} onDismiss={() => setDraftBannerDismissed(true)} />
      )}
      {!activePage && visibleStalePlan && activeTab !== "plan" && (
        <StalePlanBanner plan={visibleStalePlan}
          onReview={() => switchTab("plan", { toHub: true })}
          onDismiss={() => {
            saveStaleDismissal(user.slug, visibleStalePlan.goalId, visibleStalePlan.updatedAt);
            setStaleDismissTick(t => t + 1);
          }} />
      )}
      {!activePage && planBanner && activeTab !== "plan" && (
        <FormDirtyBanner goal={planBanner.goal} reason={planBanner.reason}
          onReview={() => switchTab("plan")}
          onDismiss={() => (planBanner.reason === "dirty"
            ? setFormDirtyBannerDismissed(true)
            : setReviewBannerDismissed(true))} />
      )}
      <div className="sp-app" style={{display: activePage ? "none" : ""}}>
        <div className={activeTab === "plan" ? "sp-tab-active" : "sp-tab-panel"}>
          <IPSBuilder user={user} onLogout={logout} onSwitchTab={switchTab} planHubKey={planHubKey}
            onIpsSaved={() => setPfRefreshKey(k => k + 1)} onUserUpdated={setUser} onNavigate={navigate}
            onDraftPending={setDraftPending} onDraftGoalInfo={setDraftGoalInfo} onStalePlans={setStalePlans}
            onFormDirty={setFormDirtyGoal} onReviewDue={setReviewDueGoal} planRefreshKey={planRefreshKey} />
        </div>
        {activated.portfolio && (
          <div className={activeTab === "portfolio" ? "sp-tab-active" : "sp-tab-panel"}>
            <PortfolioTab user={user} onSwitchTab={switchTab} onLogout={logout} onUserUpdated={setUser} refreshKey={pfRefreshKey} onNavigate={navigate} draftGoalInfo={draftGoalInfo}
              onPlanChanged={() => setPlanRefreshKey(k => k + 1)} />
          </div>
        )}
        {activated.dashboard && (
          <div className={activeTab === "dashboard" ? "sp-tab-active" : "sp-tab-panel"}>
            <DashboardTab user={user} onSwitchTab={switchTab} onLogout={logout} onUserUpdated={setUser} onNavigate={navigate} />
          </div>
        )}
      </div>
    </>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
