/* ============================================================================
 * CIANO ESTIMATION SUITE — Spectre AI Frontend (JSX Bridge / Full Canvas)
 * Author: Bitghost.com  |  Support: ghost@bitghost.com
 * Deployed for: TheCianoCompany.com
 *
 * "Ghost in the machine" AGI AgentOS estimation engine. Monochrome (#000/#fff)
 * tool shell with white shimmer + 22px signature radius + cube glyphs.
 * Generates Ciano-navy proposals that mirror the gold-standard PDF surgically.
 *
 * This single file powers BOTH the WordPress plugin and the standalone HTML
 * previewer. When window.CES_CONFIG.rest is present it talks to WordPress REST;
 * otherwise it runs fully client-side (previewer / offline) against the bundled
 * catalog. Uses full Tailwind utility classes directly per Bitghost design law.
 * ========================================================================== */

const { useState, useEffect, useRef, useMemo, useCallback } = React;

/* ----------------------------------------------------------------------------
 * 0. RUNTIME BRIDGE — detect WordPress vs. standalone, expose a tiny API client
 * ------------------------------------------------------------------------- */
const CES = (typeof window !== 'undefined' && window.CES_CONFIG) ? window.CES_CONFIG : {};
const HAS_WP = !!(CES && CES.rest);
const REST = (CES.rest || '').replace(/\/$/, '');
const NONCE = CES.nonce || '';

