/* ============================================================================ * 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: , area: , 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