async function api(path, { method = 'GET', body = null } = {}) {
  if (!HAS_WP) throw new Error('offline'); // previewer short-circuits to local logic
  const res = await fetch(`${REST}/${path.replace(/^\//, '')}`, {
    method,
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE },
    body: body ? JSON.stringify(body) : null,
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

/* ----------------------------------------------------------------------------
 * 0b. v0.0.6 BRIDGE HELPERS
 *   (a) estimate lifecycle: list / load / status / delete / store-PDF
 *   (b) proposal PDF parsing (PDF.js) + the "AGI Architect" material extractor
 *   (c) recommendations queue API
 * Everything degrades gracefully in the offline previewer (HAS_WP === false).
 * ------------------------------------------------------------------------- */

/* --- (a) Estimate lifecycle --------------------------------------------- */
function localEstimates() { try { return JSON.parse(localStorage.getItem('ces_estimates') || '[]'); } catch (e) { return []; } }
function localEstRow(e, i) {
  const t = e.totals || {}, m = e.meta || {};
  return {
    uid: e.uid || ('local-' + i), job_name: (e.job && e.job.jobName) || '(untitled)',
    client_name: (e.job && e.job.clientName) || '', grand_total: t.grandTotal || 0,
    confidence: m.confidence || 0, status: e.status || 'draft',
    updated_at: (m.generatedAt || '').replace('T', ' ').slice(0, 19),
    has_pdf: false, pdf_stale: false, pdf_url: '',
  };
}
async function apiListEstimates(limit = 40) {
  if (!HAS_WP) return { estimates: localEstimates().map(localEstRow) };
  try { return await api('estimates?limit=' + limit); } catch (e) { return { estimates: [] }; }
}
async function apiGetEstimate(uid) {
  if (!HAS_WP) {
    const all = localEstimates();
    let e = all.find((x, i) => (x.uid || ('local-' + i)) === uid);
    if (!e && /^local-\d+$/.test(String(uid))) e = all[parseInt(String(uid).slice(6), 10)];
    return e ? { uid, payload: e } : null;
  }
  try { return await api('estimates/' + encodeURIComponent(uid)); } catch (e) { return null; }
}
async function apiDeleteEstimate(uid) {
  if (!HAS_WP) {
    const all = localEstimates().filter((x, i) => (x.uid || ('local-' + i)) !== uid);
    try { localStorage.setItem('ces_estimates', JSON.stringify(all)); } catch (e) {}
    return { deleted: true };
  }
  try { return await api('estimates/' + encodeURIComponent(uid), { method: 'DELETE' }); } catch (e) { return { deleted: false }; }
}
async function apiSetStatus(uid, status) {
  if (!HAS_WP) return { updated: true, status };
  try { return await api('estimates/' + encodeURIComponent(uid) + '/status', { method: 'POST', body: { status } }); } catch (e) { return { updated: false }; }
}
async function apiStorePDF(uid, dataUri, filename) {
  if (!HAS_WP) return { stored: false, reason: 'previewer' };
  try { return await api('estimates/' + encodeURIComponent(uid) + '/pdf', { method: 'POST', body: { pdf: dataUri, filename } }); } catch (e) { return { stored: false }; }
}
// v0.0.7 — persist the job-site photos to the WordPress media library too, so
// they can be recalled later independently of the embedded PDF.
async function apiStorePhotos(uid, photos) {
  if (!HAS_WP) return { stored: false, reason: 'previewer' };
  const imgs = (photos || []).filter(p => p && p.dataUri && /^data:image\//.test(p.dataUri)).map(p => ({ dataUri: p.dataUri, name: p.name, mime: p.mime }));
  if (!imgs.length) return { stored: true, count: 0 };
  try { return await api('estimates/' + encodeURIComponent(uid) + '/photos', { method: 'POST', body: { photos: imgs } }); } catch (e) { return { stored: false }; }
}
const ESTIMATE_STATUSES = ['draft', 'sent', 'accepted', 'declined', 'archived'];

/* --- (b) Proposal PDF parsing via PDF.js -------------------------------- */
let _pdfjsReady = false;
function ensurePdfjs() {
  const lib = (typeof window !== 'undefined') && window.pdfjsLib;
  if (!lib) return null;
  if (!_pdfjsReady) {
    try { if (CES.pdfWorker) lib.GlobalWorkerOptions.workerSrc = CES.pdfWorker; } catch (e) {}
    _pdfjsReady = true;
  }
  return lib;
}
async function extractPdfText(file) {
  const lib = ensurePdfjs();
  if (!lib) throw new Error('PDF.js engine not loaded');
  const buf = await file.arrayBuffer();
  const pdf = await lib.getDocument({ data: buf }).promise;
  let out = '';
  for (let p = 1; p <= pdf.numPages; p++) {
    const page = await pdf.getPage(p);
    const tc = await page.getTextContent();
    let line = '';
    tc.items.forEach((it) => {
      line += (it.str || '');
      if (it.hasEOL) { out += line + '\n'; line = ''; } else { line += ' '; }
    });
    if (line.trim()) out += line + '\n';
    out += '\n';
  }
  return out;
}

/* The "AGI Architect" material extractor. Precision-oriented: it surfaces
 * brand- and dimension-anchored product phrases from a Ciano-format proposal,
 * tags each with a confidence (UQ law: < 0.70 flagged for review), guesses a
 * scope category + unit, and dedupes within the parse. The server then dedupes
 * authoritatively against the live library, so the operator only ever reviews
 * genuinely NEW materials. */
const MAT_BRANDS = ['daltile', 'marazzi', 'msi quartz', 'msi', 'emser', 'florida tile', 'american olean', 'arizona tile', 'bedrosians', 'anatolia', 'happy floors', 'schluter', 'ditra', 'kerdi', 'mapei', 'laticrete', 'ardex', 'redgard', 'hydroban', 'hardiebacker', 'hardie', 'durock', 'wedi', 'bostik', 'spectralock', 'permacolor', 'keracolor', 'prism', 'fabuwood', 'kohler', 'moen', 'delta', 'grohe', 'sherwin williams', 'sherwin-williams', 'behr', 'benjamin moore', 'cambria', 'silestone', 'caesarstone', 'glazzio', 'ktl', 'style access', 'covent gardens', 'oasis blume', 'winter stone', 'topwood', 'burlington', 'calacata', 'fioressa'];
const MAT_NOUNS = ['porcelain', 'ceramic', 'mosaic', 'marble', 'granite', 'quartz', 'quartzite', 'travertine', 'limestone', 'slate', 'pebble', 'glass tile', 'subway tile', 'bullnose', 'schluter trim', 'thin set', 'thin-set', 'thinset', 'mortar', 'sanded grout', 'unsanded grout', 'epoxy grout', 'grout', 'backer board', 'cement board', 'underlayment', 'waterproof membrane', 'membrane', 'pan liner', 'lvp', 'luxury vinyl', 'vinyl plank', 'laminate', 'engineered hardwood', 'hardwood', 'baseboard', 'quarter round', 'stair nose', 'stair nosing', 'drywall', 'sheetrock', 'primer', 'semi-gloss', 'eggshell', 'caulk', 'silicone', 'ram board', 'paver', 'pavers', 'slab', 'plank', 'porcelain tile', 'wood tile', 'wall tile', 'floor tile', 'sealer', 'adhesive', 'faucet', 'sink', 'toilet', 'vanity', 'cabinet', 'shower valve', 'tub filler', 'niche', 'curb'];
const MAT_STOP = new Set(['in', 'on', 'at', 'with', 'per', 'for', 'as', 'and', 'to', 'or', 'the', 'a', 'an', 'of', 'from', 'that', 'this', 'will', 'be', 'is', 'are', 'color', 'colors', 'tbd', 'approved', 'drawing', 'drawings', 'provided', 'owner', 'same', 'close', 'possible', 'area', 'areas', 'scope', 'project', 'install', 'installed', 'supply', 'remove', 'dispose', 'needed', 'existing', 'new', 'all', 'about', 'similar', 'each', 'into', 'over', 'under', 'where', 'after', 'before', 'no', 'not', 'included', 'work', 'home', 'unit', 'bath', 'kitchen']);
const MAT_BOILER = new Set(['proposal', 'terms and conditions', 'acceptance of proposal', 'thank you for your business', 'sales rep', 'sale rep contact', 'sales rep email', 'payment terms', 'we hereby submit', 'we propose hereby', 'accepted by date', 'thecianocompany com', 'fort myers fl', 'area amount', 'description amount', 'ciano s tile custom finishes']);
function _hasAny(s, list) { const l = s.toLowerCase(); return list.some((w) => l.includes(w)); }
function guessMatCategory(key) {
  const k = ' ' + key + ' ';
  const has = (...ws) => ws.some((w) => k.includes(' ' + w + ' ') || k.indexOf(' ' + w) >= 0);
  if (has('paver', 'pavers')) return 'Porcelain Pavers';
  if (has('slab', 'slabs')) return 'Porcelain Slabs';
  if (has('pool', 'waterline')) return 'Pool Tile';
  if (has('backsplash')) return 'Backsplash Tile';
  if (has('pebble', 'mosaic')) return 'Shower & Tub Tile';
  if (has('wall tile')) return 'Wall Tile';
  if (has('lvp', 'vinyl', 'luxury vinyl', 'plank')) return 'LVP Flooring';
  if (has('hardwood', 'engineered')) return 'Hardwood Flooring';
  if (has('carpet', 'padding')) return 'Carpet';
  if (has('quartz', 'granite', 'marble', 'quartzite', 'cambria', 'silestone', 'caesarstone', 'countertop', 'calacata', 'fioressa')) return 'Countertops';
  if (has('cabinet', 'fabuwood', 'vanity', 'pull', 'pulls', 'hardware')) return 'Cabinets & Tops';
  if (has('faucet', 'sink', 'toilet', 'valve', 'filler', 'moen', 'kohler', 'delta', 'grohe', 'drain')) return 'Plumbing';
  if (has('drywall', 'sheetrock', 'backer', 'cement board', 'hardie', 'durock', 'texture')) return 'Drywall & Finish';
  if (has('paint', 'primer', 'sherwin', 'behr', 'gloss', 'eggshell', 'satin', 'duration', 'promar')) return 'Prime & Paint';
  if (has('door', 'casing')) return 'Doors & Hardware';
  if (has('baseboard', 'quarter round', 'stair nose', 'nosing', 'trim')) return 'Carpentry & Trim';
  if (has('grout', 'thinset', 'thin set', 'mortar', 'membrane', 'sealer', 'adhesive', 'ditra', 'kerdi', 'redgard', 'mapei', 'laticrete', 'schluter')) return 'Floor Tile';
  if (has('tile', 'porcelain', 'ceramic', 'travertine', 'marble', 'stone')) return 'Floor Tile';
  return 'Uncategorized';
}
function guessMatUnit(key) {
  const areaish = ['tile', 'paver', 'slab', 'stone', 'marble', 'granite', 'quartz', 'porcelain', 'ceramic', 'vinyl', 'lvp', 'hardwood', 'carpet', 'plank', 'backsplash', 'drywall', 'mosaic', 'travertine', 'counter'];
  return areaish.some((w) => key.includes(w)) ? 'sq ft' : 'ea';
}
function extractMaterialCandidates(text, opts = {}) {
  const max = opts.max || 60;
  const found = new Map();
  const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
  const r3 = (n) => Math.round(n * 1000) / 1000;
  const lower = text.toLowerCase();

  function push(raw, hints) {
    let words = String(raw).replace(/\s+/g, ' ').trim().split(' ');
    while (words.length && MAT_STOP.has((words[words.length - 1] || '').toLowerCase().replace(/[^a-z]/g, ''))) words.pop();
    while (words.length && MAT_STOP.has((words[0] || '').toLowerCase().replace(/[^a-z]/g, ''))) words.shift();
    let name = words.join(' ').replace(/^[^A-Za-z0-9]+/, '').replace(/[^A-Za-z0-9)"]+$/, '').trim();
    if (name.length < 4 || name.length > 64) return;
    if (words.length < 1 || words.length > 9) return;
    if (!/[A-Za-z]/.test(name)) return;
    const key = norm(name);
    if (!key || key.length < 3 || MAT_BOILER.has(key)) return;
    let conf = 0.30;
    if (hints.brand) conf += 0.38;
    if (hints.dim) conf += 0.22;
    if (hints.noun) conf += 0.15;
    if (words.length >= 2 && words.length <= 6) conf += 0.08;
    conf = Math.max(0.30, Math.min(0.95, conf));
    const prev = found.get(key);
    if (!prev || conf > prev.confidence) {
      found.set(key, { name, sku: '', category: guessMatCategory(key), unit: guessMatUnit(key), price: 0, confidence: r3(conf) });
    }
  }

  // Pattern A — dimension-anchored product phrase ("Burlington Sand 24 x 24 porcelain pavers")
  const dimRe = /([A-Za-z][A-Za-z0-9'’.&\/-]*(?:\s+[A-Za-z0-9'’.&\/-]+){0,4})\s+(\d{1,3}\s?[xX×]\s?\d{1,3})(\s+[A-Za-z][A-Za-z0-9'’.&\/-]*(?:\s+[A-Za-z0-9'’.&\/-]+){0,4})?/g;
  let m;
  while ((m = dimRe.exec(text)) !== null) {
    const phrase = [m[1], m[2], m[3] || ''].join(' ');
    push(phrase, { dim: true, brand: _hasAny(phrase, MAT_BRANDS), noun: _hasAny(phrase, MAT_NOUNS) });
  }

  // Pattern B — brand-anchored phrase (brand + up to 6 following words)
  MAT_BRANDS.forEach((brand) => {
    let idx = 0;
    while ((idx = lower.indexOf(brand, idx)) !== -1) {
      const slice = text.substr(idx, brand.length + 52);
      const ws = slice.split(/\s+/);
      const out = [];
      for (let i = 0; i < ws.length && out.length < 7; i++) {
        const wl = ws[i].toLowerCase().replace(/[^a-z]/g, '');
        if (out.length >= 2 && (MAT_STOP.has(wl) || /^\d+$/.test(ws[i]))) break;
        out.push(ws[i]);
      }
      const ph = out.join(' ');
      push(ph, { brand: true, noun: _hasAny(ph, MAT_NOUNS), dim: /\d\s?[xX×]\s?\d/.test(ph) });
      idx += brand.length;
    }
  });

  return [...found.values()].sort((a, b) => b.confidence - a.confidence).slice(0, max);
}

/* ----------------------------------------------------------------------------
 * (b2) FABUWOOD SALES-ORDER EXTRACTOR — surgical, structured, zero-hallucination.
 *
 * A Ciano proposal is prose; a Fabuwood cabinetry sales order is a strict
 * line-item TABLE (QTY · ITEM · DESCRIPTION · HS · FE · RATE · AMOUNT). Every
 * real line ends with TWO currency values ($RATE $AMOUNT) — summary rows
 * (Grand Total, Style Total, Cabinets & Parts, Sales Tax …) carry only ONE.
 * That pair is the discriminator we trust: we lift exactly the rows the PDF
 * states — the verbatim description + the per-unit RATE — and never invent a
 * material or a price. Series/Style/Color/Room headers give each row context.
 * ------------------------------------------------------------------------- */
const FABU_TWO_MONEY = /\$\s?([\d,]+\.\d{2})\s+\$\s?([\d,]+\.\d{2})\s*$/;
const FABU_HS_TAIL   = /\s+(left|right|none|both)\s*$/i;
const FABU_SUMMARY   = /\b(grand total|item subtotal|sub ?total|sales tax|style total|cabinets?\s*&\s*parts|modifications|upgrades?\s*\/?\s*accessories)\b/i;

function isFabuwoodDoc(text) {
  const l = String(text || '').toLowerCase();
  let s = 0;
  if (l.includes('fabuwood')) s += 2;
  if (l.includes('sales order')) s += 1;
  if (l.includes('qty') && l.includes('item') && l.includes('description') && l.includes('rate') && l.includes('amount')) s += 2;
  if (l.includes('cabinets & parts') || l.includes('style total')) s += 2;
  if (/\bblum\b/.test(l)) s += 1;
  return s >= 3;
}

function fabuCategory(s) {
  const t = ' ' + String(s).toLowerCase() + ' ';
  if (/\b(hood|ventilator|cfm|rhf|rhbb)\b/.test(t)) return 'Range Hood';
  if (/\b(blum|roll ?out|cutlery|spice|tray divider|trash can|towel bar|pull ?out|soft close|slide|lazy susan)\b/.test(t)) return 'Cabinet Hardware';
  if (/\b(crown|cove|molding|moulding|toe kick|light rail|base ?board|batten|scribe|filler|nosing|lrm|cm-|tk8|bbm|sm8|tf3|wf3|bf3|olf)\b/.test(t)) return 'Cabinet Molding & Trim';
  if (/\b(wainscot|panel|skin|mdf|end panel|refrigerator end|rep30|wp-|pan-)\b/.test(t)) return 'Cabinet Panels & Skins';
  if (/\b(finished end|touch up|remove drawer|charge)\b/.test(t)) return 'Cabinet Accessories';
  return 'Cabinets & Tops';
}

function fabuPretty(desc) {
  let s = String(desc)
    .replace(/\*\*[^*]*\*\*/g, ' ')   // ** Remove Drawers [UPPER DRAWER] **
    .replace(/\*[^*]*\*/g, ' ')        // *1 DCD-30 BLUM* / *FINISHED END-BASE*
    .replace(/\s{2,}/g, ' ')
    .trim();
  // Gentle title-case: lower long ALL-CAPS words, keep short acronyms/codes.
  s = s.replace(/[A-Z]{2,}/g, (w) => (/^(LED|CFM|MDF|SS|BB|TBD|RX)$/.test(w) ? w : w.charAt(0) + w.slice(1).toLowerCase()));
  return s.replace(/\s+/g, ' ').trim();
}

function fabuSku(head) {
  const toks = String(head).split(/\s+/).filter(Boolean);
  if (!toks.length) return '';
  const first = toks[0];
  if (/^(FINISHED|REMOVE|TOUCH)$/i.test(first)) {
    const n = first.toUpperCase() === 'TOUCH' ? 3 : 2;
    return toks.slice(0, n).join(' ');
  }
  if (/[0-9]/.test(first) || /[-/]/.test(first) || /^[A-Z]{1,3}\d/.test(first)) {
    let sku = first;
    if (toks[1] && /^(BLUM|CFM)$/i.test(toks[1])) { sku += ' ' + toks[1]; if (toks[2] && /^H$/i.test(toks[2])) sku += ' ' + toks[2]; }
    return sku;
  }
  return first;
}

function extractFabuwoodMaterials(text, opts = {}) {
  const max = opts.max || 120;
  const r3 = (n) => Math.round(n * 1000) / 1000;
  const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
  const lines = String(text || '').split(/\r?\n/);
  const out = new Map();
  let ctx = { series: '', style: '', color: '', room: '' };

  for (let i = 0; i < lines.length; i++) {
    const raw = lines[i].trim();
    if (!raw) continue;

    let mctx;
    if ((mctx = raw.match(/^Series:\s*(.+)$/i))) { ctx.series = mctx[1].trim(); continue; }
    if ((mctx = raw.match(/^Style:\s*(.+)$/i)))  { ctx.style  = mctx[1].trim(); continue; }
    if ((mctx = raw.match(/^Color:\s*(.+)$/i)))  { ctx.color  = mctx[1].trim(); continue; }
    if ((mctx = raw.match(/^Room:\s*(.+)$/i)))   { ctx.room   = mctx[1].trim(); continue; }

    const money = raw.match(FABU_TWO_MONEY);
    if (!money) continue;                 // only real line items end in $RATE $AMOUNT
    if (FABU_SUMMARY.test(raw)) continue; // belt-and-suspenders summary guard

    const rate = parseFloat(money[1].replace(/,/g, ''));
    if (!(rate > 0)) continue;

    let head = raw.slice(0, raw.indexOf('$')).trim();
    head = head.replace(FABU_HS_TAIL, '').trim();        // drop trailing HS word
    head = head.replace(/^\s*(\d+\s+){1,2}/, '').trim(); // drop leading line# / qty

    // Merge a wrapped continuation line (no money, not a new item/header).
    let look = i + 1;
    while (look < lines.length && look - i <= 3) {
      const nxt = lines[look].trim();
      if (!nxt || FABU_TWO_MONEY.test(nxt) || FABU_SUMMARY.test(nxt)) break;
      if (/^(Series|Style|Color|Room):/i.test(nxt)) break;
      if (/^(QTY ITEM|Page \d|\*\*PLEASE|Bill To|Ship To|Sales Order)/i.test(nxt)) break;
      head += ' ' + nxt.replace(FABU_HS_TAIL, '');
      look++;
    }

    const sku = fabuSku(head);
    const skuTokCount = sku ? sku.split(/\s+/).length : 0;
    const descOnly = head.split(/\s+/).slice(skuTokCount).join(' ');
    const pretty = fabuPretty(descOnly || head);
    if (!pretty || pretty.length < 3) continue;

    const ctxBits = [ctx.series, ctx.style, ctx.color].filter(Boolean).join(' ');
    const name = 'Fabuwood' + (ctxBits ? ' ' + ctxBits : '') + ' \u2014 ' + pretty;
    // Classify on the SKU + the ref-stripped description (NOT the raw line) so an
    // included-accessory ref like "*1 DCD-30 BLUM*" never miscategorizes the box.
    const classifyOn = sku + ' ' + pretty;
    let conf = 0.90;
    if (/\b(charge|skin|remove drawer|touch up)\b/i.test(pretty)) conf = 0.80;

    const key = norm(name);
    if (!out.has(key)) {
      out.set(key, {
        name: name.slice(0, 120), sku: sku.slice(0, 40),
        category: fabuCategory(classifyOn), unit: 'each',
        price: r3(rate), confidence: conf, source: 'fabuwood',
      });
    }
  }
  return [...out.values()].sort((a, b) => b.confidence - a.confidence).slice(0, max);
}

/* Pick the right extractor for an uploaded proposal (Ciano prose vs Fabuwood
 * sales-order table) so we never run prose-heuristics over a structured order
 * (which is exactly where hallucination would creep in). */
function extractProposalMaterials(text) {
  if (isFabuwoodDoc(text)) {
    const fab = extractFabuwoodMaterials(text);
    if (fab.length) return { source: 'fabuwood', candidates: fab };
  }
  return { source: 'ciano', candidates: extractMaterialCandidates(text) };
}

/* --- (c) Recommendations queue API -------------------------------------- */
function localRecs() { try { return JSON.parse(localStorage.getItem('ces_material_recs') || '[]'); } catch (e) { return []; } }
function writeLocalRecs(r) { try { localStorage.setItem('ces_material_recs', JSON.stringify(r)); } catch (e) {} }
async function apiGetRecs() {
  if (!HAS_WP) { const r = localRecs(); return { recommendations: r, count: r.length }; }
  try { return await api('materials/recommendations'); } catch (e) { return { recommendations: [], count: 0 }; }
}
async function apiParseMaterials(candidates, doc) {
  if (!HAS_WP) {
    const have = new Set(localLib.read().map((m) => m.mkey));
    const recs = localRecs(); const haveR = new Set(recs.map((r) => r.mkey));
    let added = 0, dupes = 0;
    candidates.forEach((c) => {
      const mkey = (c.name || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 191);
      if (!mkey) return;
      if (have.has(mkey) || haveR.has(mkey)) { dupes++; return; }
      haveR.add(mkey); recs.unshift({ mkey, name: c.name, sku: c.sku || '', category: c.category || 'Uncategorized', unit: c.unit || 'ea', price: c.price || 0, confidence: c.confidence || 0.5, source_doc: doc, created_at: nowISO() }); added++;
    });
    writeLocalRecs(recs);
    return { added, duplicates: dupes, recs };
  }
  try { return await api('materials/parse', { method: 'POST', body: { candidates, doc } }); } catch (e) { return { added: 0, duplicates: 0, recs: [] }; }
}
async function apiAcceptRec(mkey) {
  if (!HAS_WP) {
    const recs = localRecs(); const rec = recs.find((r) => r.mkey === mkey);
    if (rec) { localLib.upsert({ name: rec.name, sku: rec.sku, category: rec.category, unit: rec.unit, price: rec.price, source: 'parsed', last_synced: nowISO(), project: rec.source_doc }); }
    const next = recs.filter((r) => r.mkey !== mkey); writeLocalRecs(next);
    return { accepted: true, recommendations: next, count: next.length };
  }
  try { return await api('materials/recommendations/accept', { method: 'POST', body: { mkey } }); } catch (e) { return { accepted: false }; }
}
async function apiRejectRec(mkey) {
  if (!HAS_WP) { const next = localRecs().filter((r) => r.mkey !== mkey); writeLocalRecs(next); return { rejected: true, recommendations: next, count: next.length }; }
  try { return await api('materials/recommendations/reject', { method: 'POST', body: { mkey } }); } catch (e) { return { rejected: false }; }
}
async function apiClearRecs() {
  if (!HAS_WP) { writeLocalRecs([]); return { cleared: 0, recommendations: [], count: 0 }; }
  try { return await api('materials/recommendations/clear', { method: 'POST', body: {} }); } catch (e) { return { cleared: 0, recommendations: [] }; }
}

/* ----------------------------------------------------------------------------
 * 1. BRAND TOKENS — Ciano proposal palette (sampled from the gold standard PDF)
 * ------------------------------------------------------------------------- */
const CIANO = {
  navy: '#003399',     // brand royal navy — titles, header bands, labels
  navyDark: '#102A66', // footer gradient deep end
  navySteel: '#A9BBD6',// footer gradient light end
  maroon: '#5A1E1E',   // table cell borders
  red: '#E00000',      // emphasised clauses ("Owner responsible…")
  ink: '#1A1A1A',      // body text
  rule: '#CCCCCC',     // hairlines
};

/* ----------------------------------------------------------------------------
 * 2. SEED CATALOG — realistic 2026 retail baseline. Each item carries a source
 *    + last_synced so the UI can surface "network metrics" (crypto flavour).
 *    Live providers (Home Depot / Lowe's via SerpApi etc.) overwrite price +
 *    source + last_synced at runtime through the WP pricing engine.
 * ------------------------------------------------------------------------- */
const SEED_CATALOG = (CES.catalog && CES.catalog.length) ? CES.catalog : [
  // --- Tile & Shower ---
  { sku: 'TILE-WALL-1236', name: '12x36 Ceramic Wall Tile (Oasis Blume look)', unit: 'sq ft', price: 3.48, category: 'Tile & Shower', coverage: 1, waste: 0.12, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'TILE-FLOOR-2424', name: '24x24 Porcelain Floor Tile', unit: 'sq ft', price: 4.97, category: 'Tile & Shower', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'TILE-FLOOR-1224', name: '12x24 Matte Floor Tile (Winterstone look)', unit: 'sq ft', price: 3.29, category: 'Tile & Shower', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'GLASS-PEBBLE', name: 'Recycled Glass Pebble Mosaic (Maya Rain look)', unit: 'sq ft', price: 12.98, category: 'Tile & Shower', coverage: 1, waste: 0.15, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'HARDIE-BACKER', name: 'HardieBacker 1/4 in. Cement Board 3x5', unit: 'sheet', price: 13.45, category: 'Tile & Shower', coverage: 15, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'THINSET-50', name: 'Modified Thinset Mortar 50 lb', unit: 'bag', price: 18.97, category: 'Tile & Shower', coverage: 40, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'GROUT-SEALER', name: 'Grout with Sealer 25 lb', unit: 'bag', price: 24.98, category: 'Tile & Shower', coverage: 100, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'WP-MEMBRANE', name: 'Waterproofing Membrane Kit (shower pan)', unit: 'kit', price: 119.00, category: 'Tile & Shower', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  // --- LVP / Flooring ---
  { sku: 'LVP-7X48', name: 'LVP TopWood White 7x48 (22mil wear / EVA pad)', unit: 'sq ft', price: 3.19, category: 'LVP Flooring', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'STAIR-NOSING', name: 'LVP Stair Nosing (per step)', unit: 'each', price: 28.50, category: 'LVP Flooring', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'BASEBOARD-525', name: '5-1/4 in. Primed Baseboard (per ft)', unit: 'ft', price: 1.85, category: 'Carpentry & Trim', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  // --- Drywall / Construction ---
  { sku: 'DRYWALL-58', name: 'Drywall Sheet 5/8 in. 4x8', unit: 'sheet', price: 17.98, category: 'Construction', coverage: 32, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'JOINT-COMP', name: 'All-Purpose Joint Compound 4.5 gal', unit: 'bucket', price: 16.97, category: 'Construction', coverage: 480, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'LUMBER-2X4', name: '2x4x8 Framing Stud', unit: 'each', price: 4.28, category: 'Construction', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'MESH-TAPE', name: 'Fiberglass Mesh Tape 300 ft', unit: 'roll', price: 7.48, category: 'Construction', coverage: 300, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  // --- Plumbing ---
  { sku: 'SINK-RECT', name: 'Rectangle White Porcelain Undermount Sink', unit: 'each', price: 89.00, category: 'Plumbing', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'FAUCET-GENTA', name: 'Moen Genta Single-Hole Faucet (Brushed Nickel)', unit: 'each', price: 168.00, category: 'Plumbing', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'SHWR-VALVE', name: 'Moen Posi-Temp Shower Valve + Genta Trim', unit: 'each', price: 245.00, category: 'Plumbing', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'SHWR-HEAD', name: 'Moen Versa Dual Shower Head + Sprayer', unit: 'each', price: 139.00, category: 'Plumbing', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'TUB-FILLER', name: 'Moen Genta Deck-Mount Tub Filler + Valves', unit: 'each', price: 289.00, category: 'Plumbing', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'WAX-KIT', name: 'Toilet Reset Kit (wax ring + bolts)', unit: 'each', price: 12.98, category: 'Plumbing', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  // --- Cabinets & Tops ---
  { sku: 'VAN-27', name: '27 in. Vanity Sink Base (Fabuwood Galaxy look)', unit: 'each', price: 415.00, category: 'Cabinets & Tops', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'VAN-30', name: '30 in. Vanity Sink Base', unit: 'each', price: 449.00, category: 'Cabinets & Tops', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'VAN-18DRW', name: '18 in. 3-Drawer Base Cabinet', unit: 'each', price: 365.00, category: 'Cabinets & Tops', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'QUARTZ-3CM', name: '3CM Quartz Countertop (Cambrian Everleigh look)', unit: 'sq ft', price: 62.00, category: 'Cabinets & Tops', coverage: 1, waste: 0.15, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'PULL-BN', name: '5 in. Brushed Nickel Cabinet Pull', unit: 'each', price: 4.98, category: 'Cabinets & Tops', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  // --- Paint ---
  { sku: 'PAINT-DUR', name: 'Sherwin-Williams Duration Wall Paint (gal)', unit: 'gal', price: 86.00, category: 'Prime & Paint', coverage: 350, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'PAINT-CEIL', name: 'SW ProMar 200 Flat Ceiling White (gal)', unit: 'gal', price: 42.00, category: 'Prime & Paint', coverage: 350, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'PRIMER', name: 'Drywall Primer / Sealer (gal)', unit: 'gal', price: 28.00, category: 'Prime & Paint', coverage: 300, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  // --- Shower Enclosure / Home Protection ---
  { sku: 'ENCL-SF', name: 'Semi-Frameless Shower Enclosure (3/8 glass, BN)', unit: 'each', price: 1150.00, category: 'Shower Enclosure', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'RAMBOARD', name: 'Ram Board Floor Protection 38 in x 100 ft', unit: 'roll', price: 64.00, category: 'Home Protection', coverage: 300, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'PLASTIC-SHEET', name: 'Plastic Sheeting 10x100 ft', unit: 'roll', price: 38.00, category: 'Home Protection', coverage: 1000, waste: 0, source: 'catalog', last_synced: '2026-01-15' },

  // === v0.0.3 modular categories ============================================
  // --- Floor Tile ---
  { sku: 'TILE-FLOOR-WOOD', name: '9x47 Wood-Look Porcelain Plank (Paja look)', unit: 'sq ft', price: 4.49, category: 'Floor Tile', coverage: 1, waste: 0.12, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'TILE-FLOOR-PORC', name: '24x24 Porcelain Floor Tile (stock)', unit: 'sq ft', price: 4.97, category: 'Floor Tile', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'CLIP-WEDGE', name: 'Tile Leveling Clip & Wedge System (250 ct)', unit: 'kit', price: 39.00, category: 'Floor Tile', coverage: 100, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  // --- Wall Tile ---
  { sku: 'TILE-WALL-CER', name: '12x36 Ceramic Wall Tile (stack pattern)', unit: 'sq ft', price: 3.48, category: 'Wall Tile', coverage: 1, waste: 0.12, source: 'catalog', last_synced: '2026-01-15' },
  // --- Backsplash Tile ---
  { sku: 'TILE-BSPLASH', name: '8x8 Decorative Backsplash Tile (Covent Gardens look)', unit: 'sq ft', price: 9.98, category: 'Backsplash Tile', coverage: 1, waste: 0.15, source: 'catalog', last_synced: '2026-01-15' },
  // --- Pool Tile ---
  { sku: 'TILE-POOL-6X6', name: '6x6 Glazed Pool / Waterline Tile', unit: 'sq ft', price: 14.50, category: 'Pool Tile', coverage: 1, waste: 0.15, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'GROUT-EPOXY', name: 'Epoxy Pool Grout (submerged-rated)', unit: 'unit', price: 89.00, category: 'Pool Tile', coverage: 60, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  // --- Porcelain Pavers ---
  { sku: 'PAVER-2424', name: '24x24 2cm Porcelain Paver (Burlington Sand look)', unit: 'sq ft', price: 6.98, category: 'Porcelain Pavers', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'THINSET-MEDBED', name: 'Medium-Bed LMM Thinset 50 lb', unit: 'bag', price: 26.97, category: 'Porcelain Pavers', coverage: 35, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'SCHLUTER-TRIM', name: 'Anodized Aluminum Schluter Edge Trim 8 ft', unit: 'piece', price: 32.00, category: 'Porcelain Pavers', coverage: 8, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'GROUT-SANDED', name: 'Sanded Grout 25 lb (paver color match)', unit: 'bag', price: 22.98, category: 'Porcelain Pavers', coverage: 90, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  // --- Porcelain Slabs ---
  { sku: 'SLAB-PORC', name: '3CM Large-Format Porcelain Slab (per sq ft)', unit: 'sq ft', price: 42.00, category: 'Porcelain Slabs', coverage: 1, waste: 0.18, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'SLAB-ADHESIVE', name: 'Large-Format Slab Adhesive / Setting Kit', unit: 'kit', price: 145.00, category: 'Porcelain Slabs', coverage: 50, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  // --- Natural Stone ---
  { sku: 'STONE-MARBLE', name: 'Natural Marble / Travertine Tile (per sq ft)', unit: 'sq ft', price: 11.50, category: 'Natural Stone', coverage: 1, waste: 0.15, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'STONE-SEALER', name: 'Penetrating Natural-Stone Sealer (qt)', unit: 'qt', price: 34.00, category: 'Natural Stone', coverage: 200, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  // --- Hardwood Flooring ---
  { sku: 'HARDWOOD-OAK', name: 'Engineered Oak Hardwood (per sq ft)', unit: 'sq ft', price: 5.49, category: 'Hardwood Flooring', coverage: 1, waste: 0.12, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'WOOD-ADHESIVE', name: 'Hardwood Flooring Adhesive 4 gal', unit: 'pail', price: 79.00, category: 'Hardwood Flooring', coverage: 250, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  // --- Carpet ---
  { sku: 'CARPET-BROAD', name: 'Stain-Resist Broadloom Carpet (per sq ft)', unit: 'sq ft', price: 2.79, category: 'Carpet', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'CARPET-PAD', name: '8 lb Carpet Pad (per sq ft)', unit: 'sq ft', price: 0.65, category: 'Carpet', coverage: 1, waste: 0.08, source: 'catalog', last_synced: '2026-01-15' },
  // --- Drywall & Finish ---
  { sku: 'DRYWALL-12', name: 'Drywall Sheet 1/2 in. 4x8', unit: 'sheet', price: 14.98, category: 'Drywall & Finish', coverage: 32, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'TEXTURE-SPRAY', name: 'Wall/Ceiling Texture (per sq ft)', unit: 'sq ft', price: 0.35, category: 'Drywall & Finish', coverage: 1, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  // --- Electrical ---
  { sku: 'ELEC-LED-CAN', name: '6 in. LED Can Light', unit: 'each', price: 19.98, category: 'Electrical', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'ELEC-DIMMER', name: 'LED Dimmer Switch', unit: 'each', price: 24.00, category: 'Electrical', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'ELEC-OUTLET', name: 'Tamper-Resistant Outlet + Plate', unit: 'each', price: 6.50, category: 'Electrical', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'ELEC-SWITCH', name: 'Decora Switch + Plate', unit: 'each', price: 7.00, category: 'Electrical', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'ELEC-UC-LED', name: 'Under-Cabinet LED Strip (per ft)', unit: 'ft', price: 8.50, category: 'Electrical', coverage: 1, waste: 0.05, source: 'catalog', last_synced: '2026-01-15' },
  // --- HVAC & Duct ---
  { sku: 'HVAC-DIFFUSER', name: 'Ceiling Diffuser + Flex Duct Run', unit: 'each', price: 78.00, category: 'HVAC & Duct', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'DRYER-VENT-KIT', name: 'Dryer Vent Box + Piping + Soffit Grill', unit: 'kit', price: 96.00, category: 'HVAC & Duct', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  // --- Countertops (standalone) ---
  { sku: 'QUARTZ-SLAB', name: '3CM Quartz Countertop (per sq ft, fabricated)', unit: 'sq ft', price: 62.00, category: 'Countertops', coverage: 1, waste: 0.15, source: 'catalog', last_synced: '2026-01-15' },
  // --- Doors & Hardware ---
  { sku: 'DOOR-SOLID', name: 'Solid-Core Flat Interior Door (prehung)', unit: 'each', price: 189.00, category: 'Doors & Hardware', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'DOOR-FRENCH', name: 'Double French Door w/ Glass Panel', unit: 'each', price: 689.00, category: 'Doors & Hardware', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'DOOR-HARDWARE', name: 'Door Lever/Latch Hardware Set', unit: 'set', price: 34.00, category: 'Doors & Hardware', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'DOOR-CASING', name: 'Door Casing Set (per opening)', unit: 'set', price: 42.00, category: 'Doors & Hardware', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  // --- Ceiling Tile & Grid ---
  { sku: 'CEIL-TILE-2X2', name: 'Acoustic Ceiling Tile 2x2 (per sq ft)', unit: 'sq ft', price: 1.65, category: 'Ceiling Tile & Grid', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  { sku: 'CEIL-GRID', name: 'Suspended Grid Main/Cross Runners (per sq ft)', unit: 'sq ft', price: 0.95, category: 'Ceiling Tile & Grid', coverage: 1, waste: 0.10, source: 'catalog', last_synced: '2026-01-15' },
  // --- Dump & Disposal ---
  { sku: 'DUMP-LOAD', name: 'Dump / Hauling Load Fee', unit: 'load', price: 425.00, category: 'Dump & Disposal', coverage: 1, waste: 0, source: 'catalog', last_synced: '2026-01-15' },
];

/* ----------------------------------------------------------------------------
 * 2b. SCOPE TAXONOMY — modular, grouped scope-of-work categories.
 *
 * v0.0.3 EMERGE: "Tile & Shower" was one monolithic phase. It is now split into
 * granular, independently-toggled categories (Floor Tile, Wall Tile, Shower &
 * Tub Tile, Backsplash, Pool Tile, Porcelain Pavers, Porcelain Slabs, Natural
 * Stone) plus broader residential trades, so "not every tile job gets a shower."
 *
 * PHASE_GROUPS drives the grouped Scope UI. PHASE_ORDER is the flat, gold-
 * standard-ordered list every downstream stage iterates (demand → labor →
 * narrative → PDF). Legacy keys are preserved so previously-saved estimates
 * keep resolving; the old combined "Tile & Shower" remains available (labelled
 * "Tile & Shower (combined)") but is OFF by default in favor of the granular set.
 * ------------------------------------------------------------------------- */
const PHASE_GROUPS = [
  { group: 'Site & Prep',           glyph: '🧊', phases: ['Home Protection', 'Remove and dispose', 'Dump & Disposal'] },
  { group: 'Tile & Stone',          glyph: '🧊', phases: ['Floor Tile', 'Wall Tile', 'Shower & Tub Tile', 'Backsplash Tile', 'Pool Tile', 'Porcelain Pavers', 'Porcelain Slabs', 'Natural Stone', 'Tile & Shower'] },
  { group: 'Flooring',              glyph: '🧊', phases: ['LVP Flooring', 'Hardwood Flooring', 'Carpet'] },
  { group: 'Structure',             glyph: '🧊', phases: ['Construction', 'Drywall & Finish'] },
  { group: 'Mechanical',            glyph: '🧊', phases: ['Plumbing', 'Electrical', 'HVAC & Duct'] },
  { group: 'Cabinetry & Surfaces',  glyph: '🧊', phases: ['Cabinets & Tops', 'Countertops'] },
  { group: 'Finishes & Carpentry',  glyph: '🧊', phases: ['Carpentry & Trim', 'Doors & Hardware', 'Prime & Paint'] },
  { group: 'Enclosures & Ceilings', glyph: '🧊', phases: ['Shower Enclosure', 'Ceiling Tile & Grid'] },
  { group: 'Closeout',              glyph: '🧊', phases: ['Cleanup', 'Permits & Architectural'] },
];

/* Flat, gold-standard-ordered list (every downstream stage iterates this). */
const PHASE_ORDER = PHASE_GROUPS.reduce((acc, g) => acc.concat(g.phases), []);

/* Display-name overrides (keys stay stable for back-compat / saved estimates). */
const PHASE_LABEL = {
  'Tile & Shower': 'Tile & Shower (combined)',
};
const phaseLabel = (p) => PHASE_LABEL[p] || p;

/* Categories toggled ON for a fresh estimate. Modular: universal prep only —
 * every trade is opt-in. */
const DEFAULT_ON = ['Home Protection', 'Remove and dispose'];

/* Category → labor rate ($/unit) + costing mode. */
const LABOR_RATES = {
  // Site & prep
  'Home Protection':         { mode: 'flat',    rate: 450 },
  'Remove and dispose':      { mode: 'sqft',    rate: 3.25 },
  'Dump & Disposal':         { mode: 'flat',    rate: 0 },     // priced as hauling load fees
  // Tile & stone (granular)
  'Floor Tile':              { mode: 'sqft',    rate: 8.50 },
  'Wall Tile':               { mode: 'sqft',    rate: 10.50 },
  'Shower & Tub Tile':       { mode: 'sqft',    rate: 11.50 }, // successor to combined rate
  'Backsplash Tile':         { mode: 'sqft',    rate: 13.00 },
  'Pool Tile':               { mode: 'sqft',    rate: 18.00 },
  'Porcelain Pavers':        { mode: 'sqft',    rate: 9.75 },
  'Porcelain Slabs':         { mode: 'sqft',    rate: 22.00 },
  'Natural Stone':           { mode: 'sqft',    rate: 14.50 },
  'Tile & Shower':           { mode: 'sqft',    rate: 11.50 }, // legacy combined
  // Flooring
  'LVP Flooring':            { mode: 'sqft',    rate: 2.65 },
  'Hardwood Flooring':       { mode: 'sqft',    rate: 4.25 },
  'Carpet':                  { mode: 'sqft',    rate: 1.45 },
  // Structure
  'Construction':            { mode: 'sqft',    rate: 4.50 },
  'Drywall & Finish':        { mode: 'sqft',    rate: 3.40 },
  // Mechanical
  'Plumbing':                { mode: 'fixture', rate: 185 },
  'Electrical':              { mode: 'device',  rate: 95 },
  'HVAC & Duct':             { mode: 'flat',    rate: 1850 },
  // Cabinetry & surfaces
  'Cabinets & Tops':         { mode: 'cabinet', rate: 95 },
  'Countertops':             { mode: 'sqft',    rate: 18.00 },
  // Finishes & carpentry
  'Carpentry & Trim':        { mode: 'ft',      rate: 3.10 },
  'Doors & Hardware':        { mode: 'door',    rate: 165 },
  'Prime & Paint':           { mode: 'sqft',    rate: 1.95 },
  // Enclosures & ceilings
  'Shower Enclosure':        { mode: 'flat',    rate: 350 },
  'Ceiling Tile & Grid':     { mode: 'sqft',    rate: 2.20 },
  // Closeout
  'Cleanup':                 { mode: 'flat',    rate: 1450 },
  'Permits & Architectural': { mode: 'flat',    rate: 0 },
};

/* ----------------------------------------------------------------------------
 * 3. UTILITIES — money, words, crypto hash (ledger), formatting
 * ------------------------------------------------------------------------- */
const fmt = (n) => '$' + (Number(n) || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;

/* Number → English words (for the proposal "lump sum total of …" line) */
function amountToWords(amount) {
  const dollars = Math.floor(amount);
  const cents = Math.round((amount - dollars) * 100);
  const ones = ['zero','one','two','three','four','five','six','seven','eight','nine','ten','eleven','twelve','thirteen','fourteen','fifteen','sixteen','seventeen','eighteen','nineteen'];
  const tens = ['','','twenty','thirty','forty','fifty','sixty','seventy','eighty','ninety'];
  function chunk(n) {
    let s = '';
    if (n >= 100) { s += ones[Math.floor(n / 100)] + ' hundred '; n %= 100; }
    if (n >= 20) { s += tens[Math.floor(n / 10)] + (n % 10 ? '-' + ones[n % 10] : '') + ' '; }
    else if (n > 0) { s += ones[n] + ' '; }
    return s;
  }
  function toWords(n) {
    if (n === 0) return 'zero ';
    let s = '';
    const scales = [['billion',1e9],['million',1e6],['thousand',1e3]];
    for (const [name, val] of scales) {
      if (n >= val) { s += chunk(Math.floor(n / val)) + name + ' '; n %= val; }
    }
    s += chunk(n);
    return s;
  }
  const words = toWords(dollars).trim().replace(/\s+/g, ' ');
  return `${words} dollars and ${cents.toString().padStart(2, '0')}/100 cents`.replace(/\b\w/, c => c.toUpperCase());
}

/* SHA-256 → hex.  Web Crypto when available, deterministic FNV fallback. */
async function sha256Hex(str) {
  try {
    if (window.crypto && window.crypto.subtle) {
      const buf = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
      return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
    }
  } catch (e) { /* fall through */ }
  // FNV-1a 64-bit-ish fallback, expanded to 64 hex chars for display parity
  let h = 0xcbf29ce484222325n;
  for (let i = 0; i < str.length; i++) { h ^= BigInt(str.charCodeAt(i)); h = (h * 0x100000001b3n) & 0xFFFFFFFFFFFFFFFFn; }
  let hex = h.toString(16).padStart(16, '0');
  while (hex.length < 64) hex = hex + h.toString(16).padStart(16, '0');
  return hex.slice(0, 64);
}

const nowISO = () => new Date().toISOString();
const shortHash = (h) => h ? `${h.slice(0, 8)}…${h.slice(-6)}` : '—';

/* ----------------------------------------------------------------------------
 * 4. AGENTOS — the "ghost in the machine". Each agent is a real transform.
 *    The orchestrator runs them as a staged pipeline and emits telemetry the
 *    UI renders as an animated kanban of agents (cube glyphs + shimmer).
 * ------------------------------------------------------------------------- */
const AGENTS = [
  { id: 'analyst',     glyph: '🧊', name: 'Analyst',      role: 'Parse scope, photos & measurements into structured requirements' },
  { id: 'planner',     glyph: '🧊', name: 'Planner',      role: 'Sequence work into gold-standard proposal phases' },
  { id: 'architect',   glyph: '🧊', name: 'Architect',    role: 'Compute quantities, waste factors & material selection' },
  { id: 'integration', glyph: '🧊', name: 'Integration',  role: 'Pull live retail pricing (Home Depot / Lowe\'s / catalog)' },
  { id: 'orchestrator',glyph: '🧊', name: 'Orchestrator', role: 'Assemble line items, labor, markup & taxes' },
  { id: 'monitoring',  glyph: '🧊', name: 'Evolution',    role: 'Confidence scoring, price-drift & staleness monitoring' },
  { id: 'feedback',    glyph: '🧊', name: 'Feedback',     role: 'Fold user overrides back into the estimate' },
];

/* Architect quantity math — turns measurements + scope into material demand */
function computeDemand(measure, scope, catalog) {
  const byCat = (cat) => catalog.filter(c => c.category === cat);
  const pick = (sku) => catalog.find(c => c.sku === sku);
  const lines = [];
  const add = (item, qty, note) => {
    if (!item || qty <= 0) return;
    const withWaste = item.waste ? Math.ceil(qty * (1 + item.waste)) : Math.ceil(qty);
    const purchaseQty = item.coverage > 1 ? Math.ceil(withWaste / item.coverage) : withWaste;
    lines.push({
      sku: item.sku, name: item.name, unit: item.unit, category: item.category,
      qty: purchaseQty, raw: round2(qty), price: item.price, source: item.source,
      last_synced: item.last_synced, note: note || '', total: round2(purchaseQty * item.price),
    });
  };
  const mbFloor = (measure.masterBathSqft || 0);
  const gbFloor = (measure.guestBathSqft || 0);
  const showerWall = (measure.showerWallSqft || 0);
  const upstairs = (measure.upstairsSqft || 0);
  const stairs = (measure.stairCount || 0);
  const perim = (measure.upstairsPerimFt || 0);
  // v0.0.4 — laser focus: every measurement is read RAW. A blank or 0 means the
  //          job does NOT include that item, so it contributes nothing to demand.
  //          No phantom fallbacks — proposals carry only what was actually measured.
  const num = (v) => (Number(v) > 0 ? Number(v) : 0);
  const floorTile = num(measure.floorTileSqft);
  const wallTile = num(measure.wallTileSqft);
  const backsplash = num(measure.backsplashSqft);
  const poolTile = num(measure.poolTileSqft);
  const paver = num(measure.paverSqft);
  const slab = num(measure.slabSqft);
  const stone = num(measure.stoneSqft);
  const hardwood = num(measure.hardwoodSqft);
  const carpet = num(measure.carpetSqft);
  const counter = num(measure.counterSqft);
  const drywall = num(measure.drywallSqft);
  const ceiling = num(measure.ceilingSqft);
  const canLights = num(measure.canLightCount);
  const outlets = num(measure.outletCount);
  const switches = num(measure.switchCount);
  const ucLedFt = num(measure.ucLedFt);
  const diffusers = num(measure.diffuserCount);
  const doors = num(measure.doorCount);
  const frenchDoors = num(measure.frenchDoorCount);
  const dumpLoads = num(measure.dumpLoads);

  if (scope['Home Protection']) {
    add(pick('RAMBOARD'), Math.max(1, (mbFloor + gbFloor + upstairs)), 'Floor protection');
    add(pick('PLASTIC-SHEET'), 1, 'Furnishing protection');
  }
  if (scope['Tile & Shower']) {
    add(pick('TILE-WALL-1236'), showerWall, 'Shower / tub surround walls');
    add(pick('GLASS-PEBBLE'), num(measure.showerFloorSqft), 'Shower pan mosaic');
    add(pick('TILE-FLOOR-1224'), gbFloor, 'Guest bath floor');
    add(pick('TILE-FLOOR-2424'), mbFloor, 'Master bath floor');
    add(pick('HARDIE-BACKER'), showerWall + mbFloor + gbFloor, 'Backer board');
    add(pick('THINSET-50'), showerWall + mbFloor + gbFloor, 'Thinset');
    add(pick('GROUT-SEALER'), showerWall + mbFloor + gbFloor, 'Grout');
    add(pick('WP-MEMBRANE'), (showerWall + num(measure.showerFloorSqft)) > 0 ? 1 : 0, 'Shower waterproofing');
    add(pick('MESH-TAPE'), showerWall, 'Seam tape');
  }
  if (scope['LVP Flooring']) {
    add(pick('LVP-7X48'), upstairs, 'Second floor + stairs LVP');
    add(pick('STAIR-NOSING'), stairs, 'Stair nosing');
  }
  if (scope['Carpentry & Trim']) {
    add(pick('BASEBOARD-525'), perim, 'Baseboard');
  }
  if (scope['Construction']) {
    add(pick('DRYWALL-58'), (showerWall || 120) * 0.6 + 64, 'Patch / new drywall');
    add(pick('JOINT-COMP'), 1, 'Finishing');
    add(pick('LUMBER-2X4'), 12, 'Niche / curb / build-out');
  }
  if (scope['Plumbing']) {
    const sinks = num(measure.sinkCount);
    add(pick('SINK-RECT'), sinks, 'Vanity sinks');
    add(pick('FAUCET-GENTA'), sinks, 'Faucets');
    add(pick('SHWR-VALVE'), 1, 'Shower valve + trim');
    add(pick('SHWR-HEAD'), 1, 'Shower head');
    add(pick('TUB-FILLER'), measure.tubFiller ? 1 : 0, 'Tub filler');
    add(pick('WAX-KIT'), num(measure.toiletCount), 'Toilet resets');
  }
  if (scope['Cabinets & Tops']) {
    add(pick('VAN-27'), measure.van27 ?? 3, '27 in. sink bases');
    add(pick('VAN-30'), measure.van30 ?? 1, '30 in. sink base');
    add(pick('VAN-18DRW'), measure.van18 ?? 2, '18 in. drawer bases');
    add(pick('QUARTZ-3CM'), num(measure.quartzSqft), 'Quartz tops + splash');
    add(pick('PULL-BN'), num(measure.pullCount), 'Cabinet pulls');
  }
  if (scope['Prime & Paint']) {
    const wallArea = (perim || 120) * (measure.ceilingHt || 9);
    add(pick('PRIMER'), wallArea, 'Prime new drywall');
    add(pick('PAINT-DUR'), wallArea, 'Wall paint (2 coats)');
    add(pick('PAINT-CEIL'), upstairs || 600, 'Ceiling paint');
  }
  if (scope['Shower Enclosure']) {
    add(pick('ENCL-SF'), 1, 'Semi-frameless enclosure');
  }

  /* ---- v0.0.3 modular categories -------------------------------------- */
  if (scope['Dump & Disposal']) {
    add(pick('DUMP-LOAD'), dumpLoads, 'Dump / hauling load fees');
  }
  if (scope['Floor Tile']) {
    add(pick('TILE-FLOOR-WOOD'), floorTile, 'Floor tile (wood-look plank)');
    add(pick('THINSET-50'), floorTile, 'Thinset');
    add(pick('GROUT-SANDED'), floorTile, 'Sanded grout');
    add(pick('CLIP-WEDGE'), floorTile, 'Leveling clips & wedges');
  }
  if (scope['Wall Tile']) {
    add(pick('TILE-WALL-CER'), wallTile, 'Wall tile');
    add(pick('THINSET-50'), wallTile, 'Thinset');
    add(pick('GROUT-SEALER'), wallTile, 'Grout');
    add(pick('MESH-TAPE'), wallTile, 'Seam tape');
  }
  if (scope['Shower & Tub Tile']) {
    add(pick('TILE-WALL-CER') || pick('TILE-WALL-1236'), showerWall, 'Shower / tub surround walls');
    add(pick('GLASS-PEBBLE'), num(measure.showerFloorSqft), 'Shower pan mosaic');
    add(pick('HARDIE-BACKER'), showerWall, 'Backer board');
    add(pick('WP-MEMBRANE'), (showerWall + num(measure.showerFloorSqft)) > 0 ? 1 : 0, 'Shower waterproofing');
    add(pick('THINSET-50'), showerWall, 'Thinset');
    add(pick('GROUT-SEALER'), showerWall, 'Grout');
    add(pick('MESH-TAPE'), showerWall, 'Seam tape');
  }
  if (scope['Backsplash Tile']) {
    add(pick('TILE-BSPLASH'), backsplash, 'Backsplash tile');
    add(pick('THINSET-50'), backsplash, 'Thinset');
    add(pick('GROUT-SEALER'), backsplash, 'Grout');
  }
  if (scope['Pool Tile']) {
    add(pick('TILE-POOL-6X6'), poolTile, 'Pool / waterline tile');
    add(pick('THINSET-MEDBED'), poolTile, 'Submerged-rated thinset');
    add(pick('GROUT-EPOXY'), poolTile, 'Epoxy pool grout');
  }
  if (scope['Porcelain Pavers']) {
    add(pick('PAVER-2424'), paver, 'Porcelain pavers');
    add(pick('THINSET-MEDBED'), paver, 'Medium-bed thinset');
    add(pick('SCHLUTER-TRIM'), Math.ceil(Math.sqrt(Math.max(1, paver)) * 4), 'Schluter edge trim');
    add(pick('GROUT-SANDED'), paver, 'Sanded grout');
  }
  if (scope['Porcelain Slabs']) {
    add(pick('SLAB-PORC'), slab, 'Porcelain slabs');
    add(pick('SLAB-ADHESIVE'), slab, 'Large-format adhesive');
  }
  if (scope['Natural Stone']) {
    add(pick('STONE-MARBLE'), stone, 'Natural stone tile');
    add(pick('THINSET-50'), stone, 'Thinset');
    add(pick('STONE-SEALER'), stone, 'Stone sealer');
  }
  if (scope['Hardwood Flooring']) {
    add(pick('HARDWOOD-OAK'), hardwood, 'Engineered hardwood');
    add(pick('WOOD-ADHESIVE'), hardwood, 'Flooring adhesive');
  }
  if (scope['Carpet']) {
    add(pick('CARPET-BROAD'), carpet, 'Broadloom carpet');
    add(pick('CARPET-PAD'), carpet, 'Carpet pad');
  }
  if (scope['Drywall & Finish']) {
    add(pick('DRYWALL-12'), drywall, 'Drywall');
    add(pick('JOINT-COMP'), 1, 'Joint compound');
    add(pick('MESH-TAPE'), drywall, 'Seam tape');
    add(pick('TEXTURE-SPRAY'), drywall, 'Texture to match');
  }
  if (scope['Electrical']) {
    add(pick('ELEC-LED-CAN'), canLights, 'LED can lights');
    add(pick('ELEC-DIMMER'), Math.ceil(canLights / 4), 'Dimmer switches');
    add(pick('ELEC-OUTLET'), outlets, 'Outlets');
    add(pick('ELEC-SWITCH'), switches, 'Switches');
    add(pick('ELEC-UC-LED'), ucLedFt, 'Under-cabinet LED');
  }
  if (scope['HVAC & Duct']) {
    add(pick('HVAC-DIFFUSER'), diffusers, 'Diffusers + flex runs');
    add(pick('DRYER-VENT-KIT'), measure.dryerVent ? 1 : 0, 'Dryer vent kit');
  }
  if (scope['Countertops']) {
    add(pick('QUARTZ-SLAB'), counter, 'Countertops (fabricated)');
  }
  if (scope['Doors & Hardware']) {
    add(pick('DOOR-SOLID'), doors, 'Solid-core doors');
    add(pick('DOOR-FRENCH'), frenchDoors, 'French doors');
    add(pick('DOOR-HARDWARE'), doors + frenchDoors, 'Door hardware');
    add(pick('DOOR-CASING'), doors + frenchDoors, 'Door casing');
  }
  if (scope['Ceiling Tile & Grid']) {
    add(pick('CEIL-TILE-2X2'), ceiling, 'Acoustic ceiling tile');
    add(pick('CEIL-GRID'), ceiling, 'Suspended grid');
  }
  return lines;
}

/* Labor cost per active phase */
function computeLabor(measure, scope) {
  const out = {};
  const m = measure;
  const num = (v) => (Number(v) > 0 ? Number(v) : 0);
  const sqft = {
    'Remove and dispose': num(m.masterBathSqft)+num(m.guestBathSqft)+num(m.upstairsSqft),
    'Construction': num(m.showerWallSqft),
    'Tile & Shower': num(m.showerWallSqft)+num(m.masterBathSqft)+num(m.guestBathSqft)+num(m.showerFloorSqft),
    'LVP Flooring': num(m.upstairsSqft),
    'Prime & Paint': (num(m.upstairsPerimFt)*(num(m.ceilingHt)||9))+num(m.upstairsSqft),
    // v0.0.4 modular areas — raw reads, 0 => phase contributes no labor
    'Floor Tile': num(m.floorTileSqft),
    'Wall Tile': num(m.wallTileSqft),
    'Shower & Tub Tile': num(m.showerWallSqft)+num(m.showerFloorSqft),
    'Backsplash Tile': num(m.backsplashSqft),
    'Pool Tile': num(m.poolTileSqft),
    'Porcelain Pavers': num(m.paverSqft),
    'Porcelain Slabs': num(m.slabSqft),
    'Natural Stone': num(m.stoneSqft),
    'Hardwood Flooring': num(m.hardwoodSqft),
    'Carpet': num(m.carpetSqft),
    'Drywall & Finish': num(m.drywallSqft),
    'Countertops': num(m.counterSqft),
    'Ceiling Tile & Grid': num(m.ceilingSqft),
  };
  for (const phase of PHASE_ORDER) {
    if (!scope[phase]) continue;
    const cfg = LABOR_RATES[phase]; if (!cfg) continue;
    let cost = 0;
    if (cfg.mode === 'flat') cost = cfg.rate;
    else if (cfg.mode === 'sqft') cost = (sqft[phase] || 0) * cfg.rate;
    else if (cfg.mode === 'fixture') cost = (num(m.sinkCount)+num(m.toiletCount)+2) * cfg.rate;
    else if (cfg.mode === 'cabinet') cost = ((m.van27??3)+(m.van30??1)+(m.van18??2)) * cfg.rate;
    else if (cfg.mode === 'ft') cost = num(m.upstairsPerimFt) * cfg.rate;
    else if (cfg.mode === 'device') cost = (num(m.canLightCount)+num(m.outletCount)+num(m.switchCount)) * cfg.rate;
    else if (cfg.mode === 'door') cost = (num(m.doorCount)+num(m.frenchDoorCount)) * cfg.rate;
    out[phase] = round2(cost);
  }
  return out;
}

/* Templated, gold-standard-style scope narrative per phase */
const PHASE_NARRATIVE = {
  'Home Protection': 'Supply and install Ram Board on floors inside home as needed for scope of work. Move all heavy furniture as needed. Supply and install painters\u2019 plastic sheeting on all furnishings as needed. Owner responsible to move all breakables in areas that need work done.',
  'Remove and dispose': 'Remove and dispose vanity cabinets, tops and faucets, all wall tile and backer board in shower and tub area, existing shower valve and tub filler, all floor tile, knee walls, shower floor and curb, shower enclosure, vanity lights and mirrors, marble sill, and all carpet, padding and base boards in areas being removed.',
  'Plumbing': 'Remove and re-install existing toilets after new tile (re-use water lines, replace wax rings and bolts). Supply and install white porcelain sinks and brushed-nickel single-hole faucets. Supply and install Posi-Temp shower valve with trim, dual shower head and sprayer, and deck-mount tub filler. All disconnects and reconnects included.',
  'Construction': 'Frame out new niche and curb. Patch all ceilings and walls with drywall as needed. Finish and texture to coordinate with existing. Supply and install Hardie backer board on shower walls, curb, niche and floors (thin-set and screwed). Install owner-supplied mirrors and lights. Build out stairs as needed for LVP and stair nosing.',
  'Tile & Shower': 'Mesh tape and thin-set all backer board joints. Pre-pitch and final-pitch shower floor with waterproof membrane and pan liner. Supply and install corner seat. Install 12x36 ceramic wall tile in stack pattern, recycled-glass pebble pan, 12x24 guest floor (brick 1/3 offset) and 24x24 master floor (straight). Grout all tile with grout-plus-sealer.',
  'Cabinets & Tops': 'Supply and install gray shaker vanity cabinets per approved 20/20 drawing, 5 in. brushed-nickel pulls, and 3CM quartz vanity tops with 4 in. splash, curb cap, window sill and niche bottom, flat polished edge with standard overhang.',
  'Carpentry & Trim': 'Supply and install 5-1/4 in. base boards to match first floor in all second-floor areas. Caulk top of base boards only so flooring can move for expansion and contraction.',
  'LVP Flooring': 'Supply and install LVP TopWood White 7x48 (6.5mm / 1mm EVA pad / 22mil wear layer) in all second-floor areas and on stairs. Supply and install stair nosing on all steps as needed.',
  'Prime & Paint': 'Prime all new drywall and patches. Paint all ceilings and closets with flat ceiling white. Paint all walls one color in satin/eggshell washable finish. Semi-gloss on all door casing, base boards and doors. Colors TBD.',
  'Shower Enclosure': 'Supply and install brushed-nickel semi-frameless shower enclosure with 3/8 in. clear glass, one C-pull handle and 3/8 in. channel on walls and curb as needed.',
  'Permits & Architectural': 'NO PERMIT OR ARCHITECTURAL INCLUDED IN PROJECT.',
  // v0.0.3 modular categories
  'Dump & Disposal': 'All disposal fees for dump and hauling all removed materials from all demo work to the dump.',
  'Floor Tile': 'Supply and install floor tile in the selected pattern over latex-modified thin-set. Supply and install clip-and-wedge leveling system to keep all tiles flat with each other. Supply and install sanded grout with grout-once sealer admix; grout color to match tile as close as possible.',
  'Wall Tile': 'Supply and install wall tile in a standard stack pattern over thin-set on prepared substrate. Mesh-tape and thin-set all board joints as needed. Grout all tile with grout-plus-sealer; grout color TBD.',
  'Shower & Tub Tile': 'Mesh tape and thin-set all backer board joints. Pre-pitch and final-pitch shower floor with waterproof membrane and pan liner. Supply and install corner seat. Install ceramic wall tile in stack pattern and recycled-glass pebble pan. Grout all tile with grout-plus-sealer.',
  'Backsplash Tile': 'Supply and install decorative backsplash tile over thin-set on prepared wall. Grout all tile with grout-plus-sealer; grout color TBD.',
  'Pool Tile': 'Supply and install glazed pool / waterline tile over submerged-rated thin-set. Supply and install epoxy pool grout. All tile set true and flush along the waterline.',
  'Porcelain Pavers': 'Supply and install stock porcelain pavers over the prepared base with medium-bed latex-modified thin-set. Supply and install anodized-aluminum Schluter edge trim along all exposed edges and transitions. Supply and install sanded grout as close as possible to paver color.',
  'Porcelain Slabs': 'Supply and install large-format porcelain slabs with large-format adhesive and a slab-setting system to keep all panels flat and lippage-free. Seams aligned and finished per approved layout.',
  'Natural Stone': 'Supply and install natural marble / travertine over thin-set in the approved pattern. Supply and apply penetrating natural-stone sealer. Grout and finish per selection.',
  'Hardwood Flooring': 'Supply and install engineered hardwood flooring with flooring adhesive over prepared substrate. Rack for color/grain variation; finish transitions and reducers as needed.',
  'Carpet': 'Supply and install stain-resist broadloom carpet over new 8 lb pad. Seams placed away from high-traffic sightlines; power-stretched and tucked at all edges.',
  'Drywall & Finish': 'Supply and install drywall on all new framing as needed for scope of project. Finish all new drywall as close as possible to existing texture and patch all walls as needed for electrical and plumbing work.',
  'Electrical': 'Supply and install LED can lights with dimmer switches, outlets and switches per the new layout, and under-cabinet LED lighting as needed. Relocate devices and re-wire as required for scope of project. All work to code.',
  'HVAC & Duct': 'Supply and install new ceiling diffusers and flex duct runs for any room being split. Supply and install dryer vent box and piping to exterior with soffit grill. Service and filter change after construction.',
  'Countertops': 'Supply and install fabricated quartz countertops with the selected edge profile, standard overhangs and splash per approved drawing. Templated, fabricated and installed; sinks and fixtures coordinated.',
  'Doors & Hardware': 'Supply and install solid-core interior doors and any double French door with glass panel per the new layout. Supply and install similar door hardware and casing to match the rest of the unit.',
  'Ceiling Tile & Grid': 'Remove and dispose damaged ceiling tiles, grid cross-beams and main runs after wall removal. Supply and install new acoustic ceiling tile and suspended grid after framing and drywall work is complete.',
  'Cleanup': 'Clean up unit as work progresses. At final completion a complete construction cleanup will be performed as needed, one area at a time starting in the largest area.',
};

/* ----------------------------------------------------------------------------
 * 5. ORCHESTRATOR — runs the agent pipeline, emits staged telemetry.
 *    Uses the WP pricing engine when available; otherwise local catalog.
 * ------------------------------------------------------------------------- */
async function fetchLivePrices(skus) {
  // When wired to WordPress, asks the pricing engine for current retail prices.
  if (!HAS_WP) return null;
  try {
    const res = await api('pricing/batch', { method: 'POST', body: { skus } });
    return res && res.prices ? res.prices : null;
  } catch (e) { return null; }
}

function applyMarkup(subtotal, markupPct) { return round2(subtotal * (1 + markupPct / 100)); }
/* v0.0.7 — operator profit margin, applied to the at-cost subtotal BEFORE the
 * standing overhead markup. Both are folded into the grand total and never
 * itemized on the PDF proposal (which prints a single lump sum), so the margin
 * is invisible to the customer while still bumping the bottom line. */
function applyMargin(subtotal, marginPct) { return round2(subtotal * (1 + (marginPct || 0) / 100)); }

/* ----------------------------------------------------------------------------
 * 5b. VISION TAKEOFF — Job-Site Photo Intelligence (v0.0.5)
 *
 *   The new Step 2 turns site photos into a *first-pass* Measurements set the
 *   operator then tweaks. Three honest extraction methods, each tagged with an
 *   epistemic-confidence score (AGI AgentOS UQ law: anything < 0.70 is flagged
 *   for human verification — these are estimates, never measured ground truth):
 *
 *     • vision-ai       — a configured vision endpoint (Claude-vision or any
 *                         custom REST provider) returns structured measures.
 *                         Gracefully degrades to the methods below when no
 *                         endpoint is wired (flag: vision_fallback).
 *     • reference-scale — operator marks a known dimension in the photo (e.g.
 *                         a 24" tile edge, an 80" door, a tape) and draws the
 *                         room's width / length; real photogrammetry by pixel
 *                         ratio. Single-plane assumption → perspective penalty.
 *     • heuristic-prior — scope-aware typical-dimension priors for the area
 *                         type. Lowest confidence; always "verify on site".
 *
 *   Every measure key produced here maps 1:1 onto the Architect's measure
 *   fields, so "Apply" simply pre-fills Step 3 (Measurements) for review.
 * ------------------------------------------------------------------------- */

/* Area archetypes → which measure fields a photo of that area can inform, with
 * typical / low / high priors (FL tile-&-remodel norms, sampled from the
 * gold-standard proposals). `areaTarget` is the field the reference-scale
 * width×length area fills directly; the rest ride heuristic priors. */
const VISION_AREAS = [
  { key: 'master_bath', label: 'Master Bathroom', areaTarget: 'masterBathSqft', cand: [
    { key: 'masterBathSqft', label: 'Master bath floor', unit: 'sq ft', typ: 90, lo: 55, hi: 150 },
    { key: 'showerWallSqft', label: 'Shower wall area', unit: 'sq ft', typ: 120, lo: 80, hi: 200 },
    { key: 'showerFloorSqft', label: 'Shower floor', unit: 'sq ft', typ: 16, lo: 9, hi: 30 },
    { key: 'sinkCount', label: 'Vanity sinks', unit: '', typ: 2, lo: 1, hi: 2 },
    { key: 'quartzSqft', label: 'Vanity top', unit: 'sq ft', typ: 26, lo: 12, hi: 50 },
  ] },
  { key: 'guest_bath', label: 'Guest / Hall Bath', areaTarget: 'guestBathSqft', cand: [
    { key: 'guestBathSqft', label: 'Guest bath floor', unit: 'sq ft', typ: 55, lo: 30, hi: 90 },
    { key: 'sinkCount', label: 'Sinks', unit: '', typ: 1, lo: 1, hi: 2 },
    { key: 'toiletCount', label: 'Toilets', unit: '', typ: 1, lo: 1, hi: 1 },
  ] },
  { key: 'shower', label: 'Shower / Tub Surround', areaTarget: 'showerWallSqft', cand: [
    { key: 'showerWallSqft', label: 'Shower / tub walls', unit: 'sq ft', typ: 120, lo: 70, hi: 220 },
    { key: 'showerFloorSqft', label: 'Shower pan', unit: 'sq ft', typ: 16, lo: 9, hi: 30 },
  ] },
  { key: 'kitchen', label: 'Kitchen', areaTarget: 'floorTileSqft', cand: [
    { key: 'quartzSqft', label: 'Countertops', unit: 'sq ft', typ: 55, lo: 25, hi: 110 },
    { key: 'backsplashSqft', label: 'Backsplash', unit: 'sq ft', typ: 34, lo: 18, hi: 60 },
    { key: 'canLightCount', label: 'Can lights', unit: '', typ: 8, lo: 4, hi: 14 },
    { key: 'floorTileSqft', label: 'Kitchen floor', unit: 'sq ft', typ: 180, lo: 90, hi: 320 },
  ] },
  { key: 'tile_floor', label: 'Tile Floor (room)', areaTarget: 'floorTileSqft', cand: [
    { key: 'floorTileSqft', label: 'Floor tile area', unit: 'sq ft', typ: 200, lo: 80, hi: 600 },
  ] },
  { key: 'lvp_floor', label: 'Wood / LVP Floor (room)', areaTarget: 'upstairsSqft', cand: [
    { key: 'upstairsSqft', label: 'LVP / wood floor area', unit: 'sq ft', typ: 320, lo: 120, hi: 900 },
    { key: 'upstairsPerimFt', label: 'Room perimeter', unit: 'ft', typ: 90, lo: 40, hi: 200 },
    { key: 'stairCount', label: 'Stairs', unit: '', typ: 0, lo: 0, hi: 16 },
  ] },
  { key: 'carpet_floor', label: 'Carpet Floor (room)', areaTarget: 'carpetSqft', cand: [
    { key: 'carpetSqft', label: 'Carpet area', unit: 'sq ft', typ: 220, lo: 100, hi: 600 },
  ] },
  { key: 'hardwood_floor', label: 'Hardwood Floor (room)', areaTarget: 'hardwoodSqft', cand: [
    { key: 'hardwoodSqft', label: 'Hardwood area', unit: 'sq ft', typ: 250, lo: 100, hi: 700 },
  ] },
  { key: 'backsplash', label: 'Backsplash', areaTarget: 'backsplashSqft', cand: [
    { key: 'backsplashSqft', label: 'Backsplash area', unit: 'sq ft', typ: 32, lo: 14, hi: 60 },
  ] },
  { key: 'countertop', label: 'Countertops', areaTarget: 'counterSqft', cand: [
    { key: 'counterSqft', label: 'Countertop area', unit: 'sq ft', typ: 48, lo: 20, hi: 110 },
  ] },
  { key: 'pool_spa', label: 'Pool / Spa / Lanai Tile', areaTarget: 'poolTileSqft', cand: [
    { key: 'poolTileSqft', label: 'Pool / waterline tile', unit: 'sq ft', typ: 120, lo: 40, hi: 400 },
  ] },
  { key: 'exterior_pavers', label: 'Exterior Pavers / Lanai', areaTarget: 'paverSqft', cand: [
    { key: 'paverSqft', label: 'Porcelain pavers', unit: 'sq ft', typ: 320, lo: 100, hi: 1200 },
  ] },
  { key: 'drywall_framing', label: 'Drywall / Framing', areaTarget: 'drywallSqft', cand: [
    { key: 'drywallSqft', label: 'Drywall area', unit: 'sq ft', typ: 240, lo: 80, hi: 800 },
    { key: 'ceilingSqft', label: 'Ceiling grid area', unit: 'sq ft', typ: 0, lo: 0, hi: 600 },
  ] },
  { key: 'commercial_floor', label: 'Commercial Unit Floor', areaTarget: 'floorTileSqft', cand: [
    { key: 'floorTileSqft', label: 'Commercial floor tile', unit: 'sq ft', typ: 600, lo: 200, hi: 3000 },
    { key: 'ceilingSqft', label: 'Drop-ceiling grid', unit: 'sq ft', typ: 600, lo: 200, hi: 3000 },
    { key: 'doorCount', label: 'Interior doors', unit: '', typ: 3, lo: 1, hi: 8 },
  ] },
  { key: 'other', label: 'Other / Unclassified', areaTarget: null, cand: [] },
];
const visionAreaByKey = (k) => VISION_AREAS.find(a => a.key === k) || VISION_AREAS[VISION_AREAS.length - 1];

/* Common real-world reference lengths (inches) operators can pick in the
 * calibrator so they don't have to know a measurement to start. */
const VISION_REFERENCES = [
  { key: 'door80', label: 'Standard door height', inches: 80 },
  { key: 'tile24', label: '24" tile edge', inches: 24 },
  { key: 'tile12', label: '12" tile edge', inches: 12 },
  { key: 'outlet', label: 'Outlet plate (4.5")', inches: 4.5 },
  { key: 'counter36', label: 'Counter height (36")', inches: 36 },
  { key: 'sheet8', label: 'Drywall sheet (96")', inches: 96 },
  { key: 'custom', label: 'Custom length…', inches: 0 },
];

const pxDist = (a, b) => (a && b) ? Math.hypot(a.x - b.x, a.y - b.y) : 0;

/* Confidence → UQ tier (AGI law: < 0.70 ⇒ flagged for human verification). */
function confTier(c) {
  if (c >= 0.7) return { label: 'high', verify: false, cls: 'border-emerald-500/40 text-emerald-300' };
  if (c >= 0.5) return { label: 'review', verify: true, cls: 'border-amber-500/40 text-amber-300' };
  return { label: 'verify on site', verify: true, cls: 'border-amber-600/50 text-amber-400' };
}

/* Compute the measure candidates a single analyzed photo contributes. */
function photoCandidates(photo) {
  const area = visionAreaByKey(photo.area);
  const out = [];
  // 1) AI vision result (highest-trust path) — already normalized to measure keys.
  if (photo.vision && photo.vision.available && Array.isArray(photo.vision.measures)) {
    photo.vision.measures.forEach(m => {
      if (!m || !m.key) return;
      const v = Number(m.value);
      if (!(v > 0)) return;
      out.push({ key: m.key, label: (area.cand.find(c => c.key === m.key) || {}).label || m.key,
        unit: m.unit || '', value: v, method: 'vision-ai',
        confidence: Math.max(0.4, Math.min(0.92, Number(m.confidence) || 0.78)) });
    });
    if (out.length) return out;
  }
  // 2) Reference-scale photogrammetry → fills the area target directly.
  if (photo.calib && photo.calib.areaSqft > 0 && area.areaTarget) {
    // Single-plane / fronto-parallel assumption: apply a perspective penalty so
    // the operator treats it as a strong estimate, not a survey.
    out.push({ key: area.areaTarget, label: (area.cand.find(c => c.key === area.areaTarget) || {}).label || area.areaTarget,
      unit: 'sq ft', value: round2(photo.calib.areaSqft), method: 'reference-scale', confidence: 0.62 });
  }
  // 3) Heuristic priors for the remaining candidate fields (and the target too,
  //    at low confidence, if no calibration was done).
  area.cand.forEach(c => {
    if (out.some(o => o.key === c.key)) return;
    if (!(c.typ > 0)) return; // 0-typ priors are opt-in only (e.g. stairs)
    out.push({ key: c.key, label: c.label, unit: c.unit, value: c.typ, method: 'heuristic-prior', confidence: 0.38 });
  });
  return out;
}

/* Blend candidates across all analyzed photos into a single proposed measure
 * set (confidence-weighted), with provenance for each field. */
function aggregateVision(photos) {
  const bucket = {}; // key -> { sum, wsum, conf, methods:Set, photos:Set, unit, label }
  photos.forEach((p, i) => {
    if (p.status !== 'analyzed') return;
    photoCandidates(p).forEach(c => {
      const b = bucket[c.key] || (bucket[c.key] = { sum: 0, wsum: 0, conf: 0, methods: new Set(), photos: new Set(), unit: c.unit, label: c.label });
      const w = c.confidence;
      b.sum += c.value * w; b.wsum += w;
      b.conf = Math.max(b.conf, c.confidence);
      b.methods.add(c.method); b.photos.add(i);
      b.unit = c.unit; b.label = c.label;
    });
  });
  const proposed = {}, sources = {};
  Object.keys(bucket).forEach(k => {
    const b = bucket[k];
    if (b.wsum <= 0) return;
    let v = b.sum / b.wsum;
    v = b.unit === '' ? Math.max(1, Math.round(v)) : Math.round(v); // counts → int, areas → nearest sqft
    proposed[k] = v;
    sources[k] = { method: [...b.methods].sort().join('+'), confidence: b.conf, photos: b.photos.size, unit: b.unit, label: b.label };
  });
  return { proposed, sources };
}

/* Optional AI-vision call. Provider-agnostic JSON contract:
 *   POST {rest}/vision/analyze { image: <dataURI>, area: <key>, mime }
 *   → { available:true, measures:[{key,value,confidence,unit}] }
 *   → { available:false, reason:'vision_fallback' }
 * Returns the fallback shape offline / when no endpoint is configured, so the
 * UI silently drops to reference-scale + heuristic methods. */
async function visionAnalyze(photo, settings) {
  const enabled = !!(settings && settings.vision && settings.vision.enabled);
  if (!HAS_WP || !enabled) return { available: false, reason: 'vision_fallback' };
  try {
    const r = await api('vision/analyze', { method: 'POST', body: { image: photo.dataUri, area: photo.area, mime: photo.mime } });
    if (r && r.available && Array.isArray(r.measures)) return r;
    return { available: false, reason: (r && r.reason) || 'vision_fallback' };
  } catch (e) {
    return { available: false, reason: 'vision_error' };
  }
}

async function runEstimate({ job, measure, scope, settings, photos, marginPct, onStage }) {
  const stage = (id, status, detail) => onStage && onStage({ id, status, detail, t: Date.now() });
  let catalog = SEED_CATALOG.map(c => ({ ...c }));

  // Analyst -------------------------------------------------------------
  stage('analyst', 'running');
  await sleep(280);
  const activePhases = PHASE_ORDER.filter(p => scope[p]);
  stage('analyst', 'done', `${activePhases.length} active phases, ${Object.keys(measure).length} measurements`);

  // Planner -------------------------------------------------------------
  stage('planner', 'running');
  await sleep(240);
  stage('planner', 'done', activePhases.join(' \u2192 ') || 'no phases selected');

  // Architect -----------------------------------------------------------
  stage('architect', 'running');
  await sleep(360);
  let demand = computeDemand(measure, scope, catalog);
  stage('architect', 'done', `${demand.length} material lines computed`);

  // Integration (live pricing) -----------------------------------------
  stage('integration', 'running');
  let priceSource = 'catalog';
  const live = await fetchLivePrices(demand.map(d => d.sku));
  if (live) {
    priceSource = (settings && settings.pricing_provider) || 'live';
    demand = demand.map(d => {
      const p = live[d.sku];
      if (p && typeof p.price === 'number') {
        return { ...d, price: p.price, source: p.source || priceSource, last_synced: p.last_synced || nowISO(), total: round2(d.qty * p.price) };
      }
      return { ...d, source: d.source + ' (fallback)' };
    });
    stage('integration', 'done', `live prices via ${priceSource}`);
  } else {
    await sleep(300);
    stage('integration', 'done', HAS_WP ? 'no live provider \u2014 maintained catalog baseline' : 'previewer \u2014 maintained catalog baseline');
  }

  // Orchestrator (assemble) --------------------------------------------
  stage('orchestrator', 'running');
  await sleep(320);
  const labor = computeLabor(measure, scope);
  // v0.0.4 — disclaimer / no-charge phases that still belong on the proposal at $0.
  const KEEP_EMPTY = ['Permits & Architectural'];
  const byPhase = {};
  for (const phase of activePhases) {
    const mat = demand.filter(d =>
      d.category === phase ||
      (phase === 'Remove and dispose' && false) ||
      (phase === 'Carpentry & Trim' && d.category === 'Carpentry & Trim')
    );
    const matTotal = round2(mat.reduce((s, m) => s + m.total, 0));
    const labTotal = labor[phase] || 0;
    // Laser focus: a phase only renders when the job actually has measured / flat
    // content for it. Unmeasured trades (no materials AND no labor) drop out, so the
    // proposal never carries context the user didn't enter measurements for.
    if (matTotal <= 0 && labTotal <= 0 && KEEP_EMPTY.indexOf(phase) === -1) continue;
    byPhase[phase] = {
      phase, materials: mat, materialTotal: matTotal, laborTotal: labTotal,
      narrative: PHASE_NARRATIVE[phase] || '',
      total: round2(matTotal + labTotal),
    };
  }
  const includedPhases = activePhases.filter(p => byPhase[p]);
  const materialTotal = round2(demand.reduce((s, m) => s + m.total, 0));
  const laborTotal = round2(Object.values(labor).reduce((s, v) => s + v, 0));
  const markupPct = (settings && settings.markup_pct != null) ? settings.markup_pct : 18;
  const taxPct = (settings && settings.tax_pct != null) ? settings.tax_pct : 6.5;
  const mPct = (marginPct != null) ? marginPct : ((settings && settings.margin_pct != null) ? settings.margin_pct : 0);
  const baseSubtotal = round2(materialTotal + laborTotal);
  const withMargin = applyMargin(baseSubtotal, mPct);   // operator profit (hidden)
  const withMarkup = applyMarkup(withMargin, markupPct); // standing overhead markup
  const tax = round2(withMarkup * (taxPct / 100));
  const grandTotal = round2(withMarkup + tax);
  stage('orchestrator', 'done', `${includedPhases.length} of ${activePhases.length} phases in scope \u00b7 subtotal ${fmt(baseSubtotal)} \u2192 total ${fmt(grandTotal)}`);

  // Evolution / Monitoring ---------------------------------------------
  stage('monitoring', 'running');
  await sleep(260);
  const staleCount = demand.filter(d => {
    const age = (Date.now() - new Date(d.last_synced).getTime()) / 86400000;
    return age > 30;
  }).length;
  const measureCompleteness = Math.min(1, Object.values(measure).filter(v => Number(v) > 0).length / 8);
  const confidence = Math.round((0.55 + 0.35 * measureCompleteness + (live ? 0.10 : 0)) * 100);
  stage('monitoring', 'done', `confidence ${confidence}% \u00b7 ${staleCount} stale price${staleCount === 1 ? '' : 's'}`);

  // Feedback ------------------------------------------------------------
  stage('feedback', 'idle', 'awaiting operator overrides');

  const estimate = {
    job, measure, scope: includedPhases,
    phases: includedPhases.map(p => byPhase[p]).filter(Boolean),
    lineItems: demand,
    photos: Array.isArray(photos) ? photos.map(p => ({
      name: p.name, dataUri: p.dataUri, mime: p.mime, area: p.area,
      areaLabel: visionAreaByKey(p.area).label, notes: p.notes || '',
      w: p.w, h: p.h, method: p.method || '', confidence: p.confidence || 0,
      calib: p.calib || null, cad: !!p.cad,
    })) : [],
    totals: { materialTotal, laborTotal, baseSubtotal, marginPct: mPct, withMargin, markupPct, withMarkup, taxPct, tax, grandTotal },
    meta: { confidence, staleCount, priceSource, photoCount: Array.isArray(photos) ? photos.length : 0, generatedAt: nowISO(), version: '0.0.7' },
  };
  return estimate;
}

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

/* ----------------------------------------------------------------------------
 * 6. SHARED UI PRIMITIVES
 * ------------------------------------------------------------------------- */
function Shimmer({ className = '' }) {
  return <span className={`ces-shimmer pointer-events-none absolute inset-0 ${className}`} aria-hidden="true" />;
}

function Cube({ spin = false, className = '' }) {
  return <span className={`inline-block ${spin ? 'ces-spin' : ''} ${className}`}>🧊</span>;
}

function Card({ children, className = '', glow = false }) {
  return (
    <div className={`relative overflow-hidden rounded-[22px] border border-zinc-800 bg-zinc-950/80 ${glow ? 'ces-glow' : ''} ${className}`}>
      {children}
    </div>
  );
}

function Field({ label, children, hint }) {
  return (
    <label className="block">
      <span className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-500">{label}</span>
      {children}
      {hint && <span className="mt-1 block text-[11px] text-zinc-600">{hint}</span>}
    </label>
  );
}

const inputCls = "w-full rounded-[14px] border border-zinc-800 bg-black px-4 py-3 text-sm text-white placeholder-zinc-600 outline-none transition focus:border-white/40 focus:ring-2 focus:ring-white/10";

function NumInput({ value, onChange, placeholder, step = '1', min = '0' }) {
  return <input type="number" inputMode="decimal" step={step} min={min} value={value ?? ''} placeholder={placeholder}
    onChange={(e) => onChange(e.target.value === '' ? '' : parseFloat(e.target.value))} className={inputCls} />;
}

/* v0.0.4 — measurement field that signals proposal inclusion.
 * A value > 0 makes the label + input glow emerald and tags it "IN PROPOSAL";
 * a 0 / blank field stays dim and is excluded from the generated proposal. */
function MeasureField({ label, value, onChange, placeholder, step = '1', min = '0', source }) {
  const on = Number(value) > 0;
  return (
    <label className="block">
      <span className={`mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] transition ${on ? 'text-emerald-300' : 'text-zinc-500'}`}>
        <span className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full transition ${on ? 'bg-emerald-400 ces-dot-glow' : 'bg-zinc-700'}`} aria-hidden="true" />
        <span className="min-w-0 truncate">{label}</span>
        {on && <span className="ml-auto shrink-0 text-[8.5px] font-bold tracking-[0.16em] text-emerald-400/80">IN PROPOSAL</span>}
      </span>
      <div className={`relative rounded-[14px] transition ${on ? 'ces-field-glow' : ''}`}>
        <input type="number" inputMode="decimal" step={step} min={min} value={value ?? ''} placeholder={placeholder}
          onChange={(e) => onChange(e.target.value === '' ? '' : parseFloat(e.target.value))}
          className={`w-full rounded-[14px] border bg-black px-4 py-3 text-sm text-white outline-none transition focus:ring-2 focus:ring-white/10 ${on ? 'border-emerald-400/50' : 'border-zinc-800 placeholder-zinc-600 focus:border-white/40'}`} />
      </div>
      {source && (
        <span className="mt-1 flex flex-wrap items-center gap-1.5">
          <span className="text-[8.5px] font-bold uppercase tracking-[0.16em] text-sky-300/70">from photos</span>
          <MethodTag method={source.method} />
          <ConfidenceChip confidence={source.confidence} />
        </span>
      )}
    </label>
  );
}

function Toggle({ active, onClick, children }) {
  return (
    <button type="button" onClick={onClick}
      className={`relative overflow-hidden rounded-[14px] border px-4 py-2 text-sm font-semibold transition ${active ? 'border-white bg-white text-black' : 'border-zinc-800 bg-zinc-900 text-zinc-400 hover:border-zinc-600 hover:text-white'}`}>
      {active && <Shimmer />}
      <span className="relative">{children}</span>
    </button>
  );
}

function StatPill({ label, value, mono = true }) {
  return (
    <div className="rounded-[14px] border border-zinc-800 bg-black/60 px-3 py-2">
      <div className="text-[10px] uppercase tracking-[0.16em] text-zinc-600">{label}</div>
      <div className={`mt-0.5 text-sm font-bold text-white ${mono ? 'ces-mono' : ''}`}>{value}</div>
    </div>
  );
}

/* ----------------------------------------------------------------------------
 * 6b. VISION TAKEOFF UI — job-site photo intake → measurement extrapolation.
 *     AGI AgentOS UQ law: every proposed number carries an epistemic-confidence
 *     tier; anything < 0.70 renders amber + "verify on site" and is never sold
 *     as a survey. Three honest methods, best-available wins per field:
 *       vision-ai (optional endpoint) ▸ reference-scale (operator calibrates a
 *       known length) ▸ heuristic-prior (scope-aware typical dimensions).
 * ------------------------------------------------------------------------- */
function MethodTag({ method }) {
  const map = {
    'vision-ai':       { label: 'AI vision', cls: 'border-sky-500/40 text-sky-300' },
    'reference-scale': { label: 'scaled',    cls: 'border-violet-500/40 text-violet-300' },
    'heuristic-prior': { label: 'prior',     cls: 'border-zinc-700 text-zinc-400' },
  };
  const parts = String(method || '').split('+').filter(Boolean);
  if (!parts.length) return null;
  return (
    <span className="inline-flex flex-wrap items-center gap-1">
      {parts.map(p => {
        const m = map[p] || { label: p, cls: 'border-zinc-700 text-zinc-400' };
        return <span key={p} className={`rounded-full border px-1.5 py-0.5 text-[8.5px] font-bold uppercase tracking-[0.12em] ${m.cls}`}>{m.label}</span>;
      })}
    </span>
  );
}

function ConfidenceChip({ confidence }) {
  const t = confTier(confidence || 0);
  const pct = Math.round((confidence || 0) * 100);
  return (
    <span className={`inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[8.5px] font-bold uppercase tracking-[0.12em] ${t.cls}`}>
      {t.verify && <span aria-hidden="true">⚠</span>}{pct}% · {t.label}
    </span>
  );
}

/* Reference-scale calibrator: operator marks one known real-world length, then
 * draws the area's width + length; we solve in/px and return area in sq ft.
 * Single-plane (fronto-parallel) assumption — the engine applies a perspective
 * penalty so the result reads as a strong estimate, not a measured truth. */
function PhotoCalibrator({ photo, onClose, onSave }) {
  const canvasRef = useRef(null);
  const imgRef = useRef(null);
  const [refKey, setRefKey] = useState('door80');
  const [customIn, setCustomIn] = useState(80);
  const [mode, setMode] = useState('ref');
  const [pts, setPts] = useState({ ref: [], width: [], length: [] });
  const [dims, setDims] = useState({ w: 520, h: 360 });

  const refObj = VISION_REFERENCES.find(r => r.key === refKey) || VISION_REFERENCES[0];
  const refInches = refKey === 'custom' ? (Number(customIn) || 0) : refObj.inches;

  useEffect(() => {
    if (!photo.dataUri) return;
    const img = new Image();
    img.onload = () => {
      imgRef.current = img;
      const maxW = 520;
      const scale = Math.min(1, maxW / (img.naturalWidth || maxW));
      setDims({ w: Math.round((img.naturalWidth || maxW) * scale), h: Math.round((img.naturalHeight || 360) * scale) });
    };
    img.src = photo.dataUri;
  }, [photo.dataUri]);

  useEffect(() => {
    const cv = canvasRef.current; if (!cv || !imgRef.current) return;
    const ctx = cv.getContext('2d');
    ctx.clearRect(0, 0, cv.width, cv.height);
    ctx.drawImage(imgRef.current, 0, 0, cv.width, cv.height);
    const colors = { ref: '#f59e0b', width: '#38bdf8', length: '#34d399' };
    Object.keys(pts).forEach(k => {
      const ps = pts[k];
      ctx.strokeStyle = colors[k]; ctx.fillStyle = colors[k]; ctx.lineWidth = 2.5;
      if (ps.length === 2) { ctx.beginPath(); ctx.moveTo(ps[0].x, ps[0].y); ctx.lineTo(ps[1].x, ps[1].y); ctx.stroke(); }
      ps.forEach(p => { ctx.beginPath(); ctx.arc(p.x, p.y, 4.5, 0, Math.PI * 2); ctx.fill(); });
    });
  }, [pts, dims]);

  const onCanvasClick = (e) => {
    const cv = canvasRef.current; if (!cv) return;
    const rect = cv.getBoundingClientRect();
    const x = (e.clientX - rect.left) * (cv.width / rect.width);
    const y = (e.clientY - rect.top) * (cv.height / rect.height);
    setPts(prev => {
      const cur = prev[mode];
      const next = cur.length >= 2 ? [{ x, y }] : [...cur, { x, y }];
      return { ...prev, [mode]: next };
    });
  };

  const scaleInPerPx = (pts.ref.length === 2 && refInches > 0) ? refInches / pxDist(pts.ref[0], pts.ref[1]) : 0;
  const widthIn  = (scaleInPerPx && pts.width.length  === 2) ? pxDist(pts.width[0],  pts.width[1])  * scaleInPerPx : 0;
  const lengthIn = (scaleInPerPx && pts.length.length === 2) ? pxDist(pts.length[0], pts.length[1]) * scaleInPerPx : 0;
  const areaSqft = (widthIn > 0 && lengthIn > 0) ? round2((widthIn / 12) * (lengthIn / 12)) : 0;

  const steps = [
    { k: 'ref',    c: '#f59e0b', t: '1 · Known length', d: pts.ref.length === 2 },
    { k: 'width',  c: '#38bdf8', t: '2 · Width',        d: pts.width.length === 2 },
    { k: 'length', c: '#34d399', t: '3 · Length',       d: pts.length.length === 2 },
  ];

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
      <div className="relative max-h-[92vh] w-full max-w-2xl overflow-auto rounded-[22px] border border-zinc-700 bg-zinc-950 p-5" onClick={e => e.stopPropagation()}>
        <div className="mb-3 flex items-center gap-2">
          <Cube className="text-base" />
          <h3 className="text-sm font-bold uppercase tracking-[0.16em] text-white">Reference-scale calibrator</h3>
          <button onClick={onClose} className="ml-auto rounded-full border border-zinc-700 px-2 py-0.5 text-xs text-zinc-400 hover:text-white">✕</button>
        </div>
        <p className="mb-3 text-[11px] leading-snug text-zinc-500">Pick a real-world reference you can see in the photo, click its two ends, then trace the area&rsquo;s width and length. We solve inches-per-pixel and estimate the floor area. Flat, straight-on shots calibrate best.</p>

        <div className="mb-3 flex flex-wrap gap-2">
          {steps.map(s => (
            <button key={s.k} onClick={() => setMode(s.k)}
              className={`rounded-[14px] border px-3 py-1.5 text-xs font-semibold transition ${mode === s.k ? 'border-white bg-white text-black' : 'border-zinc-800 bg-zinc-900 text-zinc-400 hover:text-white'}`}>
              <span className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle" style={{ background: s.c }} />{s.t}{s.d ? ' ✓' : ''}
            </button>
          ))}
        </div>

        <div className="mb-3 grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
          <Field label="Reference object">
            <select className={inputCls} value={refKey} onChange={e => setRefKey(e.target.value)}>
              {VISION_REFERENCES.map(r => <option key={r.key} value={r.key}>{r.label}</option>)}
            </select>
          </Field>
          {refKey === 'custom' && (
            <Field label="Known length (in)">
              <input type="number" className={inputCls} style={{ maxWidth: 140 }} value={customIn} onChange={e => setCustomIn(e.target.value === '' ? '' : parseFloat(e.target.value))} />
            </Field>
          )}
        </div>

        <div className="overflow-hidden rounded-[14px] border border-zinc-800" style={{ width: dims.w, maxWidth: '100%' }}>
          <canvas ref={canvasRef} width={dims.w} height={dims.h} onClick={onCanvasClick}
            style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }} />
        </div>

        <div className="mt-3 grid grid-cols-3 gap-2 text-center">
          <StatPill label="Width" value={widthIn ? `${round2(widthIn / 12)} ft` : '—'} />
          <StatPill label="Length" value={lengthIn ? `${round2(lengthIn / 12)} ft` : '—'} />
          <StatPill label="Area" value={areaSqft ? `${areaSqft} sf` : '—'} />
        </div>

        <div className="mt-4 flex justify-between">
          <GhostBtn onClick={() => setPts({ ref: [], width: [], length: [] })}>↺ Reset marks</GhostBtn>
          <PrimaryBtn disabled={!(areaSqft > 0)} onClick={() => onSave({ scaleInPerPx, widthIn: round2(widthIn), lengthIn: round2(lengthIn), areaSqft, refInches, refKey })}>
            {areaSqft > 0 ? `Save · ${areaSqft} sf →` : 'Mark all three lines'}
          </PrimaryBtn>
        </div>
      </div>
    </div>
  );
}

function VisionTakeoff({ photos, setPhotos, settings, onApply, flash, visionEnabled }) {
  const fileRef = useRef(null);
  const [calibrating, setCalibrating] = useState(null);
  const [previewing, setPreviewing] = useState(null); // photo id shown in the zoom lightbox
  const [drag, setDrag] = useState(false);

  const ACCEPT = '.jpg,.jpeg,.png,.webp,.gif,.dng,.dxf,.dwg,.obj,.ply,.ifc,image/*';
  const IMG_RE = /\.(jpe?g|png|webp|gif|bmp)$/i;

  const guessArea = (name) => {
    const n = (name || '').toLowerCase();
    if (/master/.test(n)) return 'master_bath';
    if (/guest|hall/.test(n)) return 'guest_bath';
    if (/shower|tub/.test(n)) return 'shower';
    if (/kitchen/.test(n)) return 'kitchen';
    if (/back ?splash/.test(n)) return 'backsplash';
    if (/counter|quartz|granite/.test(n)) return 'countertop';
    if (/pool|spa|waterline|lanai/.test(n)) return 'pool_spa';
    if (/paver|patio|driveway/.test(n)) return 'exterior_pavers';
    if (/carpet/.test(n)) return 'carpet_floor';
    if (/hardwood/.test(n)) return 'hardwood_floor';
    if (/lvp|vinyl|plank/.test(n)) return 'lvp_floor';
    if (/drywall|framing/.test(n)) return 'drywall_framing';
    if (/floor|tile/.test(n)) return 'tile_floor';
    return 'other';
  };

  const mkId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);

  function addFiles(fileList) {
    Array.from(fileList || []).forEach(file => {
      const isImg = IMG_RE.test(file.name) || /^image\//.test(file.type || '');
      const id = mkId();
      if (isImg) {
        const reader = new FileReader();
        reader.onload = () => {
          const dataUri = reader.result;
          const im = new Image();
          im.onload = () => setPhotos(prev => [...prev, {
            id, name: file.name, dataUri, mime: file.type || 'image/jpeg',
            w: im.naturalWidth, h: im.naturalHeight, area: guessArea(file.name),
            notes: '', calib: null, vision: null, status: 'pending', method: '', confidence: 0,
          }]);
          im.onerror = () => setPhotos(prev => [...prev, {
            id, name: file.name, dataUri, mime: file.type || 'image/jpeg',
            w: 0, h: 0, area: guessArea(file.name), notes: '', calib: null, vision: null,
            status: 'pending', method: '', confidence: 0,
          }]);
          im.src = dataUri;
        };
        reader.readAsDataURL(file);
      } else {
        // CAD / point-cloud (.dxf/.dwg/.obj/.ply/.ifc/.dng): kept as a metadata
        // reference — no in-browser geometry parse, so it informs notes only.
        setPhotos(prev => [...prev, {
          id, name: file.name, dataUri: null, mime: file.type || 'application/octet-stream',
          w: 0, h: 0, area: guessArea(file.name), notes: '', calib: null, vision: null,
          status: 'cad', method: '', confidence: 0, cad: true,
        }]);
      }
    });
  }

  const patch = (id, p) => setPhotos(prev => prev.map(ph => ph.id === id ? { ...ph, ...p } : ph));
  const remove = (id) => setPhotos(prev => prev.filter(ph => ph.id !== id));

  function finalizePhoto(ph) {
    const cands = photoCandidates(ph);
    return {
      ...ph, status: 'analyzed',
      method: [...new Set(cands.map(c => c.method))].sort().join('+'),
      confidence: cands.reduce((m, c) => Math.max(m, c.confidence), 0),
    };
  }

  async function analyzeOne(id) {
    const photo = photos.find(p => p.id === id);
    if (!photo || photo.cad) return;
    patch(id, { status: 'analyzing' });
    let vision = null;
    try { vision = await visionAnalyze(photo, settings); } catch { vision = { available: false, reason: 'vision_error' }; }
    setPhotos(prev => prev.map(p => p.id === id ? finalizePhoto({ ...p, vision }) : p));
  }

  async function analyzeAll() {
    const targets = photos.filter(p => !p.cad);
    for (const p of targets) {
      let vision = null;
      try { vision = await visionAnalyze(p, settings); } catch { vision = { available: false, reason: 'vision_error' }; }
      setPhotos(prev => prev.map(x => x.id === p.id ? finalizePhoto({ ...x, vision }) : x));
    }
    flash && flash('🧊 Photos analyzed · review proposed measurements');
  }

  const onDrop = (e) => { e.preventDefault(); setDrag(false); if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); };

  const analyzedCount = photos.filter(p => p.status === 'analyzed').length;
  const { proposed, sources } = analyzedCount ? aggregateVision(photos) : { proposed: {}, sources: {} };
  const proposedKeys = Object.keys(proposed);
  const calibPhoto = calibrating ? photos.find(p => p.id === calibrating) : null;
  const previewPhoto = previewing ? photos.find(p => p.id === previewing) : null;

  return (
    <Card className="p-6 md:p-8">
      <div className="mb-2 flex flex-wrap items-center gap-2">
        <h2 className="text-lg font-bold">🧊 Job Site Photos</h2>
        <span className={`rounded-full border px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.14em] ${visionEnabled ? 'border-sky-500/40 text-sky-300' : 'border-zinc-700 text-zinc-500'}`}>
          {visionEnabled ? 'AI vision · on' : 'AI vision · off (scale + priors)'}
        </span>
      </div>
      <p className="mb-4 text-[12px] leading-snug text-zinc-500">
        Drop job-site photos and the Vision agent extrapolates a first pass at measurements, materials &amp; labor to pre-fill the next step &mdash; then you just tweak. Every number is tagged with a confidence tier; <span className="text-amber-300">anything below 70% is flagged &ldquo;verify on site.&rdquo;</span> Photos you add here are attached to the PDF proposal, one per page, with a contractor-notes block.
      </p>

      {/* Dropzone */}
      <div
        onClick={() => fileRef.current && fileRef.current.click()}
        onDragOver={e => { e.preventDefault(); setDrag(true); }}
        onDragLeave={() => setDrag(false)}
        onDrop={onDrop}
        className={`relative cursor-pointer overflow-hidden rounded-[22px] border-2 border-dashed p-7 text-center transition ${drag ? 'border-white bg-white/5' : 'border-zinc-700 bg-black/40 hover:border-zinc-500'}`}>
        {drag && <Shimmer />}
        <div className="relative">
          <div className="text-3xl">🧊</div>
          <div className="mt-2 text-sm font-semibold text-white">Drop photos here or click to browse</div>
          <div className="mt-1 text-[11px] text-zinc-500">JPG · PNG · WEBP analyzed in-browser · DNG / DXF / DWG / OBJ / PLY / IFC stored as job references</div>
        </div>
        <input ref={fileRef} type="file" accept={ACCEPT} multiple className="hidden"
          onChange={e => { addFiles(e.target.files); e.target.value = ''; }} />
      </div>

      {photos.length > 0 && (
        <div className="mt-4 flex flex-wrap items-center gap-2">
          <PrimaryBtn onClick={analyzeAll}>⚡ Analyze all ({photos.filter(p => !p.cad).length})</PrimaryBtn>
          <GhostBtn onClick={() => setPhotos([])}>Clear all</GhostBtn>
          <span className="text-[11px] text-zinc-600">{analyzedCount} of {photos.length} analyzed</span>
        </div>
      )}

      {/* Photo cards */}
      {photos.length > 0 && (
        <div className="mt-4 grid gap-4 sm:grid-cols-2">
          {photos.map(ph => {
            const area = visionAreaByKey(ph.area);
            return (
              <div key={ph.id} className="overflow-hidden rounded-[22px] border border-zinc-800 bg-black/60">
                <div className="group relative bg-zinc-900" style={{ aspectRatio: '1 / 1' }}>
                  {ph.dataUri
                    ? <>
                        <img src={ph.dataUri} alt={ph.name}
                          onClick={() => setPreviewing(ph.id)}
                          style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', cursor: 'zoom-in' }} />
                        <button onClick={() => setPreviewing(ph.id)}
                          style={{ bottom: '0.5rem', left: '0.5rem' }}
                          className="absolute flex items-center gap-1 rounded-full border border-white/30 bg-black/70 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-white opacity-0 transition group-hover:opacity-100">
                          🔍 Preview
                        </button>
                      </>
                    : <div className="flex h-full items-center justify-center text-center text-zinc-600">
                        <div><div className="text-2xl">🧊</div><div className="mt-1 text-[10px] uppercase tracking-[0.14em]">CAD / point-cloud reference</div></div>
                      </div>}
                  <div className="absolute right-2 top-2 flex gap-1">
                    {ph.status === 'analyzed' && <ConfidenceChip confidence={ph.confidence} />}
                    {ph.cad && <span className="rounded-full border border-zinc-600 bg-black/70 px-1.5 py-0.5 text-[8.5px] font-bold uppercase tracking-[0.12em] text-zinc-400">ref only</span>}
                  </div>
                </div>
                <div className="p-3">
                  <div className="mb-2 flex items-center gap-2">
                    <span className="truncate text-[11px] text-zinc-500" title={ph.name}>{ph.name}</span>
                    <button onClick={() => remove(ph.id)} className="ml-auto shrink-0 rounded-full border border-zinc-700 px-2 py-0.5 text-[10px] text-zinc-400 hover:border-red-500/50 hover:text-red-300">remove</button>
                  </div>
                  <Field label="Area type">
                    <select className={inputCls} value={ph.area} onChange={e => patch(ph.id, { area: e.target.value, status: ph.status === 'analyzed' ? 'pending' : ph.status })}>
                      {VISION_AREAS.map(a => <option key={a.key} value={a.key}>{a.label}</option>)}
                    </select>
                  </Field>
                  <div className="mt-2">
                    <Field label="Contractor notes (prints under this photo)">
                      <textarea className={inputCls} rows={2} value={ph.notes} placeholder="Demo existing tile, slope to drain, etc."
                        onChange={e => patch(ph.id, { notes: e.target.value })} style={{ resize: 'vertical' }} />
                    </Field>
                  </div>
                  {ph.status === 'analyzed' && (
                    <div className="mt-2 flex flex-wrap items-center gap-1.5">
                      <MethodTag method={ph.method} />
                      {ph.calib && <span className="text-[10px] text-violet-300">{ph.calib.areaSqft} sf scaled</span>}
                    </div>
                  )}
                  {!ph.cad && (
                    <div className="mt-3 flex flex-wrap gap-2">
                      <button onClick={() => setPreviewing(ph.id)} className="rounded-[14px] border border-zinc-700 px-3 py-1.5 text-xs font-semibold text-white transition hover:border-white">🔍 Preview</button>
                      <button onClick={() => setCalibrating(ph.id)} className="rounded-[14px] border border-violet-500/40 px-3 py-1.5 text-xs font-semibold text-violet-300 transition hover:bg-violet-500/10">📐 Calibrate</button>
                      <button onClick={() => analyzeOne(ph.id)} disabled={ph.status === 'analyzing'}
                        className="rounded-[14px] border border-zinc-700 px-3 py-1.5 text-xs font-semibold text-white transition hover:border-white disabled:opacity-50">
                        {ph.status === 'analyzing' ? 'analyzing…' : ph.status === 'analyzed' ? '↻ Re-analyze' : '⚡ Analyze'}
                      </button>
                    </div>
                  )}
                </div>
              </div>
            );
          })}
        </div>
      )}

      {/* Aggregated proposed measurements */}
      {proposedKeys.length > 0 && (
        <div className="mt-6 overflow-hidden rounded-[22px] border border-emerald-500/30 bg-emerald-500/[0.04]">
          <div className="flex flex-wrap items-center gap-2 border-b border-emerald-500/20 px-4 py-3">
            <Cube className="text-sm" />
            <h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-emerald-300">Proposed measurements</h3>
            <span className="text-[10px] text-zinc-500">confidence-weighted blend across {analyzedCount} photo{analyzedCount === 1 ? '' : 's'}</span>
            <span className="ml-auto"><PrimaryBtn onClick={() => onApply(proposed, sources)}>Apply {proposedKeys.length} → Measurements</PrimaryBtn></span>
          </div>
          <div className="grid gap-2 p-4 sm:grid-cols-2 lg:grid-cols-3">
            {proposedKeys.map(k => {
              const s = sources[k] || {};
              return (
                <div key={k} className="rounded-[14px] border border-zinc-800 bg-black/50 px-3 py-2">
                  <div className="flex items-baseline gap-2">
                    <span className="text-sm font-bold text-white ces-mono">{proposed[k]}</span>
                    <span className="text-[10px] text-zinc-500">{s.unit || ''}</span>
                    <span className="ml-auto"><ConfidenceChip confidence={s.confidence} /></span>
                  </div>
                  <div className="mt-0.5 flex items-center gap-1.5">
                    <span className="truncate text-[11px] text-zinc-400">{s.label || k}</span>
                    <span className="ml-auto shrink-0"><MethodTag method={s.method} /></span>
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      )}

      {calibPhoto && (
        <PhotoCalibrator photo={calibPhoto} onClose={() => setCalibrating(null)}
          onSave={(calib) => { setPhotos(prev => prev.map(p => p.id === calibPhoto.id ? finalizePhoto({ ...p, calib }) : p)); setCalibrating(null); flash && flash('📐 Scale saved · area updated'); }} />
      )}

      {/* v0.0.7 — zoom lightbox: preview any job-site photo full-bleed without
          having to open the calibrator. Click backdrop or ✕ to close. */}
      {previewPhoto && previewPhoto.dataUri && (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 md:p-8" onClick={() => setPreviewing(null)}>
          <div className="relative max-h-[92vh] w-full overflow-hidden rounded-[22px] border border-zinc-700 bg-zinc-950" style={{ maxWidth: '64rem' }} onClick={(e) => e.stopPropagation()}>
            <div className="flex items-center gap-2 border-b border-zinc-800 px-4 py-3">
              <Cube className="text-sm" />
              <span className="truncate text-[12px] font-semibold text-white" title={previewPhoto.name}>{previewPhoto.name}</span>
              <span className="ml-2 rounded-full border border-zinc-700 px-2 py-0.5 text-[10px] text-zinc-400">{visionAreaByKey(previewPhoto.area).label}</span>
              {previewPhoto.w > 0 && <span className="ces-mono text-[10px] text-zinc-600">{previewPhoto.w}×{previewPhoto.h}px</span>}
              {previewPhoto.status === 'analyzed' && <span className="ml-2"><ConfidenceChip confidence={previewPhoto.confidence} /></span>}
              <button onClick={() => setPreviewing(null)} className="ml-auto rounded-full border border-zinc-700 px-3 py-1 text-sm text-zinc-300 transition hover:border-white hover:text-white">✕</button>
            </div>
            <div className="flex items-center justify-center bg-black" style={{ maxHeight: '72vh' }}>
              <img src={previewPhoto.dataUri} alt={previewPhoto.name} style={{ maxWidth: '100%', maxHeight: '72vh', objectFit: 'contain', display: 'block' }} />
            </div>
            {previewPhoto.notes ? (
              <div className="border-t border-zinc-800 px-4 py-3">
                <div className="mb-1.5 text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-500">Contractor notes</div>
                <p className="text-[12px] leading-snug text-zinc-300">{previewPhoto.notes}</p>
              </div>
            ) : null}
            <div className="flex flex-wrap items-center gap-2 border-t border-zinc-800 px-4 py-3">
              <button onClick={() => { setPreviewing(null); setCalibrating(previewPhoto.id); }} className="rounded-[14px] border border-violet-500/40 px-3 py-1.5 text-xs font-semibold text-violet-300 transition hover:bg-violet-500/10">📐 Calibrate this photo</button>
              {previewPhoto.status === 'analyzed' && <div className="flex items-center gap-1.5"><MethodTag method={previewPhoto.method} />{previewPhoto.calib && <span className="text-[10px] text-violet-300">{previewPhoto.calib.areaSqft} sf scaled</span>}</div>}
            </div>
          </div>
        </div>
      )}
    </Card>
  );
}

/* ----------------------------------------------------------------------------
 * 7. AGENTOS PIPELINE — animated kanban of the "ghost in the machine"
 * ------------------------------------------------------------------------- */
function AgentLane({ agent, state }) {
  const status = state?.status || 'idle';
  const tone = {
    idle:    'border-zinc-800 text-zinc-600',
    running: 'border-white/70 text-white ces-glow',
    done:    'border-emerald-500/40 text-emerald-300',
  }[status];
  return (
    <div className={`relative overflow-hidden rounded-[22px] border ${tone} bg-black/70 p-4 transition-all duration-300`}>
      {status === 'running' && <Shimmer />}
      <div className="relative flex items-center gap-2">
        <Cube spin={status === 'running'} className="text-lg" />
        <span className="text-sm font-bold tracking-tight">{agent.name}</span>
        <span className="ml-auto text-[10px] uppercase tracking-[0.16em]">
          {status === 'running' ? 'running' : status === 'done' ? '✓ done' : 'idle'}
        </span>
      </div>
      <p className="relative mt-2 text-[11px] leading-snug text-zinc-500">{agent.role}</p>
      {state?.detail && <p className="relative mt-2 ces-mono text-[11px] text-zinc-300">{state.detail}</p>}
    </div>
  );
}

function AgentOSBoard({ pipeline, running }) {
  return (
    <Card className="p-5">
      <div className="mb-4 flex items-center gap-2">
        <Cube spin={running} className="text-xl" />
        <h3 className="text-sm font-bold uppercase tracking-[0.2em] text-white">AgentOS · Ghost in the Machine</h3>
        <span className="ml-auto ces-mono text-[11px] text-zinc-500">{running ? 'orchestrating…' : 'standby'}</span>
      </div>
      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
        {AGENTS.map(a => <AgentLane key={a.id} agent={a} state={pipeline[a.id]} />)}
      </div>
    </Card>
  );
}

/* ----------------------------------------------------------------------------
 * 8. PRICING PANEL — live source + last-synced "network metrics" per material
 * ------------------------------------------------------------------------- */
/* ----------------------------------------------------------------------------
 * 8a. MATERIAL LIBRARY BRIDGE — predictive suggestions + name-aware live pricing.
 *     Talks to WordPress (REST) when present, else runs fully offline against a
 *     localStorage archive seeded from SEED_CATALOG (previewer parity).
 * ------------------------------------------------------------------------- */
const LS_MATERIALS = 'ces_materials_lib';
const normName = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();

const localLib = {
  read() { try { return JSON.parse(localStorage.getItem(LS_MATERIALS) || '[]'); } catch (e) { return []; } },
  write(list) { try { localStorage.setItem(LS_MATERIALS, JSON.stringify(list.slice(0, 500))); } catch (e) {} },
  upsert(m) {
    if (!m || !m.name) return;
    const key = normName(m.name);
    if (!key) return;
    const list = localLib.read();
    const i = list.findIndex((x) => x.mkey === key);
    if (i >= 0) {
      const ex = list[i];
      list[i] = { ...ex, sku: m.sku || ex.sku, category: m.category || ex.category, unit: m.unit || ex.unit, price: (m.price > 0 ? m.price : ex.price), source: m.source || ex.source, last_synced: m.last_synced || ex.last_synced, times_seen: (ex.times_seen || 1) + 1 };
    } else {
      list.push({ mkey: key, sku: m.sku || '', name: m.name, category: m.category || 'Uncategorized', unit: m.unit || 'ea', price: m.price || 0, source: m.source || 'catalog', last_synced: m.last_synced || nowISO(), times_seen: 1, projects: 1 });
    }
    localLib.write(list);
  },
  ingest(lineItems) { (lineItems || []).forEach((li) => localLib.upsert(li)); },
};

function ensureLocalSeed() {
  if (HAS_WP) return;
  if (localLib.read().length) return;
  localLib.write(SEED_CATALOG.map((c) => ({ mkey: normName(c.name), sku: c.sku, name: c.name, category: c.category, unit: c.unit, price: c.price, source: c.source || 'catalog', last_synced: c.last_synced || nowISO(), times_seen: 1, projects: 1 })));
}

async function suggestMaterials(q, limit = 8) {
  if (HAS_WP) {
    try { const r = await api('materials/suggest?q=' + encodeURIComponent(q || '') + '&limit=' + limit); return (r && r.materials) || []; } catch (e) { return []; }
  }
  ensureLocalSeed();
  const ql = (q || '').toLowerCase().trim();
  let list = localLib.read();
  if (ql) {
    list = list.filter((m) => m.name.toLowerCase().includes(ql) || (m.sku || '').toLowerCase().includes(ql) || (m.category || '').toLowerCase().includes(ql))
      .sort((a, b) => ((a.name.toLowerCase().startsWith(ql) ? 0 : 1) - (b.name.toLowerCase().startsWith(ql) ? 0 : 1)) || ((b.times_seen || 0) - (a.times_seen || 0)));
  } else {
    list = [...list].sort((a, b) => (b.times_seen || 0) - (a.times_seen || 0));
  }
  return list.slice(0, limit);
}

async function priceItemsLive(items) {
  if (!HAS_WP) return null;
  try { const r = await api('pricing/batch', { method: 'POST', body: { items } }); return (r && r.prices) || null; } catch (e) { return null; }
}

async function fetchLibrary() {
  if (HAS_WP) { try { return await api('materials'); } catch (e) { return { materials: [], stats: {} }; } }
  ensureLocalSeed();
  const materials = localLib.read();
  const cats = new Set(materials.map((m) => m.category));
  return { materials, stats: { total: materials.length, categories: cats.size, observations: materials.reduce((s, m) => s + (m.times_seen || 0), 0), updated_at: nowISO() } };
}

async function saveLibraryMaterial(m) {
  if (HAS_WP) { try { return await api('materials', { method: 'POST', body: m }); } catch (e) { return null; } }
  localLib.upsert(m); return { material: m };
}

/* MaterialSuggestInput — intelligent, predictive typeahead over the library. */
function MaterialSuggestInput({ value, onChange, onPick, placeholder, className = '' }) {
  const [open, setOpen] = useState(false);
  const [list, setList] = useState([]);
  const [hi, setHi] = useState(-1);
  const [loading, setLoading] = useState(false);
  const boxRef = useRef(null);
  const tRef = useRef(null);

  useEffect(() => {
    function onDoc(e) { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); }
    document.addEventListener('mousedown', onDoc);
    return () => { document.removeEventListener('mousedown', onDoc); clearTimeout(tRef.current); };
  }, []);

  const query = useCallback((q) => {
    clearTimeout(tRef.current);
    tRef.current = setTimeout(async () => {
      setLoading(true);
      const r = await suggestMaterials(q, 8);
      setList(r); setLoading(false); setOpen(true); setHi(-1);
    }, 170);
  }, []);

  function choose(m) { onPick(m); setOpen(false); setHi(-1); }
  function onKey(e) {
    if (!open) { if (e.key === 'ArrowDown') query(value || ''); return; }
    if (e.key === 'ArrowDown') { e.preventDefault(); setHi((h) => Math.min(h + 1, list.length - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setHi((h) => Math.max(h - 1, 0)); }
    else if (e.key === 'Enter') { if (hi >= 0 && list[hi]) { e.preventDefault(); choose(list[hi]); } }
    else if (e.key === 'Escape') { setOpen(false); }
  }

  return (
    <div ref={boxRef} className={`relative ${className}`}>
      <input type="text" value={value} placeholder={placeholder || 'Material name…'} autoComplete="off" spellCheck="false"
        onChange={(e) => { onChange(e.target.value); query(e.target.value); }}
        onFocus={() => { if ((value || '').length) query(value); }} onKeyDown={onKey}
        className="w-full rounded-[10px] border border-zinc-800 bg-black px-2.5 py-1.5 text-sm text-white outline-none transition focus:border-white/40" />
      {open && (list.length > 0 || loading) && (
        <div className="absolute z-30 mt-1 max-h-64 w-full min-w-[260px] overflow-auto rounded-[14px] border border-zinc-700 bg-zinc-950/95 p-1 shadow-2xl ces-glow">
          <div className="flex items-center gap-1.5 px-2 py-1 text-[9px] uppercase tracking-[0.16em] text-zinc-600">
            <Cube /> predictive · {loading ? 'searching…' : `${list.length} match${list.length === 1 ? '' : 'es'}`}
          </div>
          {list.map((m, i) => {
            const b = sourceBadge(m.source);
            return (
              <button type="button" key={m.mkey + i} onMouseEnter={() => setHi(i)} onClick={() => choose(m)}
                className={`flex w-full items-center gap-2 rounded-[10px] px-2 py-1.5 text-left transition ${i === hi ? 'bg-white/10' : 'hover:bg-white/5'}`}>
                <div className="min-w-0 flex-1">
                  <div className="truncate text-[13px] font-medium text-white">{m.name}</div>
                  <div className="ces-mono text-[10px] text-zinc-500">{m.category} · {m.unit} · seen ×{m.times_seen || 1}</div>
                </div>
                <span className={`shrink-0 rounded-full border px-1.5 py-0.5 text-[9px] ${b.cls}`}>{b.label}</span>
                <span className="shrink-0 ces-mono text-[12px] font-bold text-white">{fmt(m.price)}</span>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

function sourceBadge(src = '') {
  const s = src.toLowerCase();
  if (s.includes('home')) return { label: 'Home Depot', cls: 'border-orange-500/40 text-orange-300' };
  if (s.includes('lowe')) return { label: "Lowe's", cls: 'border-blue-500/40 text-blue-300' };
  if (s.includes('fallback')) return { label: 'Catalog (fallback)', cls: 'border-amber-500/40 text-amber-300' };
  if (s.includes('catalog')) return { label: 'Catalog', cls: 'border-zinc-600 text-zinc-400' };
  return { label: src || 'live', cls: 'border-emerald-500/40 text-emerald-300' };
}

function PricingTable({ items, onEdit, onPick, onAdd, onRemove, onSync, syncing }) {
  return (
    <Card className="p-5">
      <div className="mb-4 flex items-center gap-2">
        <Cube className="text-lg" />
        <h3 className="text-sm font-bold uppercase tracking-[0.2em] text-white">Material Ledger · Real-Time Pricing</h3>
        <button onClick={onSync} disabled={syncing}
          className="relative ml-auto overflow-hidden rounded-[14px] border border-white/30 bg-white/5 px-3 py-1.5 text-[11px] font-semibold text-white transition hover:bg-white/10 disabled:opacity-40">
          {syncing && <Shimmer />}
          <span className="relative">{syncing ? 'syncing…' : '↻ Sync live prices'}</span>
        </button>
      </div>
      <div className="overflow-x-auto">
        <table className="w-full min-w-[780px] text-left text-sm">
          <thead>
            <tr className="border-b border-zinc-800 text-[10px] uppercase tracking-[0.14em] text-zinc-600">
              <th className="py-2 pr-3">Material</th>
              <th className="py-2 pr-3">Phase</th>
              <th className="py-2 pr-3">Source</th>
              <th className="py-2 pr-3">Synced</th>
              <th className="py-2 pr-3 text-right">Qty</th>
              <th className="py-2 pr-3 text-right">Unit $</th>
              <th className="py-2 pr-3 text-right">Line $</th>
              <th className="py-2"></th>
            </tr>
          </thead>
          <tbody>
            {items.map((it, i) => {
              const b = sourceBadge(it.source);
              const age = Math.floor((Date.now() - new Date(it.last_synced).getTime()) / 86400000);
              const stale = age > 30;
              return (
                <tr key={(it.sku || 'row') + i} className="border-b border-zinc-900/80 align-top">
                  <td className="py-2.5 pr-3" style={{ minWidth: 220 }}>
                    <MaterialSuggestInput value={it.name} onChange={(v) => onEdit(i, { name: v })} onPick={(m) => onPick(i, m)} placeholder="Search or type a material…" />
                    <div className="ces-mono mt-1 text-[10px] text-zinc-600">{it.sku || 'custom'} · {it.note || 'line item'}</div>
                  </td>
                  <td className="py-2.5 pr-3">
                    <select value={PHASE_ORDER.includes(it.category) ? it.category : ''} onChange={(e) => onEdit(i, { category: e.target.value })}
                      className="rounded-[10px] border border-zinc-800 bg-black px-2 py-1 text-[11px] text-zinc-200 outline-none focus:border-white/40">
                      <option value="">— phase —</option>
                      {PHASE_ORDER.map((p) => <option key={p} value={p}>{p}</option>)}
                    </select>
                  </td>
                  <td className="py-2.5 pr-3">
                    <span className={`inline-block rounded-full border px-2 py-0.5 text-[10px] ${b.cls}`}>{b.label}</span>
                  </td>
                  <td className="py-2.5 pr-3">
                    <span className={`ces-mono text-[11px] ${stale ? 'text-amber-400' : 'text-zinc-500'}`}>
                      {isNaN(age) ? '—' : age <= 0 ? 'today' : `${age}d`}{stale ? ' ⚠' : ''}
                    </span>
                  </td>
                  <td className="py-2.5 pr-3 text-right">
                    <input type="number" step="0.01" min="0" value={it.qty}
                      onChange={(e) => onEdit(i, { qty: parseFloat(e.target.value) || 0 })}
                      className="w-16 rounded-[10px] border border-zinc-800 bg-black px-2 py-1 text-right ces-mono text-xs text-white outline-none focus:border-white/40" />
                    <div className="mt-0.5 text-[9px] text-zinc-600">{it.unit}</div>
                  </td>
                  <td className="py-2.5 pr-3 text-right">
                    <input type="number" step="0.01" min="0" value={it.price}
                      onChange={(e) => onEdit(i, { price: parseFloat(e.target.value) || 0 })}
                      className="w-20 rounded-[10px] border border-zinc-800 bg-black px-2 py-1 text-right ces-mono text-xs text-white outline-none focus:border-white/40" />
                  </td>
                  <td className="py-2.5 pr-3 text-right ces-mono font-bold text-white">{fmt(it.total)}</td>
                  <td className="py-2.5 text-right">
                    <button onClick={() => onRemove(i)} title="Remove material"
                      className="rounded-[10px] border border-zinc-800 px-2 py-1 text-xs text-zinc-500 transition hover:border-red-500/50 hover:text-red-400">✕</button>
                  </td>
                </tr>
              );
            })}
            {items.length === 0 && (
              <tr><td colSpan="8" className="py-6 text-center text-[12px] text-zinc-600">No materials yet — add one below or re-run the estimate.</td></tr>
            )}
          </tbody>
        </table>
      </div>
      <div className="mt-3 flex flex-wrap items-center gap-3">
        <button onClick={onAdd}
          className="rounded-[14px] border border-white/30 bg-white/5 px-3 py-1.5 text-[12px] font-semibold text-white transition hover:bg-white/10">＋ Add material 🧊</button>
        <span className="text-[11px] text-zinc-600">Start typing for predictive matches from your <span className="text-zinc-400">Material Library</span> (past projects). New names are stored to the library and price-synced the same way.</span>
      </div>
      <p className="mt-3 text-[11px] text-zinc-600">
        Prices carry their retail <span className="text-zinc-400">source</span> and a <span className="text-zinc-400">last-synced</span> age (network metric). Live Home Depot / Lowe's pricing activates when a provider key is configured in <span className="text-zinc-400">Settings → Pricing</span>; otherwise the maintained 2026 catalog baseline is used. Edit any field to override.
      </p>
    </Card>
  );
}

/* ----------------------------------------------------------------------------
 * 9. LEDGER BAR — cryptographic estimate metrics (hash-chain, timestamp, ver)
 * ------------------------------------------------------------------------- */
function LedgerBar({ ledger }) {
  return (
    <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
      <StatPill label="Estimate Hash" value={shortHash(ledger.hash)} />
      <StatPill label="Chain Height" value={`#${ledger.height}`} />
      <StatPill label="Timestamp (UTC)" value={ledger.ts ? new Date(ledger.ts).toISOString().slice(0, 19).replace('T', ' ') : '—'} />
      <StatPill label="Integrity" value={ledger.verified ? '✓ verified' : 'pending'} />
    </div>
  );
}

/* ----------------------------------------------------------------------------
 * 10. PROPOSAL PREVIEW — on-screen Ciano-navy rendition of the gold standard.
 *     Mirrors the exact section order the PDF generator emits.
 * ------------------------------------------------------------------------- */
function ProposalPreview({ estimate, settings }) {
  const co = settings.company || {};
  const t = estimate.totals;
  const dateStr = new Date(estimate.meta.generatedAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
  return (
    <div className="mx-auto w-full max-w-[850px] rounded-[8px] bg-white p-7 text-[13px] leading-snug shadow-2xl" style={{ color: CIANO.ink }}>
      {/* Letterhead */}
      <div className="flex items-start justify-between gap-4 border-b-2 pb-3" style={{ borderColor: CIANO.navy }}>
        <div className="flex items-start gap-3">
          <div className="flex h-16 w-16 items-center justify-center rounded-md text-white" style={{ background: CIANO.navy }}>
            <span className="text-3xl">🦁</span>
          </div>
          <div>
            <div className="text-base font-extrabold" style={{ color: CIANO.navy }}>{co.name || "CIANO'S TILE & CUSTOM FINISHES"}</div>
            <div className="text-[11px]" style={{ color: CIANO.navy }}>
              {(co.address || '5680 HALIFAX AVE').toUpperCase()}<br />
              {(co.city || 'FORT MYERS, FL 33912').toUpperCase()}<br />
              Office {co.phone || '239.267.8453'}<br />
              Fax {co.fax || '239.267.6720'}<br />
              {(co.website || 'THECIANOCOMPANY.COM').toUpperCase()}
            </div>
          </div>
        </div>
        <div className="text-right">
          <div className="text-4xl font-extrabold" style={{ color: CIANO.navy }}>Proposal</div>
          <div className="mt-3 text-base">
            <span className="font-bold" style={{ color: CIANO.navy }}>Date:&nbsp;&nbsp;</span>
            <span className="font-bold" style={{ color: CIANO.navy }}>{dateStr}</span>
          </div>
        </div>
      </div>

      {/* TO / JOB */}
      <div className="mt-3 grid grid-cols-2 gap-4">
        <div>
          <span className="text-[11px] font-bold" style={{ color: CIANO.navy }}>TO:</span>
          <div className="mt-1 pl-2">
            {estimate.job.clientName || 'Client Name'}<br />
            {estimate.job.address || 'Job Site Address'}<br />
            {estimate.job.cityStateZip || ''}
          </div>
          <div className="mt-2 pl-0">
            <span className="text-[11px] font-bold" style={{ color: CIANO.navy }}>Phone:&nbsp;</span>{estimate.job.clientPhone || '—'}<br />
            <span className="text-[11px] font-bold" style={{ color: CIANO.navy }}>Email:&nbsp;</span>{estimate.job.clientEmail || '—'}
          </div>
        </div>
        <div>
          <span className="text-[11px] font-bold" style={{ color: CIANO.navy }}>JOB:&nbsp;</span>{estimate.job.jobName || 'Remodel'}
        </div>
      </div>

      {/* Sales rep row */}
      <table className="mt-3 w-full border-collapse text-center text-[12px]">
        <tbody>
          <tr>
            {['SALES REP', 'SALE REP CONTACT:', 'SALES REP EMAIL:'].map((h, i) => (
              <td key={i} className="border p-1.5 font-bold" style={{ borderColor: CIANO.navy, color: CIANO.navy }}>{h}</td>
            ))}
          </tr>
          <tr>
            <td className="border p-1.5" style={{ borderColor: CIANO.navy }}>{co.rep_name || 'Paul Ciano'}</td>
            <td className="border p-1.5" style={{ borderColor: CIANO.navy }}>{co.rep_phone || '239-980-0100'}</td>
            <td className="border p-1.5" style={{ borderColor: CIANO.navy }}>{co.rep_email || 'paul@thecianocompany.com'}</td>
          </tr>
        </tbody>
      </table>

      {/* Spec table */}
      <table className="mt-3 w-full border-collapse text-[12px]">
        <thead>
          <tr className="text-white" style={{ background: CIANO.navy }}>
            <th className="border p-1.5 text-center text-[11px]" style={{ borderColor: CIANO.maroon, width: '14%' }}>AREA</th>
            <th className="border p-1.5 text-center text-[11px]" style={{ borderColor: CIANO.maroon }}>WE HEREBY SUBMIT SPECIFICATION AND ESTIMATES FOR:</th>
            <th className="border p-1.5 text-center text-[11px]" style={{ borderColor: CIANO.maroon, width: '16%' }}>AMOUNT</th>
          </tr>
        </thead>
        <tbody>
          {estimate.phases.map((p) => (
            <tr key={p.phase} className="align-top">
              <td className="border p-1.5 text-center font-medium" style={{ borderColor: CIANO.maroon }}>{p.phase}</td>
              <td className="border p-1.5" style={{ borderColor: CIANO.maroon }}>
                {highlightNarrative(p.narrative)}
              </td>
              <td className="border p-1.5 text-right ces-mono" style={{ borderColor: CIANO.maroon }}>
                {p.phase === 'Permits & Architectural' ? '—' : fmt(p.total)}
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* Total */}
      <table className="mt-3 w-full border-collapse text-[12px]">
        <tbody>
          <tr>
            <td className="border p-2" style={{ borderColor: CIANO.maroon, color: CIANO.navy }}>
              We propose, hereby to furnish labor and/or materials complete in accordance with above specifications.<br />
              <span className="italic">For the lump sum total of {amountToWords(t.grandTotal)}</span>
            </td>
            <td className="border p-2 text-right text-lg font-extrabold" style={{ borderColor: CIANO.maroon, color: CIANO.navy, width: '22%' }}>{fmt(t.grandTotal)}</td>
          </tr>
          <tr>
            <td className="border p-2" style={{ borderColor: CIANO.maroon }} colSpan={2}>
              <span className="font-bold" style={{ color: CIANO.navy }}>PAYMENT TERMS:&nbsp;&nbsp;</span>
              {settings.payment_terms || '50% Deposit, draws on work complete and Balance at Completion of Job'}
            </td>
          </tr>
        </tbody>
      </table>

      {/* Terms */}
      <p className="mt-3 text-justify text-[8.5px] leading-tight text-zinc-700">
        <span className="font-bold">TERMS AND CONDITIONS OF THIS AGREEMENT:</span> All Material is guaranteed to be as specified. All work to be completed in a workmanlike manner according to standard practices. Any alteration or deviation from the above specifications involving extra costs will be executed only upon written orders, and will become an extra charge over and above this proposal amount. All agreements are contingent upon strike, accidents or delays beyond our control. Owner to carry fire, tornado and other necessary insurance. All balances not paid in accordance with this agreement are subject to an 18% interest rate. If payment is not received in full, then all warranties become null and void.
      </p>
      <p className="mt-2 text-justify text-[8.5px] leading-tight text-zinc-700">
        <span className="font-bold">ACCEPTANCE OF PROPOSAL:</span> The above prices, specifications and conditions are satisfactory and are hereby accepted. You are authorized to do the work as specified. Payment will be made as outlined in the payment terms above.
      </p>
      <div className="mt-3 text-[10px]">Accepted by: ________________________________________ &nbsp;&nbsp; Date: __________________</div>

      {/* Footer banner */}
      <div className="mt-4 rounded-sm py-2 text-center text-base font-light tracking-wide text-white"
        style={{ background: `linear-gradient(180deg, ${CIANO.navySteel}, ${CIANO.navyDark})` }}>
        Thank You for Your Business!
      </div>
    </div>
  );
}

/* Re-create the red emphasis the gold standard uses on owner-responsibility clauses */
function highlightNarrative(text) {
  if (!text) return null;
  const triggers = ['Owner responsible to move all breakables in areas that need work done.', 'NO PERMIT OR ARCHITECTURAL INCLUDED IN PROJECT.'];
  for (const tr of triggers) {
    const idx = text.indexOf(tr);
    if (idx !== -1) {
      return (<>{text.slice(0, idx)}<span style={{ color: CIANO.red }}>{tr}</span>{text.slice(idx + tr.length)}</>);
    }
  }
  return text;
}

/* ----------------------------------------------------------------------------
 * 11. APP — the full-canvas estimation workspace
 * ------------------------------------------------------------------------- */
const DEFAULT_SETTINGS = {
  markup_pct: 18, tax_pct: 6.5, pricing_provider: '',
  payment_terms: '50% Deposit, draws on work complete and Balance at Completion of Job',
  company: {
    name: "CIANO'S TILE & CUSTOM FINISHES", address: '5680 Halifax Ave', city: 'Fort Myers, FL 33912',
    phone: '239.267.8453', fax: '239.267.6720', website: 'TheCianoCompany.com',
    rep_name: 'Paul Ciano', rep_phone: '239-980-0100', rep_email: 'paul@thecianocompany.com',
  },
  vision: { enabled: false, provider: 'custom' },
};

const STEPS = [
  { key: 'client',   glyph: '🧊', title: 'Client & Job' },
  { key: 'photos',   glyph: '🧊', title: 'Job Site Photos' },
  { key: 'measure',  glyph: '🧊', title: 'Measurements' },
  { key: 'scope',    glyph: '🧊', title: 'Scope' },
  { key: 'engine',   glyph: '🧊', title: 'AgentOS Engine' },
  { key: 'proposal', glyph: '🧊', title: 'Proposal & PDF' },
];

function App() {
  const [settings, setSettings] = useState(CES.settings || DEFAULT_SETTINGS);
  const [step, setStep] = useState(0);
  const [job, setJob] = useState({ jobName: '', clientName: '', clientPhone: '', clientEmail: '', address: '', cityStateZip: '' });
  const [measure, setMeasure] = useState({
    masterBathSqft: 90, guestBathSqft: 55, showerWallSqft: 130, showerFloorSqft: 16,
    upstairsSqft: 720, upstairsPerimFt: 180, stairCount: 14, ceilingHt: 9,
    sinkCount: 4, toiletCount: 2, quartzSqft: 38, van27: 3, van30: 1, van18: 2, pullCount: 18, tubFiller: 1,
    // v0.0.4 modular areas / counts (0 = excluded from the proposal; only measured fields count)
    floorTileSqft: 0, wallTileSqft: 0, backsplashSqft: 0, poolTileSqft: 0,
    paverSqft: 0, slabSqft: 0, stoneSqft: 0, hardwoodSqft: 0, carpetSqft: 0,
    counterSqft: 0, drywallSqft: 0, ceilingSqft: 0,
    canLightCount: 8, outletCount: 6, switchCount: 6, ucLedFt: 12,
    diffuserCount: 2, dryerVent: 0, doorCount: 2, frenchDoorCount: 0, dumpLoads: 1,
  });
  // Modular default: only universal prep is ON; every trade is opt-in.
  const [scope, setScope] = useState(Object.fromEntries(PHASE_ORDER.map(p => [p, DEFAULT_ON.includes(p)])));
  const [photos, setPhotos] = useState([]);
  const [measureSource, setMeasureSource] = useState({});
  const [pipeline, setPipeline] = useState({});
  const [running, setRunning] = useState(false);
  const [estimate, setEstimate] = useState(null);
  const [syncing, setSyncing] = useState(false);
  const [ledger, setLedger] = useState({ hash: '', height: 0, ts: '', verified: false });
  const [toast, setToast] = useState(null);
  // v0.0.6 — editing an existing estimate (vs. a fresh one). When set, Save
  // updates that record in place; "Save as new" clears it to fork a copy.
  const [editUid, setEditUid] = useState(null);
  const [editMeta, setEditMeta] = useState(null); // { jobName, updatedAt, asTemplate }
  const [storing, setStoring] = useState(false);
  const [carouselTick, setCarouselTick] = useState(0); // bump to refresh the proposal carousel
  // v0.0.7 — operator profit margin (admins/editors), applied to at-cost pricing
  // and folded into the grand total only; never printed on the proposal.
  const [marginPct, setMarginPct] = useState(() => {
    const s = CES.settings || DEFAULT_SETTINGS;
    return (s.estimate && s.estimate.margin_pct != null) ? Number(s.estimate.margin_pct) : (s.margin_pct != null ? Number(s.margin_pct) : 0);
  });
  const proposalRef = useRef(null);

  const flash = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2600); };

  // Pull persisted settings/ledger height from WP on mount
  useEffect(() => {
    if (!HAS_WP) return;
    api('settings').then(s => s && setSettings(prev => ({ ...prev, ...s }))).catch(() => {});
    api('ledger/head').then(h => h && setLedger(l => ({ ...l, height: h.height || 0 }))).catch(() => {});
  }, []);

  /* v0.0.6 — hydrate the whole wizard from a saved estimate payload. Used by
   * the Estimates console deep-link (?ces_estimate / ?ces_template) and by the
   * frontend proposal carousel. `asTemplate` forks a brand-new proposal (no
   * uid) so re-saving never overwrites the original. */
  const loadEstimateIntoApp = useCallback((rec, asTemplate) => {
    if (!rec || !rec.payload) { flash('Could not load that proposal'); return; }
    const p = rec.payload;
    if (p.job) setJob(j => ({ ...j, ...p.job }));
    if (p.measure) setMeasure(m => ({ ...m, ...p.measure }));
    if (Array.isArray(p.scope)) setScope(Object.fromEntries(PHASE_ORDER.map(ph => [ph, p.scope.includes(ph)])));
    else if (p.scope && typeof p.scope === 'object') setScope(s => ({ ...s, ...p.scope }));
    setPhotos(Array.isArray(p.photos) ? p.photos : []);
    if (p.totals && p.totals.marginPct != null) setMarginPct(Number(p.totals.marginPct));
    setMeasureSource({});
    // Restore the computed estimate so the proposal + PDF are instantly ready.
    const restored = { ...p, uid: asTemplate ? undefined : (rec.uid || p.uid) };
    setEstimate(restored);
    if (restored.meta && restored.meta.confidence != null) {
      setPipeline(Object.fromEntries(['analyst', 'planner', 'architect', 'orchestrator', 'integration', 'monitor', 'feedback'].map(k => [k, { status: 'done', detail: asTemplate ? 'loaded from template' : 'loaded for editing' }])));
    }
    if (asTemplate) { setEditUid(null); setEditMeta({ jobName: (p.job && p.job.jobName) || '', asTemplate: true, from: rec.uid }); }
    else { setEditUid(rec.uid || null); setEditMeta({ jobName: (p.job && p.job.jobName) || '', updatedAt: rec.updated_at || '', asTemplate: false }); }
    setStep(5);
    if (typeof window !== 'undefined' && window.scrollTo) { try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { window.scrollTo(0, 0); } }
    flash(asTemplate ? '🧊 Loaded as template — tweak & save as a new proposal' : '🧊 Loaded for editing');
  }, []);

  // Deep-link: open an estimate the moment the calculator mounts.
  useEffect(() => {
    if (!HAS_WP || !CES.load || !CES.load.uid) return;
    let alive = true;
    apiGetEstimate(CES.load.uid).then(rec => { if (alive && rec) loadEstimateIntoApp(rec, CES.load.mode === 'template'); });
    return () => { alive = false; };
  }, [loadEstimateIntoApp]);

  function startFresh() {
    setEditUid(null); setEditMeta(null); setEstimate(null); setPipeline({});
    setPhotos([]); setMeasureSource({}); setStep(0);
    setJob({ jobName: '', clientName: '', clientPhone: '', clientEmail: '', address: '', cityStateZip: '' });
    flash('🧊 New blank proposal');
  }

  const recomputeLedger = useCallback(async (est) => {
    const canonical = JSON.stringify({ job: est.job, totals: est.totals, scope: est.scope, ts: est.meta.generatedAt });
    const hash = await sha256Hex(canonical);
    setLedger(l => ({ hash, height: (l.height || 0) + 1, ts: est.meta.generatedAt, verified: true }));
    return hash;
  }, []);

  async function handleRun() {
    setRunning(true);
    setPipeline({});
    setStep(4);
    const est = await runEstimate({
      job, measure, scope, settings, photos, marginPct,
      onStage: ({ id, status, detail }) => setPipeline(p => ({ ...p, [id]: { status, detail } })),
    });
    setEstimate(est);
    await recomputeLedger(est);
    setRunning(false);
    flash('🧊 Estimate orchestrated · ledger sealed');
  }

  // v0.0.7 — operator changes the profit margin in the Engine step: recompute
  // the live estimate's totals (hidden margin → markup → tax → grand) and
  // re-seal the ledger so the stored hash matches what the PDF will print.
  function applyMarginPct(next) {
    const v = Math.max(0, Math.min(90, Number(next) || 0));
    setMarginPct(v);
    setEstimate(prev => {
      if (!prev) return prev;
      const rebuilt = rebuildTotals(prev, v);
      recomputeLedger(rebuilt);
      return rebuilt;
    });
  }

  /* Vision agent → Measurements: merge the confidence-weighted proposed values
   * into the measure state, record provenance per field, and advance the wizard
   * so the operator lands on a pre-filled sheet ready to tweak. */
  function applyVisionToMeasurements(proposed, sources) {
    if (!proposed || !Object.keys(proposed).length) { flash('No measurements to apply yet'); return; }
    setMeasure(m => ({ ...m, ...proposed }));
    setMeasureSource(s => ({ ...s, ...sources }));
    flash(`🧊 ${Object.keys(proposed).length} measurements pre-filled from photos`);
    setStep(2);
  }

  function editLineItem(index, patch) {
    setEstimate(prev => {
      if (!prev) return prev;
      const lineItems = prev.lineItems.map((li, i) => {
        if (i !== index) return li;
        const next = { ...li, ...patch };
        if ('price' in patch) { next.source = 'manual override'; next.last_synced = nowISO(); }
        next.total = round2((next.qty || 0) * (next.price || 0));
        return next;
      });
      return rebuildTotals({ ...prev, lineItems });
    });
    setPipeline(p => ({ ...p, feedback: { status: 'done', detail: 'operator edit applied' } }));
  }

  function pickMaterial(index, m) {
    setEstimate(prev => {
      if (!prev) return prev;
      const lineItems = prev.lineItems.map((li, i) => {
        if (i !== index) return li;
        const cat = PHASE_ORDER.includes(m.category) ? m.category : (li.category || PHASE_ORDER[0]);
        const price = (typeof m.price === 'number' && m.price > 0) ? m.price : li.price;
        const next = { ...li, sku: m.sku || li.sku, name: m.name, unit: m.unit || li.unit, category: cat, price, source: m.source || 'catalog', last_synced: m.last_synced || nowISO(), note: 'from library' };
        next.total = round2((next.qty || 0) * (next.price || 0));
        return next;
      });
      return rebuildTotals({ ...prev, lineItems });
    });
    flash('🧊 Pulled from Material Library');
  }

  function addLineItem() {
    setEstimate(prev => {
      if (!prev) return prev;
      const li = { sku: 'custom-' + Date.now().toString(36), name: '', note: 'manual', qty: 1, unit: 'ea', price: 0, total: 0, source: 'manual', last_synced: nowISO(), category: PHASE_ORDER[0] };
      return rebuildTotals({ ...prev, lineItems: [...prev.lineItems, li] });
    });
  }

  function removeLineItem(index) {
    setEstimate(prev => prev ? rebuildTotals({ ...prev, lineItems: prev.lineItems.filter((_, i) => i !== index) }) : prev);
  }

  function rebuildTotals(prev, marginOverride) {
    const materialTotal = round2(prev.lineItems.reduce((s, m) => s + m.total, 0));
    const laborTotal = prev.totals.laborTotal;
    const baseSubtotal = round2(materialTotal + laborTotal);
    const marginPct = (marginOverride != null) ? marginOverride : (prev.totals.marginPct || 0);
    const withMargin = applyMargin(baseSubtotal, marginPct);
    const withMarkup = applyMarkup(withMargin, prev.totals.markupPct);
    const tax = round2(withMarkup * (prev.totals.taxPct / 100));
    const grandTotal = round2(withMarkup + tax);
    const phases = prev.phases.map(ph => {
      const mat = prev.lineItems.filter(li => li.category === ph.phase);
      const materialTotalP = round2(mat.reduce((s, m) => s + m.total, 0));
      return { ...ph, materials: mat, materialTotal: materialTotalP, total: round2(materialTotalP + ph.laborTotal) };
    });
    return { ...prev, phases, totals: { ...prev.totals, materialTotal, baseSubtotal, marginPct, withMargin, withMarkup, tax, grandTotal } };
  }

  async function handleSync() {
    setSyncing(true);
    setPipeline(p => ({ ...p, integration: { status: 'running' } }));
    try {
      if (HAS_WP && estimate) {
        const items = estimate.lineItems.map(li => ({ sku: li.sku, name: li.name, category: li.category, unit: li.unit, price: li.price, project: job.jobName }));
        const live = await priceItemsLive(items);
        if (live) {
          setEstimate(prev => rebuildTotals({
            ...prev,
            lineItems: prev.lineItems.map(li => {
              const p = live[li.sku];
              return (p && typeof p.price === 'number' && p.price > 0)
                ? { ...li, price: p.price, source: p.source || 'live', last_synced: p.last_synced || nowISO(), total: round2(li.qty * p.price) }
                : li;
            }),
          }));
          flash('🧊 Live prices synced');
        } else { flash('No live provider configured — using catalog'); }
      } else {
        await sleep(700);
        flash('Previewer mode — configure a provider in WordPress for live sync');
      }
    } finally {
      setSyncing(false);
      setPipeline(p => ({ ...p, integration: { status: 'done', detail: 'sync complete' } }));
    }
  }

  // Returns the saved uid (so callers can chain a PDF store), or null.
  async function persistEstimate(forkNew) {
    if (!estimate) return null;
    const hash = await recomputeLedger(estimate);
    const useUid = forkNew ? undefined : (editUid || estimate.uid || undefined);
    if (HAS_WP) {
      try {
        const r = await api('estimates', { method: 'POST', body: { ...estimate, uid: useUid, ledger: { hash, height: ledger.height + 1 } } });
        const newUid = (r && r.uid) || useUid || null;
        if (newUid) { setEditUid(newUid); setEstimate(prev => prev ? { ...prev, uid: newUid } : prev); setEditMeta({ jobName: job.jobName, updatedAt: (r && r.updated_at) || '', asTemplate: false }); }
        setCarouselTick(t => t + 1);
        flash((useUid ? '🧊 Updated' : '🧊 Saved') + ' · ' + shortHash(hash));
        return newUid;
      } catch (e) { flash('Save failed: ' + e.message); return null; }
    } else {
      try {
        const all = JSON.parse(localStorage.getItem('ces_estimates') || '[]');
        const uid = useUid || ('local-' + Date.now().toString(36));
        const rec = { ...estimate, uid, ledger: { hash, height: ledger.height + 1 } };
        const idx = all.findIndex(x => x.uid === uid);
        if (idx >= 0) all[idx] = rec; else all.unshift(rec);
        localStorage.setItem('ces_estimates', JSON.stringify(all.slice(0, 40)));
        localLib.ingest(estimate.lineItems);
        setEditUid(uid); setEstimate(prev => prev ? { ...prev, uid } : prev); setCarouselTick(t => t + 1);
        flash('🧊 Saved locally · ' + shortHash(hash));
        return uid;
      } catch (e) { flash('Local save unavailable'); return null; }
    }
  }
  async function handleSave() { await persistEstimate(false); }
  async function handleSaveAsNew() { setEditUid(null); setEditMeta(null); await persistEstimate(true); }

  // Save, then render the proposal PDF and store it server-side as a stamped
  // attachment (so it can be previewed/downloaded later and flagged stale if
  // the estimate changes).
  async function handleSaveAndStorePDF() {
    if (!estimate) return;
    if (!HAS_WP) { flash('PDF storage needs WordPress — use ⬇ Generate PDF in the previewer'); return; }
    if (typeof window.cesGenerateProposalPDF !== 'function') { flash('PDF engine not loaded'); return; }
    setStoring(true);
    try {
      const uid = await persistEstimate(false);
      if (!uid) { setStoring(false); return; }
      const dataUri = window.cesGenerateProposalPDF(estimate, settings, { CIANO, amountToWords, fmt, output: 'datauri' });
      if (!dataUri) { flash('Could not render PDF'); setStoring(false); return; }
      const r = await apiStorePDF(uid, dataUri, job.jobName);
      // Also persist the job-site photos to the media library for recall.
      let photoNote = '';
      const phs = (estimate.photos || []).filter(p => p && p.dataUri && /^data:image\//.test(p.dataUri));
      if (phs.length) {
        const pr = await apiStorePhotos(uid, phs);
        if (pr && pr.stored && pr.count) photoNote = ' · ' + pr.count + ' photo' + (pr.count === 1 ? '' : 's') + ' in media library';
      }
      flash(r && r.stored ? '🧊 PDF stored & attached to this estimate' + photoNote : 'PDF store failed');
      setCarouselTick(t => t + 1);
    } catch (e) { flash('Store failed: ' + e.message); }
    finally { setStoring(false); }
  }

  function handlePDF() {
    if (!estimate) return;
    if (typeof window.cesGenerateProposalPDF !== 'function') { flash('PDF engine not loaded'); return; }
    window.cesGenerateProposalPDF(estimate, settings, { CIANO, amountToWords, fmt });
    flash('🧊 Proposal PDF generated');
  }

  const canRun = job.jobName && job.clientName && PHASE_ORDER.some(p => scope[p]);

  return (
    <div className="ces-root min-h-screen w-full bg-black text-white">
      {/* ambient grid + glow */}
      <div className="ces-bg pointer-events-none fixed inset-0" aria-hidden="true" />

      <div className="relative mx-auto max-w-7xl px-4 py-6 md:px-8 md:py-10">
        {/* Header */}
        <header className="mb-6">
          <div className="flex flex-wrap items-center gap-3">
            <div className="relative flex h-12 w-12 items-center justify-center overflow-hidden rounded-[16px] border border-zinc-700 bg-zinc-950">
              <Shimmer /><Cube className="relative text-2xl" />
            </div>
            <div>
              <h1 className="text-xl font-extrabold tracking-tight md:text-2xl">Ciano Estimation Suite</h1>
              <p className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">Spectre AI · AGI AgentOS · thecianocompany.com</p>
            </div>
            <div className="ml-auto hidden items-center gap-2 sm:flex">
              <span className="rounded-full border border-zinc-800 px-3 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-500">v0.0.7</span>
              <span className="rounded-full border border-emerald-500/30 px-3 py-1 text-[10px] uppercase tracking-[0.16em] text-emerald-300">{HAS_WP ? 'WordPress · live' : 'Previewer · local'}</span>
            </div>
          </div>
          <div className="mt-4"><LedgerBar ledger={ledger} /></div>
        </header>

        {/* v0.0.6 — editing / template banner */}
        {(editUid || (editMeta && editMeta.asTemplate)) && (
          <div className={`mb-5 flex flex-wrap items-center gap-2 rounded-[18px] border px-4 py-3 ${editMeta && editMeta.asTemplate ? 'border-violet-500/40 bg-violet-500/10' : 'border-sky-500/40 bg-sky-500/[0.05]'}`}>
            <Cube className="text-sm" />
            {editMeta && editMeta.asTemplate ? (
              <span className="text-[12px] text-violet-300">Working from a <span className="font-bold">template</span>{editMeta.jobName ? ' of "' + editMeta.jobName + '"' : ''} — saving creates a brand-new proposal, leaving the original untouched.</span>
            ) : (
              <span className="text-[12px] text-sky-200">Editing <span className="font-bold">{editMeta && editMeta.jobName ? '"' + editMeta.jobName + '"' : 'a saved proposal'}</span><span className="ces-mono text-sky-300/70"> · {String(editUid).slice(0, 14)}</span> — Save updates it in place.</span>
            )}
            <button onClick={startFresh} className="ml-auto rounded-full border border-zinc-700 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-zinc-300 transition hover:border-white hover:text-white">＋ Start fresh</button>
          </div>
        )}

        {/* Step rail (kanban) */}
        <nav className="mb-6 flex gap-2 overflow-x-auto pb-2">
          {STEPS.map((s, i) => (
            <button key={s.key} onClick={() => setStep(i)}
              className={`relative flex shrink-0 items-center gap-2 overflow-hidden rounded-[22px] border px-4 py-2.5 text-sm font-semibold transition ${step === i ? 'border-white bg-white text-black' : 'border-zinc-800 bg-zinc-950 text-zinc-400 hover:border-zinc-600 hover:text-white'}`}>
              {step === i && <Shimmer />}
              <span className="relative">{s.glyph}</span>
              <span className="relative">{i + 1}. {s.title}</span>
            </button>
          ))}
        </nav>

        {/* STEP 0 — Client & Job */}
        {step === 0 && (
          <Card className="p-6 md:p-8">
            <h2 className="mb-5 text-lg font-bold">🧊 Client & Job</h2>
            <div className="grid gap-4 md:grid-cols-2">
              <Field label="Job Name"><input className={inputCls} value={job.jobName} placeholder="Master bath, Guest bath & Upstairs flooring remodel" onChange={e => setJob({ ...job, jobName: e.target.value })} /></Field>
              <Field label="Client Name"><input className={inputCls} value={job.clientName} placeholder="Bonnie & Dave Lawston" onChange={e => setJob({ ...job, clientName: e.target.value })} /></Field>
              <Field label="Job Site Address"><input className={inputCls} value={job.address} placeholder="936 Golden Pond Court" onChange={e => setJob({ ...job, address: e.target.value })} /></Field>
              <Field label="City / State / ZIP"><input className={inputCls} value={job.cityStateZip} placeholder="Cape Coral, FL 33909" onChange={e => setJob({ ...job, cityStateZip: e.target.value })} /></Field>
              <Field label="Client Phone"><input className={inputCls} value={job.clientPhone} placeholder="519-551-9393" onChange={e => setJob({ ...job, clientPhone: e.target.value })} /></Field>
              <Field label="Client Email"><input className={inputCls} value={job.clientEmail} placeholder="client@email.com" onChange={e => setJob({ ...job, clientEmail: e.target.value })} /></Field>
            </div>
            <div className="mt-6 flex justify-end">
              <PrimaryBtn onClick={() => setStep(1)}>Continue → Job Site Photos</PrimaryBtn>
            </div>
          </Card>
        )}

        {/* STEP 1 — Job Site Photos (Vision Takeoff) */}
        {step === 1 && (
          <div className="space-y-4">
            <VisionTakeoff
              photos={photos} setPhotos={setPhotos} settings={settings}
              onApply={applyVisionToMeasurements} flash={flash}
              visionEnabled={!!(settings && settings.vision && settings.vision.enabled)} />
            <div className="flex justify-between">
              <GhostBtn onClick={() => setStep(0)}>← Back</GhostBtn>
              <PrimaryBtn onClick={() => setStep(2)}>{photos.length ? 'Skip / Continue → Measurements' : 'Continue → Measurements'}</PrimaryBtn>
            </div>
          </div>
        )}

        {/* STEP 2 — Measurements */}
        {step === 2 && (
          <Card className="p-6 md:p-8">
            <h2 className="mb-2 text-lg font-bold">🧊 Measurements</h2>
            <p className="mb-3 text-[12px] text-zinc-500">The Architect agent converts these into quantities with waste factors. Only fields with a value are included &mdash; <span className="text-emerald-300">a field that glows is in the proposal</span>; leave a field at <span className="text-zinc-300">0</span> to exclude it entirely.</p>
            {Object.keys(measureSource).length > 0 && (
              <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[14px] border border-sky-500/30 bg-sky-500/[0.05] px-4 py-2.5">
                <Cube className="text-sm" />
                <span className="text-[11px] text-sky-200">{Object.keys(measureSource).length} field{Object.keys(measureSource).length === 1 ? '' : 's'} pre-filled from job-site photos &mdash; tagged below with method &amp; confidence. <span className="text-amber-300">Amber = verify on site.</span> Tweak any value freely.</span>
                <button onClick={() => setStep(1)} className="ml-auto rounded-full border border-sky-500/40 px-2.5 py-0.5 text-[10px] font-semibold text-sky-300 hover:bg-sky-500/10">↩ photos</button>
              </div>
            )}
            <p className="mb-5 flex items-center gap-1.5 text-[10px] text-emerald-300/80">
              <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-400 ces-dot-glow" aria-hidden="true" />
              Glowing field = included in the laser-focused proposal
            </p>
            <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
              <MeasureField label="Master Bath floor (sq ft)" value={measure.masterBathSqft} source={measureSource.masterBathSqft} onChange={v => setMeasure({ ...measure, masterBathSqft: v })} />
              <MeasureField label="Guest Bath floor (sq ft)" value={measure.guestBathSqft} source={measureSource.guestBathSqft} onChange={v => setMeasure({ ...measure, guestBathSqft: v })} />
              <MeasureField label="Shower wall area (sq ft)" value={measure.showerWallSqft} source={measureSource.showerWallSqft} onChange={v => setMeasure({ ...measure, showerWallSqft: v })} />
              <MeasureField label="Shower floor (sq ft)" value={measure.showerFloorSqft} source={measureSource.showerFloorSqft} onChange={v => setMeasure({ ...measure, showerFloorSqft: v })} />
              <MeasureField label="Upstairs LVP area (sq ft)" value={measure.upstairsSqft} source={measureSource.upstairsSqft} onChange={v => setMeasure({ ...measure, upstairsSqft: v })} />
              <MeasureField label="Upstairs perimeter (ft)" value={measure.upstairsPerimFt} source={measureSource.upstairsPerimFt} onChange={v => setMeasure({ ...measure, upstairsPerimFt: v })} />
              <MeasureField label="Stair count" value={measure.stairCount} source={measureSource.stairCount} onChange={v => setMeasure({ ...measure, stairCount: v })} />
              <MeasureField label="Ceiling height (ft)" value={measure.ceilingHt} source={measureSource.ceilingHt} onChange={v => setMeasure({ ...measure, ceilingHt: v })} />
              <MeasureField label="Sinks" value={measure.sinkCount} source={measureSource.sinkCount} onChange={v => setMeasure({ ...measure, sinkCount: v })} />
              <MeasureField label="Toilets" value={measure.toiletCount} source={measureSource.toiletCount} onChange={v => setMeasure({ ...measure, toiletCount: v })} />
              <MeasureField label="Quartz top area (sq ft)" value={measure.quartzSqft} source={measureSource.quartzSqft} onChange={v => setMeasure({ ...measure, quartzSqft: v })} />
              <MeasureField label="Cabinet pulls" value={measure.pullCount} source={measureSource.pullCount} onChange={v => setMeasure({ ...measure, pullCount: v })} />
            </div>

            {/* v0.0.4 — modular tile & stone areas */}
            <div className="mt-7 mb-2 flex items-center gap-2">
              <Cube className="text-sm" />
              <h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-zinc-300">Tile &amp; Stone areas</h3>
              <span className="text-[10px] text-zinc-600">leave a category at 0 to exclude it from this job</span>
              <div className="ml-2 flex-1 border-t border-zinc-800" />
            </div>
            <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
              <MeasureField label="Floor tile area (sq ft)" value={measure.floorTileSqft} source={measureSource.floorTileSqft} onChange={v => setMeasure({ ...measure, floorTileSqft: v })} />
              <MeasureField label="Wall tile area (sq ft)" value={measure.wallTileSqft} source={measureSource.wallTileSqft} onChange={v => setMeasure({ ...measure, wallTileSqft: v })} />
              <MeasureField label="Backsplash area (sq ft)" value={measure.backsplashSqft} source={measureSource.backsplashSqft} onChange={v => setMeasure({ ...measure, backsplashSqft: v })} />
              <MeasureField label="Pool / waterline tile (sq ft)" value={measure.poolTileSqft} source={measureSource.poolTileSqft} onChange={v => setMeasure({ ...measure, poolTileSqft: v })} />
              <MeasureField label="Porcelain pavers (sq ft)" value={measure.paverSqft} source={measureSource.paverSqft} onChange={v => setMeasure({ ...measure, paverSqft: v })} />
              <MeasureField label="Porcelain slabs (sq ft)" value={measure.slabSqft} source={measureSource.slabSqft} onChange={v => setMeasure({ ...measure, slabSqft: v })} />
              <MeasureField label="Natural stone (sq ft)" value={measure.stoneSqft} source={measureSource.stoneSqft} onChange={v => setMeasure({ ...measure, stoneSqft: v })} />
              <MeasureField label="Standalone countertops (sq ft)" value={measure.counterSqft} source={measureSource.counterSqft} onChange={v => setMeasure({ ...measure, counterSqft: v })} />
            </div>

            {/* v0.0.4 — flooring & trades */}
            <div className="mt-7 mb-2 flex items-center gap-2">
              <Cube className="text-sm" />
              <h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-zinc-300">Flooring &amp; trades</h3>
              <div className="ml-2 flex-1 border-t border-zinc-800" />
            </div>
            <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
              <MeasureField label="Hardwood area (sq ft)" value={measure.hardwoodSqft} source={measureSource.hardwoodSqft} onChange={v => setMeasure({ ...measure, hardwoodSqft: v })} />
              <MeasureField label="Carpet area (sq ft)" value={measure.carpetSqft} source={measureSource.carpetSqft} onChange={v => setMeasure({ ...measure, carpetSqft: v })} />
              <MeasureField label="Drywall area (sq ft)" value={measure.drywallSqft} source={measureSource.drywallSqft} onChange={v => setMeasure({ ...measure, drywallSqft: v })} />
              <MeasureField label="Ceiling grid area (sq ft)" value={measure.ceilingSqft} source={measureSource.ceilingSqft} onChange={v => setMeasure({ ...measure, ceilingSqft: v })} />
              <MeasureField label="LED can lights" value={measure.canLightCount} source={measureSource.canLightCount} onChange={v => setMeasure({ ...measure, canLightCount: v })} />
              <MeasureField label="Outlets" value={measure.outletCount} source={measureSource.outletCount} onChange={v => setMeasure({ ...measure, outletCount: v })} />
              <MeasureField label="Switches" value={measure.switchCount} source={measureSource.switchCount} onChange={v => setMeasure({ ...measure, switchCount: v })} />
              <MeasureField label="HVAC diffusers" value={measure.diffuserCount} source={measureSource.diffuserCount} onChange={v => setMeasure({ ...measure, diffuserCount: v })} />
              <MeasureField label="Interior doors" value={measure.doorCount} source={measureSource.doorCount} onChange={v => setMeasure({ ...measure, doorCount: v })} />
              <MeasureField label="French doors" value={measure.frenchDoorCount} source={measureSource.frenchDoorCount} onChange={v => setMeasure({ ...measure, frenchDoorCount: v })} />
              <MeasureField label="Dump loads" value={measure.dumpLoads} source={measureSource.dumpLoads} onChange={v => setMeasure({ ...measure, dumpLoads: v })} />
            </div>

            <div className="mt-6 flex justify-between">
              <GhostBtn onClick={() => setStep(1)}>← Back</GhostBtn>
              <PrimaryBtn onClick={() => setStep(3)}>Continue → Scope</PrimaryBtn>
            </div>
          </Card>
        )}

        {/* STEP 3 — Scope */}
        {step === 3 && (
          <Card className="p-6 md:p-8">
            <div className="mb-2 flex flex-wrap items-center gap-3">
              <h2 className="text-lg font-bold">🧊 Scope of Work</h2>
              <span className="rounded-full border border-zinc-800 px-3 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
                {PHASE_ORDER.filter(p => scope[p]).length} of {PHASE_ORDER.length} included
              </span>
              <div className="ml-auto flex gap-2">
                <GhostBtn onClick={() => setScope(Object.fromEntries(PHASE_ORDER.map(p => [p, true])))}>Select all</GhostBtn>
                <GhostBtn onClick={() => setScope(Object.fromEntries(PHASE_ORDER.map(p => [p, DEFAULT_ON.includes(p)])))}>Reset</GhostBtn>
              </div>
            </div>
            <p className="mb-5 text-[12px] text-zinc-500">Modular: toggle exactly the trades this job includes — not every tile job gets a new shower. Order within each group mirrors the gold-standard proposal layout.</p>
            <div className="space-y-5">
              {PHASE_GROUPS.map(g => {
                const onCount = g.phases.filter(p => scope[p]).length;
                return (
                  <div key={g.group}>
                    <div className="mb-2 flex items-center gap-2">
                      <Cube className="text-sm" />
                      <h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-zinc-300">{g.group}</h3>
                      <span className={`text-[10px] uppercase tracking-[0.16em] ${onCount ? 'text-emerald-300' : 'text-zinc-600'}`}>{onCount}/{g.phases.length}</span>
                      <div className="ml-2 flex-1 border-t border-zinc-800" />
                    </div>
                    <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
                      {g.phases.map(p => (
                        <button key={p} onClick={() => setScope({ ...scope, [p]: !scope[p] })}
                          className={`relative overflow-hidden rounded-[22px] border p-4 text-left transition ${scope[p] ? 'border-white bg-white/5' : 'border-zinc-800 bg-zinc-950 hover:border-zinc-600'}`}>
                          {scope[p] && <Shimmer />}
                          <div className="relative flex items-center gap-2">
                            <Cube className="text-base" />
                            <span className="text-sm font-bold text-white">{phaseLabel(p)}</span>
                            <span className={`ml-auto text-[10px] uppercase tracking-[0.16em] ${scope[p] ? 'text-emerald-300' : 'text-zinc-600'}`}>{scope[p] ? 'included' : 'off'}</span>
                          </div>
                          <p className="relative mt-2 line-clamp-2 text-[11px] leading-snug text-zinc-500">{PHASE_NARRATIVE[p]?.slice(0, 96)}…</p>
                        </button>
                      ))}
                    </div>
                  </div>
                );
              })}
            </div>
            <div className="mt-6 flex justify-between">
              <GhostBtn onClick={() => setStep(2)}>← Back</GhostBtn>
              <PrimaryBtn onClick={handleRun} disabled={!canRun}>{canRun ? 'Run AgentOS Engine →' : 'Add job name + client'}</PrimaryBtn>
            </div>
          </Card>
        )}

        {/* STEP 4 — Engine */}
        {step === 4 && (
          <div className="space-y-5">
            <AgentOSBoard pipeline={pipeline} running={running} />
            {estimate && (
              <>
                <div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
                  <StatPill label="Materials" value={fmt(estimate.totals.materialTotal)} />
                  <StatPill label="Labor" value={fmt(estimate.totals.laborTotal)} />
                  <StatPill label={`Margin ${Math.round(estimate.totals.marginPct || 0)}%`} value={fmt((estimate.totals.withMargin || estimate.totals.baseSubtotal) - estimate.totals.baseSubtotal)} />
                  <StatPill label={`Markup ${estimate.totals.markupPct}%`} value={fmt(estimate.totals.withMarkup - (estimate.totals.withMargin || estimate.totals.baseSubtotal))} />
                  <StatPill label="Grand total" value={fmt(estimate.totals.grandTotal)} />
                </div>

                {/* v0.0.7 — PROFIT MARGIN: admins/editors bump the at-cost pricing
                    for profit before building. Folded into the grand total only —
                    never itemized on the customer-facing proposal PDF. */}
                {CES.canEdit && (
                  <div className="relative overflow-hidden rounded-[22px] border border-emerald-500/30 bg-emerald-500/[0.04] p-5">
                    <div className="mb-3 flex flex-wrap items-center gap-2">
                      <Cube className="text-sm" />
                      <h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-emerald-300">Profit margin</h3>
                      <span className="rounded-full border border-zinc-700 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.14em] text-zinc-400">internal · not shown on proposal</span>
                      <span className="ml-auto ces-mono text-2xl font-extrabold text-white">{Math.round(marginPct)}%</span>
                    </div>
                    <input type="range" min="0" max="60" step="1" value={marginPct}
                      onChange={(e) => applyMarginPct(e.target.value)}
                      className="w-full" style={{ accentColor: '#34d399' }} />
                    <div className="mt-3 flex flex-wrap items-center gap-2">
                      {[0, 10, 15, 20, 25, 30, 40].map(p => (
                        <button key={p} onClick={() => applyMarginPct(p)}
                          className={`rounded-[10px] border px-2.5 py-1 text-[11px] font-bold transition ${Math.round(marginPct) === p ? 'border-emerald-400 bg-emerald-400 text-black' : 'border-zinc-700 text-zinc-300 hover:border-emerald-400/60 hover:text-white'}`}>{p}%</button>
                      ))}
                      <div className="ml-auto flex items-center gap-2">
                        <span className="text-[11px] text-zinc-500">Exact</span>
                        <input type="number" min="0" max="90" step="0.5" value={marginPct}
                          onChange={(e) => applyMarginPct(e.target.value)}
                          className="w-20 rounded-[10px] border border-zinc-700 bg-black px-2 py-1 text-right text-[13px] font-bold text-white outline-none focus:border-white/40" />
                        <span className="text-[12px] text-zinc-400">%</span>
                      </div>
                    </div>
                    <div className="mt-4 grid gap-2 sm:grid-cols-3">
                      <div className="rounded-[14px] border border-zinc-800 bg-black/50 px-3 py-2">
                        <div className="text-[10px] uppercase tracking-[0.14em] text-zinc-500">At-cost subtotal</div>
                        <div className="ces-mono text-[14px] font-bold text-zinc-300">{fmt(estimate.totals.baseSubtotal)}</div>
                      </div>
                      <div className="rounded-[14px] border border-emerald-500/30 bg-black/50 px-3 py-2">
                        <div className="text-[10px] uppercase tracking-[0.14em] text-emerald-400/80">+ Margin (profit)</div>
                        <div className="ces-mono text-[14px] font-bold text-emerald-300">{fmt((estimate.totals.withMargin || estimate.totals.baseSubtotal) - estimate.totals.baseSubtotal)}</div>
                      </div>
                      <div className="rounded-[14px] border border-white/30 bg-black/50 px-3 py-2">
                        <div className="text-[10px] uppercase tracking-[0.14em] text-zinc-400">Proposal grand total</div>
                        <div className="ces-mono text-[14px] font-extrabold text-white">{fmt(estimate.totals.grandTotal)}</div>
                      </div>
                    </div>
                  </div>
                )}
                <PricingTable items={estimate.lineItems} onEdit={editLineItem} onPick={pickMaterial} onAdd={addLineItem} onRemove={removeLineItem} onSync={handleSync} syncing={syncing} />
                <div className="flex flex-wrap justify-between gap-3">
                  <GhostBtn onClick={() => setStep(3)}>← Adjust scope</GhostBtn>
                  <div className="flex gap-3">
                    <GhostBtn onClick={handleRun}>↻ Re-run</GhostBtn>
                    <PrimaryBtn onClick={() => setStep(5)}>Build Proposal →</PrimaryBtn>
                  </div>
                </div>
              </>
            )}
          </div>
        )}

        {/* STEP 5 — Proposal */}
        {step === 5 && estimate && (
          <div className="space-y-5">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <h2 className="text-lg font-bold">🧊 Gold-Standard Proposal</h2>
              <div className="flex flex-wrap gap-2">
                {editUid ? <GhostBtn onClick={handleSaveAsNew}>⎘ Save as new</GhostBtn> : null}
                <GhostBtn onClick={handleSave}>{editUid ? '💾 Update + seal' : '💾 Save + seal ledger'}</GhostBtn>
                {HAS_WP ? <GhostBtn onClick={handleSaveAndStorePDF}>{storing ? '⏳ Storing…' : '📎 Save + store PDF'}</GhostBtn> : null}
                <PrimaryBtn onClick={handlePDF}>⬇ Generate PDF</PrimaryBtn>
              </div>
            </div>
            {CES.canEdit && estimate.totals && (estimate.totals.marginPct || 0) > 0 && (
              <div className="flex flex-wrap items-center gap-2 rounded-[14px] border border-emerald-500/30 bg-emerald-500/[0.04] px-4 py-2 text-[11px] text-emerald-300">
                <span className="font-bold uppercase tracking-[0.14em]">Margin baked in</span>
                <span className="text-zinc-400">{Math.round(estimate.totals.marginPct)}% profit margin is folded into the {fmt(estimate.totals.grandTotal)} grand total — it is <span className="text-white">not shown anywhere on the customer PDF</span>.</span>
              </div>
            )}
            <div className="grid gap-2 sm:grid-cols-4"><LedgerBar ledger={ledger} /></div>
            <div ref={proposalRef} className="overflow-x-auto rounded-[22px] border border-zinc-800 bg-zinc-900 p-4 md:p-8">
              <ProposalPreview estimate={estimate} settings={settings} />
            </div>
            <div className="flex justify-between">
              <GhostBtn onClick={() => setStep(4)}>← Pricing</GhostBtn>
              <GhostBtn onClick={startFresh}>＋ New estimate</GhostBtn>
            </div>
          </div>
        )}

        {/* v0.0.6 — 3D proposal carousel: reuse a past proposal as a template */}
        {CES.canEdit && (
          <ProposalCarousel
            refreshKey={carouselTick}
            currentUid={editUid}
            onOpen={(uid, mode) => { apiGetEstimate(uid).then(rec => { if (rec) loadEstimateIntoApp(rec, mode === 'template'); else flash('Could not load that proposal'); }); }}
          />
        )}

        <footer className="mt-12 border-t border-zinc-900 pt-5 text-center text-[11px] text-zinc-600">
          🧊 Ciano Estimation Suite — engineered by <span className="text-zinc-400">Bitghost.com</span> · Spectre AI AGI AgentOS · support ghost@bitghost.com
        </footer>
      </div>

      {/* Toast */}
      {toast && (
        <div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-[16px] border border-zinc-700 bg-zinc-950/95 px-5 py-3 text-sm font-semibold text-white shadow-2xl ces-glow">{toast}</div>
      )}
    </div>
  );
}

function PrimaryBtn({ children, onClick, disabled }) {
  return (
    <button onClick={onClick} disabled={disabled}
      className="relative overflow-hidden rounded-[22px] bg-white px-6 py-3 text-sm font-bold text-black transition hover:bg-zinc-200 disabled:cursor-not-allowed disabled:bg-zinc-800 disabled:text-zinc-500">
      {!disabled && <Shimmer />}<span className="relative">{children}</span>
    </button>
  );
}
function GhostBtn({ children, onClick }) {
  return (
    <button onClick={onClick}
      className="rounded-[22px] border border-zinc-800 bg-zinc-950 px-6 py-3 text-sm font-bold text-zinc-300 transition hover:border-zinc-600 hover:text-white">{children}</button>
  );
}

/* ----------------------------------------------------------------------------
 * 11a-shared. v0.0.6 — viewport hook, status metadata, proposal thumbnail.
 * ------------------------------------------------------------------------- */
function useViewport() {
  const [w, setW] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const on = () => setW(window.innerWidth);
    window.addEventListener('resize', on);
    return () => window.removeEventListener('resize', on);
  }, []);
  return w;
}

const STATUS_META = {
  draft:    { label: 'Draft',    cls: 'border-zinc-700 text-zinc-300' },
  sent:     { label: 'Sent',     cls: 'border-sky-500/40 text-sky-300' },
  accepted: { label: 'Accepted', cls: 'border-emerald-500/40 text-emerald-300' },
  declined: { label: 'Declined', cls: 'border-red-500/40 text-red-300' },
  archived: { label: 'Archived', cls: 'border-zinc-700 text-zinc-500' },
};
function statusMeta(s) { return STATUS_META[s] || STATUS_META.draft; }

/* A stylized monochrome proposal "page" face for carousel/console cards —
 * evokes the real Ciano letterhead without rendering a full PDF (so a deck of
 * many proposals stays instant). */
function ProposalThumb({ rec, w, h }) {
  const bars = [0.92, 0.7, 0.84, 0.6, 0.76, 0.5];
  const f = (px) => Math.max(6, Math.round(px));
  return (
    <div style={{ width: w, height: h }} className="relative overflow-hidden rounded-[10px] border border-zinc-300 bg-zinc-100">
      <div style={{ height: Math.round(h * 0.2), background: 'linear-gradient(90deg,#003399,#102A66)' }} className="flex items-center px-2">
        <span style={{ fontSize: f(w * 0.052) }} className="truncate font-bold tracking-wide text-white">CIANO · PROPOSAL</span>
      </div>
      <div className="px-2 pt-1.5">
        <div style={{ fontSize: f(w * 0.058), color: '#1A1A1A' }} className="truncate font-bold">{rec.job_name || 'Proposal'}</div>
        <div style={{ fontSize: f(w * 0.046), color: '#666' }} className="truncate">{rec.client_name || '—'}</div>
        <div className="mt-1.5 space-y-1">
          {bars.map((p, i) => (
            <div key={i} style={{ width: (p * 100) + '%', height: Math.max(2, Math.round(h * 0.012)), background: '#d2d2d2' }} className="rounded-full" />
          ))}
        </div>
      </div>
      <div style={{ position: 'absolute', bottom: 6, right: 8, fontSize: f(w * 0.062), color: '#003399' }} className="ces-mono font-extrabold">{fmt(rec.grand_total || 0)}</div>
    </div>
  );
}

/* ----------------------------------------------------------------------------
 * 11c. PROPOSAL CAROUSEL — 3D coverflow of past proposals (admins/editors).
 *      Manual rotate only (no autoplay), drag/swipe + keyboard, mobile-aware.
 *      Sits below the calculator; clicking the front card loads it as a
 *      reusable template.
 * ------------------------------------------------------------------------- */
function ProposalCarousel({ refreshKey, currentUid, onOpen }) {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [active, setActive] = useState(0);
  const vw = useViewport();
  const isNarrow = vw < 640;
  const drag = useRef({ x: 0, on: false, moved: 0 });

  useEffect(() => {
    let alive = true; setLoading(true);
    apiListEstimates(40).then((r) => {
      if (!alive) return;
      const list = (r && r.estimates) || [];
      setItems(list); setActive((a) => Math.min(a, Math.max(0, list.length - 1))); setLoading(false);
    });
    return () => { alive = false; };
  }, [refreshKey]);

  const n = items.length;
  const cardW = isNarrow ? 152 : 212;
  const cardH = isNarrow ? 200 : 274;
  const stageH = cardH + (isNarrow ? 36 : 56);
  const step = isNarrow ? 98 : 150;
  const depth = isNarrow ? 120 : 170;
  const go = (d) => setActive((a) => Math.max(0, Math.min(n - 1, a + d)));

  function down(e) { drag.current = { x: e.touches ? e.touches[0].clientX : e.clientX, on: true, moved: 0 }; }
  function move(e) { if (!drag.current.on) return; const x = e.touches ? e.touches[0].clientX : e.clientX; drag.current.moved = x - drag.current.x; }
  function up() { const m = drag.current.moved; drag.current.on = false; if (Math.abs(m) > 44) go(m < 0 ? 1 : -1); }

  if (loading) {
    return (
      <section className="mt-14">
        <div className="mb-3 flex items-center gap-2"><Cube className="text-base" /><h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-zinc-400">Past proposals</h3></div>
        <div className="rounded-[22px] border border-zinc-900 bg-zinc-950/40 py-12 text-center text-zinc-600"><Cube spin className="text-xl" /><div className="mt-2 text-[12px]">Loading proposal deck…</div></div>
      </section>
    );
  }
  if (!n) {
    return (
      <section className="mt-14">
        <div className="mb-3 flex items-center gap-2"><Cube className="text-base" /><h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-zinc-400">Past proposals</h3></div>
        <div className="rounded-[22px] border border-dashed border-zinc-800 bg-zinc-950/40 py-10 text-center text-[12px] text-zinc-600">Saved proposals appear here as a 3D deck — build &amp; save one, then reuse it as a template. 🧊</div>
      </section>
    );
  }

  return (
    <section className="mt-14">
      <header className="mb-3 flex flex-wrap items-center gap-3">
        <Cube className="text-base" />
        <div>
          <h3 className="text-[13px] font-bold uppercase tracking-[0.18em] text-white">Past proposals</h3>
          <p className="text-[11px] text-zinc-500">Spin the deck, then <span className="text-sky-300">Edit</span> a proposal in place or fork it as a <span className="text-violet-300">Template</span> — same client or similar job, done in seconds.</p>
        </div>
        <div className="ml-auto flex items-center gap-2">
          <button onClick={() => go(-1)} disabled={active <= 0} className="rounded-full border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-sm text-zinc-300 transition hover:border-zinc-600 hover:text-white disabled:opacity-40">‹</button>
          <span className="ces-mono text-[11px] text-zinc-500">{active + 1}/{n}</span>
          <button onClick={() => go(1)} disabled={active >= n - 1} className="rounded-full border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-sm text-zinc-300 transition hover:border-zinc-600 hover:text-white disabled:opacity-40">›</button>
        </div>
      </header>

      <div className="relative select-none overflow-hidden rounded-[22px] border border-zinc-900 bg-zinc-950/40"
        style={{ height: stageH, perspective: '1200px' }}
        onMouseDown={down} onMouseMove={move} onMouseUp={up} onMouseLeave={up}
        onTouchStart={down} onTouchMove={move} onTouchEnd={up}>
        {items.map((rec, i) => {
          const off = i - active, abs = Math.abs(off);
          if (abs > 3) return null;
          const tx = off * step, tz = -abs * depth, ry = Math.max(-60, Math.min(60, off * -42)), sc = Math.max(0.72, 1 - abs * 0.12);
          const isActive = i === active;
          const isEditing = currentUid && rec.uid === currentUid;
          return (
            <div key={rec.uid || i}
              onClick={() => { if (!isActive) setActive(i); }}
              className="absolute cursor-pointer"
              style={{ left: '50%', top: isNarrow ? 8 : 12, width: cardW, height: cardH, marginLeft: -cardW / 2, transform: `translateX(${tx}px) translateZ(${tz}px) rotateY(${ry}deg) scale(${sc})`, transformStyle: 'preserve-3d', transition: 'transform .5s cubic-bezier(.22,1,.36,1), opacity .4s, filter .4s', opacity: abs > 2 ? 0 : 1, zIndex: 100 - abs, pointerEvents: abs > 2 ? 'none' : 'auto', filter: isActive ? 'none' : 'brightness(.55)' }}>
              <div className={`relative h-full rounded-[18px] border ${isEditing ? 'border-emerald-400/60' : isActive ? 'border-white' : 'border-zinc-800'} bg-zinc-950 p-2.5`}>
                {isActive && <Shimmer />}
                <ProposalThumb rec={rec} w={cardW - 20} h={cardH - 80} />
                <div className="relative mt-2 px-0.5">
                  <div className="truncate text-[12px] font-bold text-white">{rec.job_name || '(untitled)'}</div>
                  <div className="flex items-center gap-1.5">
                    <span className="truncate text-[10px] text-zinc-500">{rec.client_name || '—'}</span>
                    <span className="ml-auto ces-mono text-[11px] font-bold text-white">{fmt(rec.grand_total || 0)}</span>
                  </div>
                </div>
                {isActive && (
                  <>
                    <div className="relative mt-1.5 flex items-center gap-1.5">
                      <span className={`rounded-full border px-1.5 py-0.5 text-[9px] ${statusMeta(rec.status).cls}`}>{statusMeta(rec.status).label}</span>
                      {rec.has_pdf && <span className={`rounded-full border px-1.5 py-0.5 text-[9px] ${rec.pdf_stale ? 'border-amber-500/40 text-amber-300' : 'border-emerald-500/40 text-emerald-300'}`}>{rec.pdf_stale ? 'PDF stale' : 'PDF'}</span>}
                    </div>
                    {/* v0.0.7 — load a past proposal either FOR EDITING (modify /
                        regenerate the same record in place) or AS A TEMPLATE
                        (fork a brand-new proposal, leaving the original intact). */}
                    <div className="relative mt-2 flex gap-1.5">
                      <button onClick={(e) => { e.stopPropagation(); if (Math.abs(drag.current.moved) < 6) onOpen(rec.uid, 'edit'); }}
                        className="flex-1 rounded-[10px] border border-sky-500/40 bg-sky-500/[0.05] py-1 text-[10px] font-bold text-sky-300 transition hover:border-white hover:text-white">✎ Edit</button>
                      <button onClick={(e) => { e.stopPropagation(); if (Math.abs(drag.current.moved) < 6) onOpen(rec.uid, 'template'); }}
                        className="flex-1 rounded-[10px] border border-violet-500/40 bg-violet-500/10 py-1 text-[10px] font-bold text-violet-300 transition hover:border-white hover:text-white">⎘ Template</button>
                    </div>
                  </>
                )}
              </div>
            </div>
          );
        })}
      </div>

      <div className="mt-3 flex flex-wrap items-center justify-center gap-1.5">
        {items.slice(0, Math.min(n, 28)).map((_, i) => (
          <button key={i} onClick={() => setActive(i)} aria-label={`Proposal ${i + 1}`}
            className={`h-1.5 rounded-full transition-all ${i === active ? 'w-5 bg-white' : 'w-1.5 bg-zinc-700 hover:bg-zinc-500'}`} />
        ))}
      </div>
    </section>
  );
}

/* ----------------------------------------------------------------------------
 * 11d. ESTIMATES CONSOLE — wp-admin management surface for general contractors.
 *      Search / status / inline PDF preview + store/regenerate + open-to-edit /
 *      duplicate / delete. Mounts on #ces-estimates-app.
 * ------------------------------------------------------------------------- */
function EstimatesConsole({ appUrl }) {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [q, setQ] = useState('');
  const [statusFilter, setStatusFilter] = useState('all');
  const [preview, setPreview] = useState(null); // { uid, url, job }
  const [busy, setBusy] = useState('');
  const [toast, setToast] = useState(null);
  const flash = (m) => { setToast(m); setTimeout(() => setToast(null), 2400); };
  const settings = CES.settings || DEFAULT_SETTINGS;

  const load = useCallback(async () => { setLoading(true); const r = await apiListEstimates(200); setItems((r && r.estimates) || []); setLoading(false); }, []);
  useEffect(() => { load(); }, [load]);

  const filtered = useMemo(() => {
    const ql = q.toLowerCase().trim();
    return items.filter((r) => (statusFilter === 'all' || (r.status || 'draft') === statusFilter)
      && (!ql || (r.job_name || '').toLowerCase().includes(ql) || (r.client_name || '').toLowerCase().includes(ql)));
  }, [items, q, statusFilter]);

  const summary = useMemo(() => {
    const value = items.reduce((s, r) => s + (Number(r.grand_total) || 0), 0);
    const accepted = items.filter((r) => r.status === 'accepted').length;
    const withPdf = items.filter((r) => r.has_pdf).length;
    return { count: items.length, value, accepted, withPdf };
  }, [items]);

  function appLink(uid, mode) {
    if (!appUrl) return '#';
    const param = mode === 'template' ? 'ces_template' : 'ces_estimate';
    return appUrl + (appUrl.indexOf('?') >= 0 ? '&' : '?') + param + '=' + encodeURIComponent(uid);
  }
  async function setStatus(uid, status) { setItems((l) => l.map((r) => (r.uid === uid ? { ...r, status } : r))); await apiSetStatus(uid, status); flash('Status → ' + status); }
  async function del(uid) {
    if (typeof window !== 'undefined' && !window.confirm('Delete this estimate permanently? This also removes its stored PDF and cannot be undone.')) return;
    setBusy(uid); const r = await apiDeleteEstimate(uid); setBusy('');
    if (r && r.deleted) { setItems((l) => l.filter((x) => x.uid !== uid)); flash('🧊 Deleted'); } else flash('Delete failed');
  }
  async function renderPdf(uid, mode) {
    if (typeof window.cesGenerateProposalPDF !== 'function') { flash('PDF engine not loaded'); return null; }
    setBusy(uid);
    try {
      const rec = await apiGetEstimate(uid);
      if (!rec || !rec.payload) { flash('Could not load estimate'); return null; }
      const out = window.cesGenerateProposalPDF(rec.payload, settings, { CIANO, amountToWords, fmt, output: mode === 'store' ? 'datauri' : 'bloburl' });
      return { rec, est: rec.payload, out };
    } catch (e) { flash('Render failed'); return null; } finally { setBusy(''); }
  }
  async function previewPdf(uid, job) {
    const row = items.find((x) => x.uid === uid);
    if (row && row.has_pdf && !row.pdf_stale && row.pdf_url) { setPreview({ uid, url: row.pdf_url, job }); return; }
    const res = await renderPdf(uid, 'preview');
    if (res && res.out) setPreview({ uid, url: res.out, job });
  }
  async function downloadPdf(uid) {
    const row = items.find((x) => x.uid === uid);
    if (row && row.has_pdf && !row.pdf_stale && row.pdf_url) { window.open(row.pdf_url, '_blank'); return; }
    const rec = await apiGetEstimate(uid);
    if (rec && rec.payload && typeof window.cesGenerateProposalPDF === 'function') window.cesGenerateProposalPDF(rec.payload, settings, { CIANO, amountToWords, fmt });
  }
  async function storePdf(uid) {
    const res = await renderPdf(uid, 'store');
    if (!res || !res.out) return;
    setBusy(uid);
    const r = await apiStorePDF(uid, res.out, res.est && res.est.job && res.est.job.jobName);
    setBusy('');
    if (r && r.stored) { flash('🧊 PDF stored & stamped'); load(); } else flash('Store needs WordPress');
  }

  const newLink = appUrl || '#';

  return (
    <div className="ces-root min-h-screen w-full bg-black p-4 text-white md:p-8">
      <div className="ces-bg pointer-events-none fixed inset-0" aria-hidden="true" />
      <div className="relative mx-auto max-w-7xl">
        <header className="mb-6 flex flex-wrap items-center gap-3">
          <Cube spin className="text-2xl" />
          <div>
            <h1 className="text-xl font-bold ces-text-shimmer">Estimates · Management Console</h1>
            <p className="text-[12px] text-zinc-500">Open to edit, duplicate as a template, preview &amp; store proposal PDFs, set status, and prune — built for the shop.</p>
          </div>
          <div className="ml-auto flex items-center gap-2">
            <button onClick={load} className="rounded-[14px] border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-[12px] font-semibold text-zinc-300 transition hover:text-white">↻ Refresh</button>
            <a href={newLink} className="relative overflow-hidden rounded-[14px] bg-white px-3 py-1.5 text-[12px] font-bold text-black transition hover:bg-zinc-200"><Shimmer /><span className="relative">＋ New estimate</span></a>
          </div>
        </header>

        <div className="mb-5 grid grid-cols-2 gap-2 sm:grid-cols-4">
          <StatPill label="Estimates" value={`${summary.count}`} />
          <StatPill label="Pipeline value" value={fmt(summary.value)} />
          <StatPill label="Accepted" value={`${summary.accepted}`} />
          <StatPill label="With stored PDF" value={`${summary.withPdf}`} />
        </div>

        <div className="mb-4 flex flex-wrap items-center gap-2">
          <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="🔎 Search job or client…"
            className="flex-1 rounded-[14px] border border-zinc-800 bg-zinc-950 px-4 py-2.5 text-sm text-white outline-none focus:border-white/40 md:max-w-sm" />
          <select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
            className="rounded-[14px] border border-zinc-800 bg-black px-3 py-2.5 text-[12px] text-zinc-200 outline-none focus:border-white/40">
            <option value="all">All statuses</option>
            {ESTIMATE_STATUSES.map((s) => <option key={s} value={s}>{statusMeta(s).label}</option>)}
          </select>
        </div>

        {loading ? (
          <div className="py-16 text-center text-zinc-500"><Cube spin className="text-2xl" /><div className="mt-2 text-[12px]">Loading estimates…</div></div>
        ) : filtered.length === 0 ? (
          <Card className="p-10 text-center text-zinc-500">No estimates match. Build one in the calculator — it lands here automatically. 🧊</Card>
        ) : (
          <div className="overflow-x-auto rounded-[22px] border border-zinc-800 bg-zinc-950/60">
            <table className="w-full min-w-[860px] text-left text-sm">
              <thead>
                <tr className="border-b border-zinc-800 text-[10px] uppercase tracking-[0.16em] text-zinc-500">
                  <th className="px-4 py-3">Proposal</th>
                  <th className="px-3 py-3">Total</th>
                  <th className="px-3 py-3">Conf.</th>
                  <th className="px-3 py-3">Status</th>
                  <th className="px-3 py-3">PDF</th>
                  <th className="px-3 py-3">Updated (UTC)</th>
                  <th className="px-4 py-3 text-right">Actions</th>
                </tr>
              </thead>
              <tbody>
                {filtered.map((r) => (
                  <tr key={r.uid} className="border-b border-zinc-900 transition hover:bg-white/5">
                    <td className="px-4 py-3">
                      <div className="font-semibold text-white">{r.job_name || '(untitled)'}</div>
                      <div className="text-[11px] text-zinc-500">{r.client_name || '—'} <span className="ces-mono text-zinc-700">· {String(r.uid).slice(0, 12)}</span></div>
                    </td>
                    <td className="px-3 py-3 ces-mono font-bold text-white">{fmt(r.grand_total || 0)}</td>
                    <td className="px-3 py-3 ces-mono text-[12px] text-zinc-400">{r.confidence || 0}%</td>
                    <td className="px-3 py-3">
                      <select value={r.status || 'draft'} onChange={(e) => setStatus(r.uid, e.target.value)}
                        className={`rounded-full border bg-black px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] outline-none ${statusMeta(r.status).cls}`}>
                        {ESTIMATE_STATUSES.map((s) => <option key={s} value={s} className="bg-black text-white">{statusMeta(s).label}</option>)}
                      </select>
                    </td>
                    <td className="px-3 py-3">
                      {r.has_pdf
                        ? <span className={`rounded-full border px-2 py-0.5 text-[10px] ${r.pdf_stale ? 'border-amber-500/40 text-amber-300' : 'border-emerald-500/40 text-emerald-300'}`}>{r.pdf_stale ? 'stale' : 'stored'}</span>
                        : <span className="rounded-full border border-zinc-700 px-2 py-0.5 text-[10px] text-zinc-600">none</span>}
                    </td>
                    <td className="px-3 py-3 ces-mono text-[11px] text-zinc-500">{r.updated_at || '—'}</td>
                    <td className="px-4 py-3">
                      <div className="flex flex-wrap items-center justify-end gap-1.5">
                        <a href={appLink(r.uid, 'edit')} title="Open in calculator to edit" className="rounded-[10px] border border-zinc-700 px-2 py-1 text-[11px] font-semibold text-zinc-200 transition hover:border-white hover:text-white">✎ Edit</a>
                        <a href={appLink(r.uid, 'template')} title="Duplicate as a new proposal" className="rounded-[10px] border border-zinc-800 px-2 py-1 text-[11px] text-zinc-300 transition hover:border-zinc-600 hover:text-white">⎘ Dup</a>
                        <button onClick={() => previewPdf(r.uid, r.job_name)} disabled={busy === r.uid} title="Preview proposal PDF" className="rounded-[10px] border border-zinc-800 px-2 py-1 text-[11px] text-zinc-300 transition hover:border-zinc-600 hover:text-white disabled:opacity-50">{busy === r.uid ? '⏳' : '👁'}</button>
                        <button onClick={() => storePdf(r.uid)} disabled={busy === r.uid} title={r.pdf_stale ? 'Regenerate stored PDF' : 'Store proposal PDF'} className="rounded-[10px] border border-zinc-800 px-2 py-1 text-[11px] text-zinc-300 transition hover:border-zinc-600 hover:text-white disabled:opacity-50">{r.pdf_stale ? '♻' : '📎'}</button>
                        <button onClick={() => downloadPdf(r.uid)} title="Download proposal PDF" className="rounded-[10px] border border-zinc-800 px-2 py-1 text-[11px] text-zinc-300 transition hover:border-zinc-600 hover:text-white">⬇</button>
                        {CES.isAdmin && <button onClick={() => del(r.uid)} disabled={busy === r.uid} title="Delete estimate" className="rounded-[10px] border border-zinc-800 px-2 py-1 text-[11px] text-zinc-500 transition hover:border-red-500/50 hover:text-red-400 disabled:opacity-50">🗑</button>}
                      </div>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}

        <footer className="mt-12 border-t border-zinc-900 pt-5 text-center text-[11px] text-zinc-600">🧊 Estimates Console — Ciano Estimation Suite by <span className="text-zinc-400">Bitghost.com</span></footer>
      </div>

      {preview && (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-3 md:p-8" onClick={() => setPreview(null)}>
          <div className="relative flex h-[92vh] w-full max-w-[900px] flex-col overflow-hidden rounded-[22px] border border-zinc-700 bg-zinc-950" onClick={(e) => e.stopPropagation()}>
            <div className="flex items-center gap-2 border-b border-zinc-800 px-4 py-3">
              <Cube className="text-sm" />
              <span className="truncate text-[13px] font-bold text-white">{preview.job || 'Proposal'}</span>
              <a href={preview.url} target="_blank" rel="noopener noreferrer" className="ml-auto rounded-[10px] border border-zinc-700 px-3 py-1 text-[11px] font-semibold text-zinc-200 transition hover:border-white hover:text-white">⬇ Open / download</a>
              <button onClick={() => setPreview(null)} className="rounded-[10px] border border-zinc-800 px-3 py-1 text-[11px] text-zinc-400 transition hover:border-zinc-600 hover:text-white">✕ Close</button>
            </div>
            <iframe title="Proposal PDF preview" src={preview.url} className="h-full w-full bg-white" />
          </div>
        </div>
      )}

      {toast && <div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-[16px] border border-zinc-700 bg-zinc-950/95 px-5 py-3 text-sm font-semibold text-white shadow-2xl ces-glow">{toast}</div>}
    </div>
  );
}

/* ----------------------------------------------------------------------------
 * 11b. MATERIAL LIBRARY — admin "React Component Server for materials".
 *      22px kanban cards of every material parsed from past projects, with a
 *      predictive add form. Mounts on the wp-admin Material Library screen.
 * ------------------------------------------------------------------------- */
function MaterialLibrary() {
  const [materials, setMaterials] = useState([]);
  const [stats, setStats] = useState({ total: 0, categories: 0, observations: 0 });
  const [loading, setLoading] = useState(true);
  const [q, setQ] = useState('');
  const [draft, setDraft] = useState({ name: '', category: '', unit: 'ea', price: 0, sku: '' });
  const [toast, setToast] = useState(null);
  // v0.0.6 — proposal-parse recommendations queue (the "AGI Architect" review tab)
  const [tab, setTab] = useState('library');
  const [recs, setRecs] = useState([]);
  const [parsing, setParsing] = useState(false);
  const [parseMsg, setParseMsg] = useState('');
  const [parseInfo, setParseInfo] = useState(null);
  const [dragOver, setDragOver] = useState(false);
  const flash = (m) => { setToast(m); setTimeout(() => setToast(null), 2400); };

  const load = useCallback(async () => {
    setLoading(true);
    const r = await fetchLibrary();
    setMaterials((r && r.materials) || []);
    setStats((r && r.stats) || { total: 0, categories: 0, observations: 0 });
    setLoading(false);
  }, []);
  const loadRecs = useCallback(async () => { const r = await apiGetRecs(); setRecs((r && r.recommendations) || []); }, []);
  useEffect(() => { load(); loadRecs(); }, [load, loadRecs]);

  const filtered = useMemo(() => {
    const ql = q.toLowerCase().trim();
    return ql ? materials.filter(m => m.name.toLowerCase().includes(ql) || (m.category || '').toLowerCase().includes(ql) || (m.sku || '').toLowerCase().includes(ql)) : materials;
  }, [materials, q]);

  const groups = useMemo(() => {
    const g = {};
    filtered.forEach(m => { const c = m.category || 'Uncategorized'; (g[c] = g[c] || []).push(m); });
    return Object.entries(g).sort((a, b) => b[1].length - a[1].length);
  }, [filtered]);

  const recsByCat = useMemo(() => {
    const g = {};
    recs.forEach(m => { const c = m.category || 'Uncategorized'; (g[c] = g[c] || []).push(m); });
    return Object.entries(g).sort((a, b) => b[1].length - a[1].length);
  }, [recs]);

  async function onFiles(fileList) {
    const files = Array.from(fileList || []).filter(f => /\.pdf$/i.test(f.name) || f.type === 'application/pdf');
    if (!files.length) { flash('Choose a PDF proposal'); return; }
    if (!ensurePdfjs()) { flash('PDF reader not loaded — reload the page'); return; }
    setParsing(true); setParseInfo(null);
    let added = 0, dupes = 0, last = '';
    for (const f of files) {
      try {
        setParseMsg('Reading ' + f.name + '…');
        const text = await extractPdfText(f);
        const { source, candidates } = extractProposalMaterials(text);
        setParseMsg((source === 'fabuwood' ? 'Fabuwood order detected · stripping cabinetry line items…' : 'AGI Architect extracting materials…'));
        const r = await apiParseMaterials(candidates, f.name);
        added += (r && r.added) || 0; dupes += (r && r.duplicates) || 0; last = f.name;
      } catch (e) { flash('Could not read ' + f.name); }
    }
    setParsing(false); setParseMsg('');
    setParseInfo({ file: files.length > 1 ? (files.length + ' files') : last, added, duplicates: dupes });
    flash(added ? `🧊 ${added} material${added === 1 ? '' : 's'} queued for review` : 'No new materials found');
    setTab('recs');
    loadRecs();
  }
  async function acceptRec(m) {
    const r = await apiAcceptRec(m.mkey);
    setRecs((r && r.recommendations) || recs.filter(x => x.mkey !== m.mkey));
    flash('🧊 Added “' + m.name + '”'); load();
  }
  async function rejectRec(m) {
    const r = await apiRejectRec(m.mkey);
    setRecs((r && r.recommendations) || recs.filter(x => x.mkey !== m.mkey));
    flash('Rejected');
  }
  async function clearRecs() {
    if (typeof window !== 'undefined' && !window.confirm('Clear all pending recommendations?')) return;
    await apiClearRecs(); setRecs([]); flash('Queue cleared');
  }
  async function acceptAll() {
    const list = [...recs];
    for (const m of list) { await apiAcceptRec(m.mkey); }
    setRecs([]); flash('🧊 All recommendations added'); load();
  }

  async function addDraft() {
    if (!draft.name.trim()) { flash('Enter a material name'); return; }
    const res = await saveLibraryMaterial({ ...draft, source: 'manual', last_synced: nowISO(), project: 'Manual entry' });
    if (res) { flash('🧊 Added to library'); setDraft({ name: '', category: '', unit: 'ea', price: 0, sku: '' }); load(); }
    else flash('Save failed');
  }
  async function removeMat(m) {
    if (HAS_WP) { try { await api('materials?mkey=' + encodeURIComponent(m.mkey), { method: 'DELETE' }); } catch (e) {} }
    else { localLib.write(localLib.read().filter(x => x.mkey !== m.mkey)); }
    flash('Removed'); load();
  }

  return (
    <div className="ces-root min-h-screen w-full bg-black p-4 text-white md:p-8">
      <div className="ces-bg pointer-events-none fixed inset-0" aria-hidden="true" />
      <div className="relative mx-auto max-w-7xl">
        <header className="mb-6 flex flex-wrap items-center gap-3">
          <Cube spin className="text-2xl" />
          <div>
            <h1 className="text-xl font-bold ces-text-shimmer">Material Library · Component Server</h1>
            <p className="text-[12px] text-zinc-500">Self-enriching archive parsed from every estimate &amp; live price sync.</p>
          </div>
          <button onClick={load} className="ml-auto rounded-[14px] border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-[12px] font-semibold text-zinc-300 transition hover:text-white">↻ Refresh</button>
        </header>

        <div className="mb-5 grid grid-cols-2 gap-2 sm:grid-cols-4">
          <StatPill label="Materials" value={`${stats.total || 0}`} />
          <StatPill label="Categories" value={`${stats.categories || 0}`} />
          <StatPill label="Observations" value={`${stats.observations || 0}`} />
          <StatPill label="Recommendations" value={`${recs.length}`} />
        </div>

        {/* v0.0.6 — Library / Recommendations tabs */}
        <div className="mb-5 flex items-center gap-2">
          <button onClick={() => setTab('library')} className={`relative overflow-hidden rounded-[14px] border px-4 py-2 text-[12px] font-bold transition ${tab === 'library' ? 'border-white bg-white text-black' : 'border-zinc-800 bg-zinc-950 text-zinc-400 hover:text-white'}`}>{tab === 'library' && <Shimmer />}<span className="relative">🧊 Filter Library</span></button>
          <button onClick={() => setTab('recs')} className={`relative overflow-hidden rounded-[14px] border px-4 py-2 text-[12px] font-bold transition ${tab === 'recs' ? 'border-white bg-white text-black' : 'border-zinc-800 bg-zinc-950 text-zinc-400 hover:text-white'}`}>{tab === 'recs' && <Shimmer />}<span className="relative">🧊 Recommendations{recs.length ? ` · ${recs.length}` : ''}</span></button>
        </div>

        {tab === 'library' && (
        <>
        <Card className="mb-5 p-4">
          <div className="mb-3 flex items-center gap-2"><Cube /><h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-white">Add material</h3></div>
          <div className="grid gap-2 md:grid-cols-12">
            <div className="md:col-span-5">
              <MaterialSuggestInput value={draft.name} onChange={(v) => setDraft(d => ({ ...d, name: v }))}
                onPick={(m) => setDraft({ name: m.name, category: PHASE_ORDER.includes(m.category) ? m.category : '', unit: m.unit || 'ea', price: m.price || 0, sku: m.sku || '' })}
                placeholder="Material name (predictive)…" />
            </div>
            <select value={draft.category} onChange={(e) => setDraft(d => ({ ...d, category: e.target.value }))}
              className="rounded-[10px] border border-zinc-800 bg-black px-2 py-1.5 text-[12px] text-zinc-200 outline-none focus:border-white/40 md:col-span-3">
              <option value="">— category —</option>
              {PHASE_ORDER.map(p => <option key={p} value={p}>{p}</option>)}
            </select>
            <input value={draft.unit} onChange={(e) => setDraft(d => ({ ...d, unit: e.target.value }))} placeholder="unit"
              className="rounded-[10px] border border-zinc-800 bg-black px-2 py-1.5 text-center text-[12px] text-white outline-none focus:border-white/40 md:col-span-1" />
            <input type="number" step="0.01" value={draft.price} onChange={(e) => setDraft(d => ({ ...d, price: parseFloat(e.target.value) || 0 }))} placeholder="price"
              className="rounded-[10px] border border-zinc-800 bg-black px-2 py-1.5 text-right ces-mono text-[12px] text-white outline-none focus:border-white/40 md:col-span-2" />
            <button onClick={addDraft} className="relative overflow-hidden rounded-[14px] bg-white px-3 py-1.5 text-[12px] font-bold text-black transition hover:bg-zinc-200 md:col-span-1"><Shimmer /><span className="relative">＋</span></button>
          </div>
        </Card>

        <div className="mb-4">
          <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="🔎 Filter library…"
            className="w-full rounded-[14px] border border-zinc-800 bg-zinc-950 px-4 py-2.5 text-sm text-white outline-none focus:border-white/40 md:max-w-sm" />
        </div>

        {loading ? (
          <div className="py-16 text-center text-zinc-500"><Cube spin className="text-2xl" /><div className="mt-2 text-[12px]">Loading library…</div></div>
        ) : groups.length === 0 ? (
          <Card className="p-10 text-center text-zinc-500">No materials yet. Run an estimate or add one above — the library learns as you go. 🧊</Card>
        ) : (
          <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
            {groups.map(([cat, list]) => (
              <div key={cat} className="rounded-[22px] border border-zinc-800 bg-zinc-950/60 p-3">
                <div className="mb-3 flex items-center gap-2 px-1">
                  <Cube /><h3 className="text-[12px] font-bold uppercase tracking-[0.14em] text-white">{cat}</h3>
                  <span className="ml-auto rounded-full border border-zinc-700 px-2 py-0.5 text-[10px] text-zinc-400">{list.length}</span>
                </div>
                <div className="space-y-2">
                  {list.map((m, i) => {
                    const b = sourceBadge(m.source);
                    const age = Math.floor((Date.now() - new Date(m.last_synced).getTime()) / 86400000);
                    return (
                      <div key={m.mkey + i} className="group rounded-[22px] border border-zinc-800 bg-black p-3 transition hover:border-zinc-600">
                        <div className="flex items-start gap-2">
                          <div className="min-w-0 flex-1">
                            <div className="truncate text-[13px] font-semibold text-white">{m.name}</div>
                            <div className="ces-mono text-[10px] text-zinc-600">{m.sku || 'custom'} · {m.unit}</div>
                          </div>
                          <div className="ces-mono text-[14px] font-bold text-white">{fmt(m.price)}</div>
                        </div>
                        <div className="mt-2 flex flex-wrap items-center gap-1.5">
                          <span className={`rounded-full border px-1.5 py-0.5 text-[9px] ${b.cls}`}>{b.label}</span>
                          <span className="ces-mono text-[10px] text-zinc-500">{isNaN(age) ? '—' : age <= 0 ? 'today' : age + 'd'}</span>
                          <span className="ces-mono text-[10px] text-zinc-500">· seen ×{m.times_seen || 1}</span>
                          <span className="ces-mono text-[10px] text-zinc-500">· {m.projects || 1} proj</span>
                          <button onClick={() => removeMat(m)} className="ml-auto text-[11px] text-zinc-600 opacity-0 transition group-hover:opacity-100 hover:text-red-400">✕</button>
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
            ))}
          </div>
        )}
        </>
        )}

        {tab === 'recs' && (
        <>
          <Card className="mb-5 p-5">
            <div className="mb-2 flex items-center gap-2"><Cube /><h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-white">Parse a past proposal</h3></div>
            <p className="mb-4 text-[12px] text-zinc-500">Drop an old <span className="text-white">Ciano proposal</span> <span className="text-zinc-400">or</span> a <span className="text-white">Fabuwood material order</span> PDF. Ciano proposals are read for their line-item narratives; a Fabuwood sales order is parsed surgically — each cabinetry line item is lifted verbatim with its real per-unit cost (no guessing). Materials you already have are skipped; accept or reject each new one below. The queue persists, so you can come back to it.</p>
            <label
              onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
              onDragLeave={() => setDragOver(false)}
              onDrop={(e) => { e.preventDefault(); setDragOver(false); onFiles(e.dataTransfer.files); }}
              className={`flex cursor-pointer flex-col items-center justify-center rounded-[22px] border-2 border-dashed px-6 py-10 text-center transition ${dragOver ? 'border-violet-500/40 bg-violet-500/10' : 'border-zinc-700 bg-zinc-950/60 hover:border-zinc-500'}`}>
              <input type="file" accept="application/pdf,.pdf" multiple className="hidden" onChange={(e) => onFiles(e.target.files)} />
              {parsing
                ? (<><Cube spin className="text-2xl" /><div className="mt-2 text-[12px] text-zinc-300">{parseMsg || 'Parsing…'}</div></>)
                : (<><span className="text-2xl">🧊📄</span><div className="mt-2 text-[13px] font-semibold text-white">Drop proposal PDF(s) here</div><div className="mt-1 text-[11px] text-zinc-500">or click to choose · .pdf</div></>)}
            </label>
            {parseInfo && <div className="mt-3 rounded-[14px] border border-zinc-800 bg-black px-4 py-2.5 text-[12px] text-zinc-300">Parsed <span className="font-bold text-white">{parseInfo.file}</span>: <span className="text-emerald-300">{parseInfo.added}</span> new material{parseInfo.added === 1 ? '' : 's'} queued{parseInfo.duplicates ? `, ${parseInfo.duplicates} already in library` : ''}.</div>}
          </Card>

          <div className="mb-3 flex flex-wrap items-center gap-2">
            <Cube className="text-sm" />
            <h3 className="text-[12px] font-bold uppercase tracking-[0.18em] text-white">Pending recommendations</h3>
            <span className="rounded-full border border-violet-500/40 px-2 py-0.5 text-[10px] text-violet-300">{recs.length}</span>
            {recs.length > 0 && (
              <div className="ml-auto flex gap-2">
                <button onClick={acceptAll} className="rounded-[12px] border border-emerald-500/40 px-3 py-1.5 text-[11px] font-semibold text-emerald-300 transition hover:bg-emerald-500/[0.04]">✓ Accept all</button>
                <button onClick={clearRecs} className="rounded-[12px] border border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-400 transition hover:border-red-500/50 hover:text-red-400">Clear all</button>
              </div>
            )}
          </div>

          {recs.length === 0 ? (
            <Card className="p-10 text-center text-zinc-500">No pending recommendations. Upload a proposal above and any materials you&rsquo;re missing will queue here for review. 🧊</Card>
          ) : (
            <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
              {recsByCat.map(([cat, list]) => (
                <div key={cat} className="rounded-[22px] border border-zinc-800 bg-zinc-950/60 p-3">
                  <div className="mb-3 flex items-center gap-2 px-1">
                    <Cube /><h3 className="text-[12px] font-bold uppercase tracking-[0.14em] text-white">{cat}</h3>
                    <span className="ml-auto rounded-full border border-zinc-700 px-2 py-0.5 text-[10px] text-zinc-400">{list.length}</span>
                  </div>
                  <div className="space-y-2">
                    {list.map((m, i) => {
                      const conf = confTier(m.confidence || 0);
                      return (
                        <div key={m.mkey + i} className="rounded-[22px] border border-zinc-800 bg-black p-3">
                          <div className="flex items-start gap-2">
                            <div className="min-w-0 flex-1">
                              <div className="truncate text-[13px] font-semibold text-white">{m.name}</div>
                              <div className="ces-mono text-[10px] text-zinc-600">{m.sku || 'custom'} · {m.unit} · from {m.source_doc || 'proposal'}</div>
                            </div>
                            <div className="ces-mono text-[14px] font-bold text-white">{m.price ? fmt(m.price) : '—'}</div>
                          </div>
                          <div className="mt-2 flex flex-wrap items-center gap-1.5">
                            <span className={`rounded-full border px-1.5 py-0.5 text-[9px] ${conf.cls}`}>{Math.round((m.confidence || 0) * 100)}% · {conf.label}</span>
                            <div className="ml-auto flex gap-1.5">
                              <button onClick={() => acceptRec(m)} className="rounded-[10px] border border-emerald-500/40 px-2 py-1 text-[11px] font-semibold text-emerald-300 transition hover:bg-emerald-500/[0.04]">✓ Add</button>
                              <button onClick={() => rejectRec(m)} className="rounded-[10px] border border-zinc-800 px-2 py-1 text-[11px] text-zinc-500 transition hover:border-red-500/50 hover:text-red-400">✕ Reject</button>
                            </div>
                          </div>
                        </div>
                      );
                    })}
                  </div>
                </div>
              ))}
            </div>
          )}
        </>
        )}

        <footer className="mt-12 border-t border-zinc-900 pt-5 text-center text-[11px] text-zinc-600">🧊 Material Library — Ciano Estimation Suite by <span className="text-zinc-400">Bitghost.com</span></footer>
      </div>
      {toast && <div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-[16px] border border-zinc-700 bg-zinc-950/95 px-5 py-3 text-sm font-semibold text-white shadow-2xl ces-glow">{toast}</div>}
    </div>
  );
}

/* ----------------------------------------------------------------------------
 * 12. MOUNT — estimator app + admin material-library app + previewer root
 * ------------------------------------------------------------------------- */
function cesMount() {
  const render = (el, node) => {
    if (el.dataset.cesMounted) return;
    el.dataset.cesMounted = '1';
    if (ReactDOM.createRoot) { ReactDOM.createRoot(el).render(node); }
    else { ReactDOM.render(node, el); }
  };
  document.querySelectorAll('#ces-materials-app').forEach((el) => render(el, <MaterialLibrary />));
  document.querySelectorAll('#ces-estimates-app').forEach((el) => render(el, <EstimatesConsole appUrl={el.dataset.cesAppUrl || ''} />));
  document.querySelectorAll('#ces-app, .ces-react-root, #ces-preview-root').forEach((el) => render(el, <App />));
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', cesMount);
else cesMount();
